Привет Хабр. Последний свой пост я публиковал сравнительно недавно, так что вряд ли вы успели забыть, что меня зовут Марко. Сегодня публикую перевод небольшой заметки, которая касается нескольких очень вкусных оптимизаций из еще не вышедшего Go 1.9. Эти оптимизации позволяют генерировать меньше мусора в большинстве программ на Go. Меньше мусора – меньше задержки и затраты на сборку этого мусора.


Эта статья о новых оптимизациях компилятора, которые готовятся к релизу Go 1.9, но я бы хотел начать разговор с логирования.


Пару недель назад Питер Бургон начал тред на golang-dev с предложением стандартизировать логирование. Оно используется везде, так что вопрос производительности стоит довольно остро. Пакет go-kit использует структурное логирование, которое основывается на следующем интерфейсе:


type Logger interface {
    Log(keyvals ...interface{}) error
}

Пример вызова:


logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")

Заметьте: всё, что передаётся в вызов Log, преобразуется в интерфейс. А это значит, что будет много операций выделения памяти.


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


logger.Info("Failed to fetch URL.",
  zap.String("url", url),
  zap.Int("attempt", tryNum),
  zap.Duration("backoff", sleepFor),
)

Аргументы для logger.Info имеют тип logger.Field. logger.Field – это структура а-ля union, которая содержит тип и поле для каждого из string, int, и interface{}. И получается, что интерфейсы не нужны для передачи основных типов значений.


Но довольно о логировании. Давайте разберёмся, почему преобразование значения в интерфейс иногда требует выделения памяти.


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


Но всё же его данные немного устарели. Автор указывает на очевидную оптимизацию: когда размер значения равен размеру указателя или меньше него, мы можем просто положить значение вместо указателя во второе слово интерфейса. Однако с появлением конкурентного сборщика мусора эта оптимизация была убрана из компилятора, и сейчас второе слово – всегда просто указатель.


Предположим, у нас есть такой код:


fmt.Println(1)

До Go 1.4 он не приводил к выделению памяти, так как значение 1 можно было положить напрямую во второе слово интерфейса.


То есть компилятор делал примерно следующее:


fmt.Println({int, 1}),

Где {typ, val} представляет два слова интерфейса.


После Go 1.4 этот код начал приводить к выделению памяти, так как 1 не является указателем, а второе слово интерфейса теперь обязано быть указателем. И получается, что компилятор и рантайм делали примерно следующее:


i := new(int) // allocates!
*i = 1
fmt.Println({int, i})

Это было неприятно, и много копий было сломано в словесных баталиях после этого изменения.


Первая значительная оптимизация по избавлению от аллокаций была сделана чуть позже. Она срабатывала, когда результирующий интерфейс не убегал (примечание переводчика: термин из escape analysis). В этом случае временное значение можно выделить на стеке вместо кучи. Используя пример выше:


i := new(int) // now doesn't allocate, as long as e doesn't escape
*i = 1
var e interface{} = {int, i}
// do things with e that don't make it escape

К сожалению, многие интерфейсы убегают, включая те, что в fmt.Println и в моих примерах по логированию выше.


К счастью, в Go 1.9 появятся ещё несколько оптимизаций, на реализацию которых разработчиков вдохновил тот самый разговор о логировании (если, конечно, в последний момент их не откатят, что всегда возможно).


Первая оптимизация заключается в том, чтобы не выделять память, когда мы конвертируем константу в интерфейс. Так что fmt.Println(1) больше не будет приводить к выделению памяти. Компилятор кладёт значение 1 в глобальную область памяти, доступную только для чтения. Примерно так:


var i int = 1 // at the top level, marked as readonly

fmt.Println({int, &i})

Это возможно, поскольку константы неизменны (иммутабельны) и останутся такими в любом случае.


На эту оптимизацию разработчиков вдохновило как раз обсуждение логирования. В структурном логировании большое количество аргументов являются константами (точно все ключи и, наверное, часть значений). Вспомните пример из go-kit:


logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")

Этот код после Go 1.9 приведёт только к одной операции выделения памяти вместо шести, так как пять из шести аргументов являются константными строками.


