Привет, хабровчане. Подготовили для вас интересный перевод в преддверии старта курса "Golang Developer".


Если уменьшение размера бинарников Go на 6% - это то, в чем вы отчаянно нуждаетесь, то эта статья для вас. (Я проводил этот эксперимент в рамках помощи Tailscale. Следует отметить, что я инвестор.) Если же вас не сильно заботит размер двоичных файлов, что ж, может быть, вам хотя бы будет интересно почитать это для развлечения.

Чтобы получить примерные цифры для этой статьи, я взял первый попавшийся файл из моего GOPATH. Все конкретные цифры в этой статье относятся к github.com/mvdan/sh/cmd/shfmt. После нескольких экспериментов, они кажутся мне довольно репрезентативными.

В качестве базового комита я использую коммит 9d812cfa5c тулчейна Go. Это ветка master по состоянию на 29 апреля 2020 г.; вероятно, он будет схож с версией Go 1.15beta1. Я использую его, а не Go 1.14, потому что он включает в себя несколько сокращений размера бинарников, в том числе одно конкретное, которое вам обязательно понадобится, если для вас важен размер.

Существует множество способов уменьшить размер бинарника. Удаление внешних зависимостей, вероятно, самый лучший способ. Может помочь отказ от глобальных мап за счет осмотрительного использования sync.Once. Может помочь содержание разделяемого кода разделяемым с помощью косвенного обращения. Вы можете поотключать генерацию алгоритма сравнения (ну… по крайней мере там, где она вам действительно не понадобится). Зачастую вы можете сократить размер на двузначный процент, просто удалив отладочную информацию: внесите -ldflags=-w в go build.

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

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

Мы собираемся избавиться от информации о местоположении.

Мы не можем просто взять и полностью выкорчевать информацию о местоположении из бинарника. Это сломало бы много вещей.

Но мы можем сделать все номера строк одинаковыми. Это ничего не должно сломать. В конце концов, никто (кроме gofmt) не говорит, что мы должны разместить наш код в несколько строк.

Например, вместо этого:

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello, playground")
}

Мы могли бы написать так:

package main

import ( "fmt" ); func main() { fmt.Println("Hello, playground") }

Компилятор Go и рантайм должны быть подготовлены к тому, чтобы в одной строке размещалось много вещей.

Мы могли бы написать препроцессор, может быть с использованием директив -toolexec и //line, но проще просто хакнуть компилятор. К счастью, это хорошо продуманный код, поэтому нам достаточно подправить только два небольших момента.

--- a/src/cmd/compile/internal/syntax/pos.go
+++ b/src/cmd/compile/internal/syntax/pos.go
@@ -23,3 +23,3 @@ type Pos struct {
 // MakePos возвращает новый Pos для заданных PosBase, строки и столбца.
-func MakePos(base *PosBase, line, col uint) Pos { return Pos{base, sat32(line), sat32(col)} }
+func MakePos(base *PosBase, line, col uint) Pos { return Pos{base, 1, 1} }

@@ -101,2 +101,3 @@ type PosBase struct {
 func NewFileBase(filename string) *PosBase {
+       filename = "x.go"
        base := &PosBase{MakePos(nil, linebase, colbase), filename, linebase, colbase}

Каждый файл теперь называется x.go, и каждая исходная позиция имеет строку и столбец 1. (Столбцы фактически не имеют значения для размера бинарника, как только вы удалите DWARF).

Этого недостаточно. В тулчейне есть еще два места, которые недовольны, если весь код находится на x.go:1:1.

Первое заключается в создании DWARF для дебаггеров. Мы можем просто удалить эту проверку: мы уже и так удаляем DWARF, поэтому создание недопустимого DWARF не играет роли.

Второй - в cgo. Существует несколько проверок безопасности на предмет того, где может быть расположена определенная прагма cgo. Мы собираемся довериться самим себе и не нарушать их (гарантируя, что весь код продолжает строиться с неизменным тулчейном), и удалим эту проверку безопасности.

Полная версия находится на https://github.com/josharian/go/commit/1a3e66ceed.

Теперь весь код, который мы компилируем, имеет позицию x.go:1:1.

Наша программа, скомпилированная с -ldflags=-w, сжимается с 3,126,800 байт до 2,938,384 байт, или примерно на 6%.

В основном это связано с сокращением кодировки информации о местоположении. Часть этого - заслуга оптимизации компилятора.

Эти две программы компилируются немного по-разному:

func f(x []byte) {
    _ = x[0]
    _ = x[1]
}
func f(x []byte) {
    _, _ = x[0], x[1]
}

Если вы запустите go tool compile -S x.go для каждого из этих файлов, вы увидите, что первая программа содержит два отдельных вызова runtime.panicIndex. Вторая программа содержит только один такой вызов. Причина в том, что runtime.panicIndex должен отображать обратную трассировку, содержащую номер строки, вызвавшей панику. В первой программе нам нужны две отдельные паники, по одной для каждого возможного номера строки паники. Во второй программе - нет, поэтому компилятор объединяет их.

Поскольку теперь мы помещаем весь код в одну строку, компилятор может объединить больше паник, чем раньше.

Что мы теряем, делая это? Все, что требует точной информации о местоположении. При панике по-прежнему будет отображаться компьютер, функция, аргументы и т. д. Но все номера строк будут x.go:1. С достаточной долей терпения, вы все равно сможете вычислить номер строки самостоятельно на основе ПК, но это потребует некоторой ручной работы. Pprof по-прежнему сможет анализировать производительность по функциям и по инструкциям, но он будет думать, что все происходит в одной строке, что сделает анализ по номеру строки бесполезным.

Давайте немного поэкспериментируем. Что, если мы отбросим только имена файлов и сохраним истинные номера строк? Это сэкономит всего 0,9%. И, как и следовало ожидать, сохранение только корректных имен файлов и добавление всего в строку под номером 1 позволяет сэкономить 5,1%.

Таким образом, большая часть экономии достигается за счет номеров строк. Что, если мы сохраним исходные имена файлов и отсечем все номера строк до ближайшего числа, кратного 16? То есть усечем наш diff до:

--- a/src/cmd/compile/internal/syntax/pos.go
+++ b/src/cmd/compile/internal/syntax/pos.go
@@ -23,3 +23,3 @@ type Pos struct {
 // MakePos возвращает новый Pos для данной PosBase, строки и столбца.
-func MakePos(base *PosBase, line, col uint) Pos { return Pos{base, 1, 1} }
+func MakePos(base PosBase, line, col uint) Pos { return Pos{base, sat32(line/1616 + 1), 1} }

Это сократит наши двоичные файлы на 2,2%. Неплохо. Что, если вместо этого мы разделим все номера строк на 16? При этом сохраняется точно такая же информация, что и при отсечении, но нам нужно умножать вручную, чтобы получить «ближайший» номер строки.

--- a/src/cmd/compile/internal/syntax/pos.go
+++ b/src/cmd/compile/internal/syntax/pos.go
@@ -23,3 +23,3 @@ type Pos struct {
 // MakePos возвращает новый Pos для заданных PosBase, строки и столбца.
-func MakePos(base *PosBase, line, col uint) Pos { return Pos{base, 1, 1} }
+func MakePos(base *PosBase, line, col uint) Pos { return Pos{base, sat32(line/16 + 1), 1} }

Это сокращает наши двоичные файлы на 2,75%! Почему /16 экономит на 0,5% больше, чем /16*16?

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


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


Читать ещё: