Эквивалентен ли по производительности код, представленный ниже?


// (A). Вызов HasPrefix будет встроен.
return strings.HasPrefix(s, "#")
// (B). Ручное встраивание тела HasPrefix.
return len(s) >= len("#") && s[:len("#")] == "#"

Ответ: нет.


За подробностями и объяснениями прошу под кат.




Доброго времени суток, перед тем, как раскрыть тему, хотел бы представиться.
Меня зовут Искандер и я время от времени отправляю коммиты в репозиторий golang/go.


image

Раньше я делал это от лица команды Intel Go, но наши пути разошлись и теперь я независимый контрибьютор. С недавних пор работаю в vk в команде инфраструктуры.


В свободное время делаю разные инструменты для Go, типа go-critic и go-consistent. А ещё я рисую гоферов.




Measure it!


Сразу приступим к сравнению и набросаем бенчмарк:


package benchmark

import (
    "strings"
    "testing"
)

var s = "#string"

func BenchmarkHasPrefixCall(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.HasPrefix(s, "#")
        _ = strings.HasPrefix(s, "x")
    }
}

func BenchmarkHasPrefixInlined(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = len(s) >= len("#") && s[:len("#")] == "#"
        _ = len(s) >= len("x") && s[:len("x")] == "x"
    }
}

Вместо того, чтобы рекомендовать вам benchstat, я покажу вам benchrun.


С помощью одной команды мы можем запустить оба бенчмарка и получить сравнение:


go-benchrun HasPrefixCall HasPrefixInlined -v -count=10 .
  Benchstat results:
name             old time/op  new time/op  delta
HasPrefixCall-8  9.15ns ± 1%  0.36ns ± 3%  -96.09%  (p=0.000 n=10+9)

Вариант с ручным встраиванием гораздо быстрее кода, который был получен при встраивании тела функции компилятором. Попробуем разобраться почему это происходит.


strings.HasPrefix


Вспомним реализацию strings.HasPrefix:


// HasPrefix tests whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {
    return len(s) >= len(prefix) && s[0:len(prefix)] == prefix
}

Функция HasPrefix встраивается компилятором.
Проверить это можно следующим образом:


go build -gcflags='-m=2' strings 2>&1 | grep 'can inline HasPrefix'

Для вызова strings.HasPrefix из варианта (A) мы получаем следующий машинный код:


    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)
    JLS more_stack
fn_body:
    SUBQ    $40, SP
    MOVQ    BP, 32(SP)
    LEAQ    32(SP), BP
    XCHGL   AX, AX
    MOVQ    s+56(SP), AX
    CMPQ    AX, $1
    JGE compare_strings
    XORL    AX, AX
    MOVB    AL, ~ret1+64(SP)
    MOVQ    32(SP), BP
    ADDQ    $40, SP
return:
    RET
compare_strings:
    MOVQ    s+48(SP), AX
    MOVQ    AX, (SP)
    LEAQ    go.string."#"(SB), AX
    MOVQ    AX, 8(SP)
    MOVQ    $1, 16(SP)
    CALL    runtime.memequal(SB)
    MOVBLZX 24(SP), AX
    JMP return
more_stack:
    CALL    runtime.morestack_noctxt(SB)
    JMP fn_body

Не обращайте внимания на то, что код выглядит как лапша.


На что стоит обратить внимание:


  • strings.HasPrefix действительно был вставлен, вызова нет.
  • Для сравнения строк вызывается runtime.memequal.

Но что же тогда генерируется для встроенного вручную варианта, кода из примера (B)?


    MOVQ    s+16(SP), AX
    CMPQ    AX, $1
    JLT different_length
    MOVQ    s+8(SP), AX
    CMPB    (AX), $35 // 35 - код символа "#"
    SETEQ   AL
return:
    MOVB    AL, "".~ret1+24(SP)
    RET
different_length:
    XORL    AX, AX
    JMP 22

А вот тут компилятор не генерирует вызова runtime.memequal и производится сравнение единственного символа напрямую. В идеале, то же самое он должен был сделать и для первого варианта.


Мы наблюдаем слабую сторону оптимизатора Go, её и разберём.


Оптимизации константных выражений


Причина, по которой вызов strings.HasPrefix(s, "#") может быть оптимизирован в том, что аргумент-префикс является константой. Нам известна его длина и содержимое. Нет смысла вызывать runtime.memequal для коротких строк, быстрее сделать сравнение символов "на месте", избегая лишнего вызова.