Вторая новая оптимизация заключается в том, чтобы не аллоцировать память при конвертации булевых значений и байтов в интерфейсы. Эта оптимизация реализована добавлением глобального [256]byte массива под названием staticbytes во все результирующие бинарники. Для данного массива верно, что staticbytes[b] = b для всех b. Когда компилятор хочет положить булевое значение, или uint8, или какое-либо другое однобайтовое значение в интерфейс, то вместо аллокации он кладёт туда указатель на элемент этого массива. Например, так:


var staticbytes [256]byte = {0, 1, 2, 3, 4, 5, ...}

i := uint8(1)
fmt.Println({uint8, &staticbytes[i]})

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


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


Довольно интересно взаимодействие между API и какими-то решениями в реализации.


Выбор и создание API требует обдумывания вопросов производительности. Не случайно ведь интерфейс io.Reader позволяет вызывающему коду использовать свой буфер.


Производительность является важным аспектом тех или иных решений. Как мы увидели выше, детали реализации интерфейсов влияют на то, где и когда произойдут операции выделения памяти. И в то же время эти самые решения зависят от того, какой код пишут люди. Авторы компилятора и рантайма стремятся оптимизировать реальный, часто используемый код. Так, решение сохранить в Go 1.4 два слова для интерфейсов вместо добавления третьего, что вызвало бы лишнюю операцию выделения памяти в fmt.Println(1), базировалось на рассмотрении кода, который пишут реальные люди.


А поскольку то, какой код пишут люди, зачастую зависит от того, какие API они используют, у нас получается такая вот обратная связь, которая с одной стороны восхищает, а с другой – иногда сложна для управления.


Возможно, это не очень глубокое наблюдение, но всё же: если вы проектируете API и беспокоитесь о производительности, держите в голове не только то, что компилятор и рантайм делают, но и то, что они могли бы делать. Пишите код для настоящего, но проектируйте API для будущего.


И если вы не уверены, – спрашивайте. Это сработало (немножко) для логирования.