Как вы знаете, в компиляторах обычно есть как минимум две части: compiler frontend и compiler backend. Первая работает с более высокоуровневым представлением, вторая уже ближе к машине и промежуточное представление будет похожим на поток инструкций. Уже несколько версий в Go используется SSA представление для оптимизаций в backend части компилятора.


Сворачивание констант, такое как {10*2 => 20}, реализовано в backend'е. Вообще большинство операций, связанных с понижением вычислительной стоимости выражений находятся именно в этой части компилятора. Но есть исключения.


Одним из исключений является оптимизация сравнений константных строк. Когда компилятор видит сравнение строк (или подстрок), в котором один или оба операнда являются константами, генерируется более эффективный код, нежели вызов runtime.memequal.


Посмотреть отвечающий за это исходный код можно в файле cmd/compile/internal/gc/walk.go:3362.


Встраивание функций происходит до запуска этих оптимизаций, но тоже во frontend части компилятора.


Казалось бы, что же всё-таки не даёт этой оптимизации сработать в нашем случае?


Как Go встраивает вызовы функций


Вот как будет происходить встраивание:


// Вот как выглядел вызов:
return strings.HasPrefix(s, "#")

// Вот сигнатура:
func HasPrefix(s, prefix string) bool

// А вот результат встраивания:
_s, _prefix := s, "#"
return len(s) >= len(prefix) && s[:len(prefix)] == prefix

При встраивании функций компилятор присваивает аргументы временным переменным, что ломает оптимизации, поскольку алгоритм в walk.go видит не константы, а аргументы с переменными. В этом проблема.


К слову, оптимизациям из backend'а, которые имеют в распоряжении SSA, это не мешает. Но там присутствуют другие проблемы, например, невозможность восстановить высокоуровневые конструкции языка для их эффективного сопоставления (работа по устранению этого недостатка "планируется" уже несколько лет).


Ещё один пример: escape analysis


Представим функцию, которой важно выделять временный буфер на стеке:


func businessLogic() error {
    buf := make([]byte, 0, 16)
    // buf используется только локально
    // для временного хранения результатов.
    return nil
}

Поскольку buf не "убегает", компилятор сможет выделить эти 16 байтов на стеке, без аллокации на куче. Опять же, всё благодаря константному значению при вызове make. Для выделения памяти на стеке нам важно знать требуемый размер, который будет входить в состав фрейма, отводимого под вызов функции.


Допустим, в будущем нам захотелось выделять временные буферы разных размеров и инкапсулировать некоторую логику в методах. Мы ввели новую абстракцию и решили везде использовать новый тип tmpBuf. Конструирующая функция предельно проста:


func newTmpBuf(sizeHint int) tmpBuf {
    return tmpBuf{buf: make([]byte, 0, sizeHint)}
}

Адаптируем исходный пример:


func businessLogic() error {
-   buf := make([]byte, 0, 16)
+   buf := newTmpBuf(16)
    // buf используется только локально
    // для временного хранения результатов.
    return nil
}

Конструктор будет встраиваться, но вот аллокация теперь всегда будет на куче, по той же причине передачи аргументов через временные переменные. Escape analysis будет видеть make([]byte, 0, _sizeHint), что не попадает под его паттерны распознавания оптимизируемых вызовов make.


Если бы у нас было "всё как у людей", проблемы бы не существовало, после встраивания конструктора newTmpBuf было бы ясно, что размер всё так же известен на этапе компиляции.


Это огорчает чуть ли не сильнее, чем ситуация со сравнением строк.


Горизонты Go 1.13


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


image

Если вы считаете, что описанная в статье проблема действительно нуждается в решении, поставьте, пожалуйста, палец вверх в соответствующем issue.





Моя позиция состоит в том, что встраивать код руками только потому что он работает быстрее в текущей версии Go — это неправильно. Нужно исправить этот недочёт в оптимизаторе, хотя бы до того уровня, чтобы примеры, описанные выше, работали без неожиданных регрессий производительности.


Если всё пойдёт по плану, эта оптимизация войдёт в релиз Go 1.13.


Спасибо за внимание.


Дополнение: предложенное решение


Эта секция для самых храбрых, тех, кто ещё не устал читать.


Итак, у нас несколько мест, которые работают хуже при использовании переменных вместо их значений напрямую. Предложенное решение состоит в том, чтобы ввести во frontend части компилятора новую функцию, которая позволяет по имени получить последнее привязанное значение. После этого в каждой оптимизации, которая ожидает константное значение, не сдаваться при обнаружении переменной, а получать это ранее сохранённое состояние.


Сигнатура нашей новой функции может выглядеть так:


func getConstValue(n *Node) *Node

Определение Node можно посмотреть в файле syntax.go.


Каждому определению переменной соответствует Node с тегом ONAME. Внутри Node.Name.Defn для большинства таких переменных имеется инициализирующее значение.


Если Node уже литерал, ничего делать не нужно и мы просто возвращаем n. Если же это ONAME (переменная), то можно попробовать извлечь из n.Name.Defn то самое инициализирующее значение.


Но как быть с модификациями между объявлением и чтением переменной, для которой мы вызываем getConstValue? Если мы ограничимся только read-only переменными, то никакой проблемы не существует. В frontend'е Go уже есть специальные флаги ноды, которые отмечают подобные имена. Если переменная модифицировалась, getConstValue не будет возвращать инициализирующее значение.


Программисты, обычно, не модифицируют входные аргументы числовых и строковых типов и это позволяет покрыть довольно большое число случаев этим примитивным алгоритмом.


Теперь мы готовы рассмотреть реализацию:


func getConstValue(n *Node) *Node {
    // Мы раскрываем только ONAME у которых доступен definition.
    if n.Op != ONAME || n.Name.Defn == nil {
        return n
    }

    // Проверка на то, что инициализирующее значение не изменялось.
    // Заметим, что это очень консервативно, но нашу задачу по
    // исправлению проблемы встраивания функций и escape analysis'а решает.
    maybeModified := n.Assigned() || n.Name.Defn.Assigned() || n.Addrtaken()
    if maybeModified {
        return n
    }

    // OAS - Node типа присваивания.
    // n.Name.Defn.Left - это LHS.
    // n.Name.Defn.Right - это RHS.
    // consttype(v) возвращает константный тип инициализирующего выражения.
    // Если это CTxxx, то переданное выражение не является константным.
    if n.Name.Defn.Op == OAS {
        v := n.Name.Defn.Right
        if v != nil && consttype(v) != CTxxx {
            return v
        }
    }
    return n    
}

Примерно вот так меняется код, который зависит от констант:


- i := indexconst(r)
+ i := indexconst(getConstValue(r))

Отлично, и оно даже работает:


n := 10
xs := make([]int, n) // Теперь не убегает в кучу!

До этого изменения escape analysis не мог получить через n значение 10, из-за чего делал предположение о необходимости разместить xs в куче.


Код выше синтаксически похож на ситуацию, наблюдаемую при встраивании. n может быть временной переменной, которая добавляется при передаче аргумента.


К сожалению, есть нюансы.


Мы решили проблему для локальных переменных, вводимых через OAS, но Go инициализирует переменные для встроенных функций через OAS2. Из-за этого нам понадобится второе изменение, расширяющее функцию getConstValue и немного модифицирующее код самого inliner'а, ведь, помимо прочего, OAS2 не имеет подходящего поля Defn.


Это были плохие новости. Хорошие новости: в русскоязычном слаке появился канал #gocontributing, где можно делиться своими идеями и планами, задавать вопросы, а также обсуждать всё, что связано с участием в разработке Go.