Поделиться с друзьями
-->

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


  1. youROCK
    15.05.2017 18:33

    Я вот только не очень понял, почему именно с конкурентным сборщиком мусора стало нельзя совать значения в интерфейсы «как есть»


    1. mkevac
      15.05.2017 18:37

      Там есть ссылка на github. Или ты не очень понял пояснение по ссылке?


      1. youROCK
        15.05.2017 20:03
        +1

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


      1. QtRoS
        15.05.2017 22:48

        Думаю, было бы полезнее ответить: первое слово (word в оригинале) это тип данных, а второе либо указатель, либо данные. Но поскольку атомарности чтения нет, может сложиться ситуация, когда будет прочитан тип (первое слово), но перед чтением второго слова тип поменяется, тогда может произойти, например, обращение к данным как к указателю. Чтение двух слов атомарно поддерживается не на всех платформах.


        1. Deosis
          16.05.2017 06:27

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


          1. t0pep0
            16.05.2017 17:14

            Вжух-вжух
            var arg interface{}
            arg = 1;
            arg = false;


  1. gobwas
    16.05.2017 12:30
    +2

    Спасибо за перевод и ссылки!


    Возник следующий вопрос (больше даже относящийся к статье Russ Cox):


    package main
    
    import (
            "testing"
    )
    
    type Stringer interface {
            String() string
    }
    
    func GetString(s Stringer) string {
            return s.String()
    }
    
    type Text struct {
            val string
            pad [24]byte
    }
    
    func (t Text) String() string {
            return t.val
    }
    
    func BenchmarkGetString(b *testing.B) {
            for i := 0; i < b.N; i++ {
                    t := Text{val: "hello"} // Moved to heap, 24 bytes for pad and 8 bytes for string.
                    _ = GetString(&t)       // Allocation for Stringer 8 + 8 bytes.
            }
    }

    Если запустить следующую команду:


    go test iface_test.go -gcflags="-m -N -l" -bench=. -benchmem

    Мы видим, среди прочего:


    ./iface_test.go:26: moved to heap: t
    ...
    BenchmarkGetString-4    30000000                56.7 ns/op            48 B/op          1 allocs/op

    Т.е. в каждой итерации выделяется память на heap под структуру Text, и интерфейс Stringer. Почему нельзя в данном случае ограничиться стеком?


    1. mkevac
      16.05.2017 13:18

      Привет. Я не специалист в компиляторах, но предполагаю что это возможно. Просто Go-шный escape анализатор этого не умеет пока.

      Я взял и упростил твой код до такого, когда никаких строк нет. И даже return value нет. Только вызов функции интерфейса.

      $ cat lala_test.go 
      package lala
      
      import (
      	"testing"
      )
      
      type Fooer interface {
      	Foo()
      }
      
      func GetString(s Fooer) {
      	s.Foo()
      }
      
      type Text struct {
      	val string
      	pad [24]byte
      }
      
      func (t Text) Foo() {
      }
      
      func BenchmarkGetString(b *testing.B) {
      	for i := 0; i < b.N; i++ {
      		t := Text{val: "hello"} // Moved to heap, 24 bytes for pad and 8 bytes for string.
      		GetString(&t)           // Allocation for Stringer 8 + 8 bytes.
      	}
      }
      


      Если убрать вызов

      s.Foo()
      


      из

      func GetString(s Fooer)
      


      то аллокации не будет. Предполагаю что любой такой вызов по интерфейсу приведет к escape, т.к. анализатор не знает что именно произойдет в вызываемой функции.

      P.S. Чтобы получить больше подробностей, можно передать -m -m (два раза).

      Чуть-чуть похожий тикет есть https://github.com/golang/go/issues/17332. Но я бы порекомендовал создать еще один с этим конкретным тест кейсом.

      А искать по коду нужно по строке «receiver in indirect call»:

      marko@marko-ubuntu:~/go/src ((go1.8.1)) $ ack "receiver in indirect call"
      cmd/compile/internal/gc/esc.go
      1511:				e.escassignSinkWhy(call, r, "receiver in indirect call")
      


      1. gobwas
        16.05.2017 17:42
        +1

        Еще немного упростил пример:


        package main
        
        import (
                "testing"
        )
        
        type Fooer interface {
                Foo()
        }
        
        type Concrete struct {
                data byte // Just to see in allocation.
        }
        
        func (c Concrete) Foo() {}
        
        func HandleFooer(f Fooer) {}
        
        func BenchmarkCallFooer(b *testing.B) {
                for i := 0; i < b.N; i++ {
                        c := Concrete{} // Moved to heap.
                        Fooer(&c).Foo()
                }
        }
        
        func BenchmarkPassFooer(b *testing.B) {
                for i := 0; i < b.N; i++ {
                        c := Concrete{} // Not moved to heap.
                        HandleFooer(Fooer(&c))
                }
        }

        Результат:


        BenchmarkCallFooer-4    100000000               17.6 ns/op             1 B/op          1 allocs/op
        BenchmarkPassFooer-4    500000000                2.99 ns/op            0 B/op          0 allocs/op

        Да, видимо, анализатор не учитывает конкретную реализацию Foo() объекта внутри Fooer, и поэтому кладет его на heap.


        Попробую создать тикет.


        P.S. Спасибо за -m -m! Не знал. =)


        1. mkevac
          16.05.2017 18:08

          Кинь линк на тикет, как создашь! :-)


          1. gobwas
            16.05.2017 18:13

            Ой, уже кинул ниже )



    1. t0pep0
      16.05.2017 17:12

      Смотрите, если eface (interface{}), состоит из двух слов (указатель на структуру описывающую тип и указатель на данные), то в iface (в Вашем случае — Stringer) в себя ещё включает таблицу методов.
      В данном случае аллокация, на данный момент неизбежна, так-как в рантайме происходят несколько вещей:

      • Проверка на соответсвие передаваемого типа интерфейсу (наличию методов)
      • Создание интерфейса
      • И самое главное: вызов метода через интерфейс(переходим к объекту в памяти, от него к таблице методов и вызываем)

      Сделать это не засунув t в кучу — довольно проблематично, насколько мне известно. (А вот если GetString будет принимать напрямую Text, а не интерфейс, то аллокаций не будет)
      Другое дело, что в данном примере t — статичен и можно было произвести оптимизацию, но, я достаточно плохо разбираюсь в компиляторах, что бы оценить сложность таких оптимизаций