Комментарии (13)


  1. freezlite
    05.02.2019 16:31
    -1

    Зачем в этой статье логотипы VK?


    1. quasilyte Автор
      05.02.2019 17:45

      Зачем в этой статье логотипы VK?

      В основном потому что мне захотелось его туда добавить.


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


      Секцию "о себе" в первый раз использовал. Возможно слегка переборщил и можно было без самого лого, одного лишь талисмана оставить.


  1. Sly_tom_cat
    05.02.2019 18:04

    Правильная идея. Поддерживаю.

    Тоже натыкался на подобные неочевидности — когда руками инлайнишь то получается совсем не то что оптимизатор инлайнит. Но так глубоко не копал.

    И спасибо за хорошие инструменты!

    Про benchrun — не слышал — но идея интересная — надо будет попробовать…


  1. JekaMas
    05.02.2019 19:16

    Отличное исследование и подход! За issue отдельное спасибо.


  1. datacompboy
    05.02.2019 22:10
    +1

    Не хватает описания решения для улучшения. Просто для полноты статьи, и потому, что это самое вкусное.


    Спасибо за улучшения:)


    1. quasilyte Автор
      05.02.2019 22:15
      +1

      О, об этом я как-то не подумал. Хорошее дополнение, спасибо!

      Я тогда допишу в скором времени в этой же статье.


      1. datacompboy
        06.02.2019 02:26

        Спасибо! Примерно так и ожидал, учитывая основной юзкейс :) но, кстати, позволяет и расширять потом буде нуна.


    1. quasilyte Автор
      06.02.2019 00:17
      +1

      Готово. А ещё приглашаю в канал #gocontributing в слаке, если вам там ещё нет. :)


  1. recompileme
    06.02.2019 00:46

    Не могли бы вы пояснить такой момент.
    Вот функция: https://github.com/recoilme/pudge/blob/master/pudge.go#L206
    Там есть свитч и нелепый момент. Внутри части кейсов, создаётся переменная buf. Выглядит как будто я упорот и люблю писать одну и ту же строку 10 раз. Но дело в том, что если вынести создание buf до свича, код замедлится, даже если я не хожу в кейс, в котором юзается буф. Те компилятор не оптимизирует код разворачивая свич на несколько веток, а создаёт буфер на куче независимо от того юзал я его или нет. Я совсем не силен в компиляторах и возможно так и должно быть или это недостаток? Замедление нескщественное точно не помню, с десяток наносекунд или сотня, и обычно не стоит упарываться, дублируя код ради них, тут просто база данных, и этот код повсюду вызывался.


    1. quasilyte Автор
      06.02.2019 01:29
      +1

      Лучше запускать компиляцию с флагом -gcflags='-m=2', тогда будет понятно, что происходит с аллокациями, что идёт в кучу, а что на стек:


      foo.go:16:13: new(bytes.Buffer) escapes to heap
      foo.go:16:13:   from buf (assigned) at foo.go:16:7
      foo.go:16:13:   from buf (interface-converted) at foo.go:17:21
      foo.go:16:13:   from buf (passed to call[argument escapes]) at foo.go:17:21

      Даже если у вас new(bytes.Buffer) внутри case, он будет размещён на куче. Когда buf передаётся в качестве io.Writer, будет безусловная аллокация.


      Вообще interface{} с производительностью слабо совместим.
      Множественные case в type switch плохи как минимум тем, что туда значение пробрасывается как interface{}.


      Если нужна скорость, сделайте числовые типы первыми и используйте для (u)int16/32/64 методы binary.BigEndian.PutUint16/32/64 напрямую.
      Это может быть эффективнее.


      Учтите, что type switch выполняет ветки последовательно, поэтому более частые типы стоит ставить первыми.
      Для числовых типов вы вполне можете сделать пути выполнения без лишних аллокаций.
      Слайсы, выделяемые в "buf := make([]byte, 4)", где размер заранее известен, Go спокойно размещает на стеке (но в этой функции буфер убегает через return, так что всё равно ему место в куче).


      Вот пример для специализации ветки uint64: https://play.golang.org/p/1srtJDsIFIJ.
      Разница в производительности:


      $ go-benchrun Old New -benchmem -count=10
        Running old benchmarks:
      goos: linux
      goarch: amd64
      BenchmarkOld-8       1000000          1402 ns/op         816 B/op         11 allocs/op
      BenchmarkNew-8      50000000            24.3 ns/op         8 B/op          1 allocs/op
      PASS
      ok      _/<ВЦ>  12.802s
        Benchstat results:
      name   old time/op    new time/op    delta
      Old-8    1.42µs ± 5%    0.03µs ± 3%  -98.24%  (p=0.000 n=10+10)
      
      name   old alloc/op   new alloc/op   delta
      Old-8      816B ± 0%        8B ± 0%  -99.02%  (p=0.000 n=10+10)
      
      name   old allocs/op  new allocs/op  delta
      Old-8      11.0 ± 0%       1.0 ± 0%  -90.91%  (p=0.000 n=10+10)

      Надеюсь, это является ответом на ваш вопрос.


      1. recompileme
        06.02.2019 09:13

        Спасибо, за подробный ответ. Для типа []byte, сделано как вы советуете. В целом это грустно. Получается избежать кучи практически невозможно. Пойду проголосую за ваш ишью (мне нравится пользоваться функциями)


  1. bolshaaan
    06.02.2019 11:41

    Подскажите, плиз, а чем выделение памяти на стеке лучше, чем в куче — намного быстрее?


    1. quasilyte Автор
      06.02.2019 11:45

      Быстрее выделение, быстрее очистка, не увеличивает объём работы для GC.
      А ещё по времени выполнения более детерменированно.


      То, что в Go есть типы-значения (то, что можно использовать не по указателю), позволяет больше всего на стеке размещать, что может иногда сильно помогать.


      Escape analysis правда до сих пор многие вещи не распознаёт, но у Matthew Dempsky как-то делился своим полным переписыванием этой части, может, её дорабатывать будет проще. :)