Понимание Go Inline оптимизации на примере
В эпоху мобильного Интернета масштабы бизнес-систем, которые непосредственно сталкиваются с C-пользователем, как правило, очень велики, и машинные ресурсы, потребляемые системой, также весьма значительны. Количество ядер ЦП и памяти, используемых системой, поглощают реальные деньги компании. Сведение к минимуму потребления ресурсов одним экземпляром службы без снижения уровня обслуживания, что обычно известно как «есть меньше травы и производить больше молока», всегда было целью операторов каждой компании, и некоторые компании могут сэкономить сотни тысяч долларов в год за счет сокращения количества используемых ядер процессора на 1%.
При одинаковом выборе языка программирования важно постоянно снижать потребление сервисных ресурсов. Более естественно и просто полагаться, с одной стороны, на разработчиков, которые будут постоянно совершенствовать производительность своего кода, а с другой — на компиляторов языка программирования, чтобы улучшить результаты с точки зрения оптимизации компиляции. Однако эти два аспекта также дополняют друг друга: если разработчики смогут более тщательно понять сценарии и инструменты оптимизации компилятора, они смогут написать более дружественный код для оптимизации компиляции и, таким образом, получить лучшие результаты оптимизации производительности.
Основная команда Go постоянно инвестирует в оптимизацию компилятора Go и добилась хороших результатов, хотя по сравнению с возможностями оптимизации кода старых GCC и llvm еще есть много возможностей. В недавней статье «Путь разработки крупномасштабного микросервисного языка bytedance» также упоминается, что встроенная оптимизация встроенного компилятора Go bytedance (наиболее выгодное изменение) была изменена, так что код Go внутренней службы bytedance был предоставлено больше возможностей для оптимизации, что позволило повысить производительность онлайн-сервиса на 10-20% и снизить использование ресурсов памяти, что позволило сэкономить примерно 100 000 ядер.
Увидев такие очевидные результаты, я уверен, что вам, читателям, не терпится узнать о встроенной оптимизации в компиляторе Go. Не волнуйтесь, в этой статье я вместе с вами изучу и пойму встроенную оптимизацию в компиляторе Go. Я надеюсь, что эта статья поможет вам освоить следующее.
- Что такое встроенная оптимизация и в чем ее преимущества
- Где находится встроенная оптимизация в процессе компиляции Go и как она реализована
- Какой код можно оптимизировать inline, а какой пока нельзя оптимизировать inline
- Как управлять встроенной оптимизацией компилятора Go
- Каковы недостатки встроенной оптимизации
Давайте начнем с понимания того, что такое встроенная оптимизация.
Что такое оптимизация встраивания компилятора
inlining
— это распространенный инструмент оптимизации, используемый компиляторами языков программирования для функций, также известный как встраивание функций. Если функция F поддерживает встраивание, это означает, что компилятор может заменить код, который вызывает функцию F, телом функции/определением функции F, чтобы устранить дополнительные накладные расходы, вызванные вызовом функции, процесс, показанный на рисунке. ниже.
Мы знаем, что начиная с версии 1.17 Go изменился на регистровый статут вызовов. Предыдущие версии были основаны на передаче аргументов и возвращаемых значений в стеке, а накладные расходы на вызовы функций были намного выше, и в этом случае эффект встроенной оптимизации был более значительным.
Кроме того, при встроенной оптимизации решения компилятора по оптимизации могут приниматься не в контексте каждой отдельной функции (например, функция g на рисунке выше), а в контексте цепочки вызовов функций (код становится более плоским после встроенной функции замены). Например, оптимизация последующего выполнения g на приведенном выше рисунке не будет ограничена контекстом g, но позволит компилятору принять решение о последующей оптимизации в контексте цепочки вызовов g->f из-за к встраиванию f, т.е. встраивание позволяет компилятору видеть дальше и шире.
Давайте рассмотрим простой пример.
// github.com/bigwhite/experiments/tree/master/inlining-optimisations/add/add.go
//go:noinline
func add(a, b int) int {
return a + b
}
func main() {
var a, b = 5, 6
c := add(a, b)
println(c)
}
В этом примере наше внимание сосредоточено на функции добавления. над определением функции добавления мы используем //go:noinline
, чтобы указать компилятору отключить встроенную функцию для функции добавления, мы собираем программу и получаем исполняемый файл: add-without-inline, затем удалите строку //go:noinline
и выполните другую сборку. Мы получаем исполняемый файл add и используем инструмент Lensm, чтобы графически посмотреть на ассемблерный код двух исполняемых файлов и провести следующее сравнение.
Мы видим, что неинлайновая оптимизированная версия add-without-inline вызывает функцию add в основной функции с инструкцией CALL, как мы и ожидали; однако во встроенной оптимизированной версии тело функции добавления не заменяет код в том месте, где вызывается функция добавления в основной функции; место, где основная функция вызывает функцию добавления, соответствует Это инструкция сборки NOPL, которая является пустой инструкцией, не выполняющей никаких операций. Так где же ассемблерный код реализуется функцией добавления?
// Assembly code for add function implementation
ADDQ BX, AX
RET
Вывод таков: он был оптимизирован! Это то, о чем говорилось ранее, инлайнинг дает больше возможностей для последующей оптимизации. После того, как вызов функции добавления заменен реализацией функции добавления, компилятор Go может напрямую определить, что результатом вызова является 11, поэтому даже операция сложения опускается, а результат функции добавления напрямую заменяется на константа 11 (0xb), а затем константа 11 напрямую передается встроенной функции println ( MOVL 0xb, AX).
Простой тест также показывает разницу в производительности между встроенной и не встроенной надстройкой.
# Turn on inline optimization
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8 1000000000 0.2720 ns/op
PASS
ok github.com/bigwhite/experiments/inlining-optimisations/add 0.307s
# Turn off inline optimization
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8 818820634 1.357 ns/op
PASS
ok github.com/bigwhite/experiments/inlining-optimisations/add 1.268s
Мы видим, что встроенная версия примерно в 5 раз превосходит по производительности не встроенную версию.
На этом этапе многие люди могут спросить: поскольку встроенная оптимизация работает так хорошо, почему бы не встроить все функции внутри программы Go, чтобы вся программа Go стала одной большой функцией без каких-либо промежуточных вызовов функций, чтобы производительность могла стать еще выше? Хотя теоретически это может иметь место, оптимизация встраивания не обходится без накладных расходов, и эффект встраивания варьируется для функций разной сложности. Я начну с того, что вместе с вами рассмотрю накладные расходы на встроенную оптимизацию!
«Накладные расходы» на встроенную оптимизацию
Прежде чем мы действительно поймем накладные расходы на встроенную оптимизацию, давайте посмотрим, где находится встроенная оптимизация в процессе компиляции Go, т. е. где она находится.
Перейти к процессу компиляции
Как и во всех компиляторах статических языков, процесс компиляции Go можно условно разделить на следующие этапы.
- Компиляция интерфейса
Команда Go не разделяет намеренно процесс компиляции Go на переднюю и заднюю часть нашего здравого смысла. Если бы нам пришлось, анализ исходного кода (включая лексический и синтаксический анализ), проверку типов и построение промежуточного представления (Intermediate Representation) можно было бы классифицировать как логический интерфейс компиляции, а остальные ссылки за ним классифицировать как бэкэнд (конец).
Исходный код анализируется для формирования абстрактного синтаксического дерева с последующей проверкой типа на основе абстрактного синтаксического дерева. После прохождения проверки типа компилятор Go преобразует AST в промежуточное представление кода, не зависящее от целевой платформы.
В настоящее время в Go есть два типа реализации IR: одна — irgen (также известная как «-G=3» или «noder2»), irgen — это реализация, используемая начиная с Go 1.18 (которая также является структурой, подобной AST); другой — унифицированный IR, в Go 1.19. В Go 1.19 мы можем включить его с помощью GOEXPERIMENT=unified
, и, согласно последним новостям, унифицированный IR появится в Go 1.20.
Большинство процессов компиляции современных языков программирования генерируют промежуточный код (IR) несколько раз, например, упомянутая ниже статическая форма одиночного присваивания (SSA) также является формой IR. Для каждого IR у компилятора будут какие-то действия по оптимизации.
- Компиляция серверной части
Первым шагом в конце компиляции является сеанс, который команда Go называет промежуточным этапом, в ходе которого компилятор Go выполняет несколько раундов (проходов) оптимизации на основе приведенного выше промежуточного кода, включая устранение неработающего кода, встроенную оптимизацию и т. д. материализация вызова метода (девиртуализация) и анализ побега.
Девиртуализация означает преобразование метода, вызываемого через переменную интерфейса, в переменную интерфейса с динамическим типом для прямого вызова метода, исключая процесс поиска таблицы методов через интерфейс.
Далее следует промежуточный обход кода (обход), который является последним раундом оптимизации на основе приведенного выше представления IR. Он в основном разбивает сложные операторы на отдельные более простые операторы, вводит временные переменные и повторно оценивает порядок выполнения, в то время как в этом сеансе он также преобразует некоторые структуры Go высокого уровня в более простые структуры операций более низкого уровня, такие как преобразование switch операторы в алгоритм двоичного поиска или список пропусков, заменяя операции с картами и каналами вызовами во время выполнения (например, доступ к карте) и т. д.
Далее следуют последние две части бэкэнда компиляции: сначала преобразование IR в форму SSA (статическое одиночное присваивание), снова выполнение нескольких раундов оптимизации на основе SSA и, наконец, генерация машинно-зависимых инструкций по сборке на основе окончательной формы SSA. для целевой архитектуры, которые затем передаются ассемблеру для генерации перемещаемого целевого машинного кода.
Компилятор go создает перемещаемый целевой машинный код, который в конечном итоге предоставляется компоновщику для создания исполняемых файлов.
Мы видим, что инлайнинг в Go происходит в середине сессии и является средством оптимизации, основанным на промежуточном коде IR, который реализует решение на уровне IR, инлайнима функция или нет, и замена тела функции при ее вызове на inlinable функции.
Как только мы поймем, где находится встроенный Go, мы сможем примерно определить накладные расходы, связанные с встроенной оптимизацией Go.
Накладные расходы на оптимизацию Go Inline
Давайте посмотрим на накладные расходы встроенной оптимизации Go на примере. Reviewdog — это чистая реализация Go для инструмента проверки кода, который поддерживает основные платформы размещения кода, такие как github и gitlab. Он имеет размер около 12 тыс. строк (используя статистику loccount).
// reviewdog code line count results.
$loccount .
all SLOC=14903 (100.00%) LLOC=4613 in 141 files
Go SLOC=12456 (83.58%) LLOC=4584 in 106 files
... ...
Мы собираем reviewdog
с включенной и выключенной встроенной оптимизацией и собираем время сборки и размер собранных двоичных файлов со следующими результатами.
# Turn on inline optimization (default)
$time go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog 53.87s user 9.55s system 567% cpu 11.181 total
# Turn off inline optimization
$time go build -o reviewdog-noinline -gcflags=all="-l" -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-noinline -gcflags=all="-l" -a 43.25s user 8.09s system 566% cpu 9.069 total
$ ls -l
-rwxrwxr-x 1 tonybai tonybai 23080429 Oct 13 12:05 reviewdog-inline*
-rwxrwxr-x 1 tonybai tonybai 20745006 Oct 13 12:04 reviewdog-noinline*
... ...
Мы видим, что версия с включенной встроенной оптимизацией потребляет примерно на 24% больше времени на компиляцию, чем версия с отключенной встроенной оптимизацией, а результирующий размер двоичного файла примерно на 11% больше — это накладные расходы на встроенную оптимизацию, т. е. замедляет работу компилятора и увеличивает размер сгенерированного двоичного файла.
Независимо от того, включена ли встроенная оптимизация для программ уровня hello world, большую часть времени вы не увидите большой разницы ни во времени компиляции, ни в размере двоичного файла.
Поскольку мы знаем, где находится встроенная оптимизация, эти накладные расходы можно хорошо объяснить: в соответствии с определением встроенной оптимизации, как только функция решается быть встроенной, весь код в программе в том месте, где вызывается функция, заменяется. с реализацией функции, что устраняет накладные расходы времени выполнения на вызов функции, что также приводит к определенному количеству «раздувания кода» на уровне IR (промежуточного кода). «раздувание» кода на уровне IR (промежуточный код). Как упоминалось ранее, «побочный эффект» раздувания кода заключается в том, что компилятор может взглянуть на код шире и отстраненнее и, таким образом, может реализовать больше оптимизаций. Чем больше раундов оптимизации можно реализовать, тем медленнее работает компилятор, что еще больше увеличивает время, затрачиваемое компилятором; в то же время,
Go всегда был чувствителен к скорости компиляции и размеру двоичного файла, поэтому Go использует относительно консервативную стратегию оптимизации встраивания. Так как же именно компилятор Go решает, может ли функция быть встроена или нет? Давайте кратко рассмотрим, как компилятор Go решает, какие функции можно оптимизировать в процессе работы.
Принцип принятия решения встроенной функции
Как упоминалось ранее, встроенная оптимизация — это один раунд многоэтапной (проходной) оптимизации в середине компиляции, поэтому ее логика относительно независима, она выполняется на основе IR-кода, и именно IR-код изменяется. Мы можем найти основной код компилятора Go для встроенной оптимизации $GOROOT/src/cmd/compile/internal/inline/inl.go
в исходном коде Go.
Расположение и логика кода в разделе встроенной оптимизации компилятора Go могли измениться в предыдущих и будущих версиях; в настоящее время эта статья относится к коду, который является исходным кодом в Go 1.19.1.
Сеанс оптимизации IR делает две вещи: во-первых, он проходит через все функции в IR, определяет, может ли функция быть встроена с помощью CanInline, и для тех функций, которые могут быть встроены, сохраняет соответствующую информацию, такую как тело функции, для последующей замены встроенной функции; во-вторых, он заменяет все встроенные функции, вызываемые в функции. Мы сосредоточимся на CanInline, то есть на том, как компилятор Go решает, является ли функция встроенной или нет!
«Управляющая логика» встроенного процесса оптимизации находится в функции Main в $GOROOT/src/cmd/compile/internal/gc/main.go
.
// $GOROOT/src/cmd/compile/internal/gc/main.go
func Main(archInit func(*ssagen.ArchInfo)) {
base.Timer.Start("fe", "init")
defer handlePanic()
archInit(&ssagen.Arch)
... ...
// Enable inlining (after RecordFlags, to avoid recording the rewritten -l). For now:
// default: inlining on. (Flag.LowerL == 1)
// -l: inlining off (Flag.LowerL == 0)
// -l=2, -l=3: inlining on again, with extra debugging (Flag.LowerL > 1)
if base.Flag.LowerL <= 1 {
base.Flag.LowerL = 1 - base.Flag.LowerL
}
... ...
// Inlining
base.Timer.Start("fe", "inlining")
if base.Flag.LowerL != 0 {
inline.InlinePackage()
}
noder.MakeWrappers(typecheck.Target) // must happen after inlining
... ...
}
Мы видим из кода: если нет глобального отключения встроенной оптимизации (base.Flag.LowerL != 0
), то Main вызовет функцию InlinePackage встроенного пакета для выполнения встроенной оптимизации.
Код для InlinePackage выглядит следующим образом.
// $GOROOT/src/cmd/compile/internal/inline/inl.go
func InlinePackage() {
ir.VisitFuncsBottomUp(typecheck.Target.Decls, func(list []*ir.Func, recursive bool) {
numfns := numNonClosures(list)
for _, n := range list {
if !recursive || numfns > 1 {
// We allow inlining if there is no
// recursion, or the recursion cycle is
// across more than one function.
CanInline(n)
} else {
if base.Flag.LowerM > 1 {
fmt.Printf("%v: cannot inline %v: recursive\n", ir.Line(n), n.Nname)
}
}
InlineCalls(n)
}
})
}
InlinePackage перебирает каждую объявленную функцию верхнего уровня, а для нерекурсивных функций или рекурсивных функций, которые перед рекурсией охватывают более одной функции, определяет, можно ли их встроить, вызывая функцию CanInline. Независимо от того, может ли она быть встроена, функция InlineCalls затем вызывается для замены встроенной функции, вызываемой в ее определении функции.
VisitFuncsBottomUp проходится снизу вверх в соответствии с графом вызовов функций, что гарантирует, что каждый раз, когда вызывается анализ, каждая функция в списке вызывает только другие функции в списке или функции, которые уже были проанализированы (в этом случае заменены встроенными тела функций) в предыдущем вызове.
Что такое рекурсивная функция, которая охватывает более одной функции до рекурсии, посмотрите на этот пример ниже, чтобы понять.
// github.com/bigwhite/experiments/tree/master/inlining-optimisations/recursion/recursion1.go
func main() {
f(100)
}
func f(x int) {
if x < 0 {
return
}
g(x - 1)
}
func g(x int) {
h(x - 1)
}
func h(x int) {
f(x - 1)
}
f является рекурсивной функцией, но вместо того, чтобы вызывать себя, она в конечном итоге вызывает себя через цепочку функций g -> h, длина которой > 1, поэтому f можно встроить.
go build -gcflags '-m=2' recursion1.go
./recursion1.go:7:6: can inline f with cost 67 as: func(int) { if x < 0 { return }; g(x - 1) }
Функция CanInline имеет более 100 строк кода, а ее основная логика разделена на три части.
Во-первых, это определение некоторых //go:xxx directive
. Когда функция содержит следующую директиву, функция не может быть встроена.
//go:noinline
//go:norace
или построить командную строку с параметром -race//go:nocheckptr
//go:cgo_unsafe_args
//go:uintptrkeepalive
//go:uintptrescapes
- ... ...
Во-вторых, он примет решение о статусе функции, например, если тело функции пустое, его нельзя инлайнить; если проверка типов (typecheck) не сделана, ее нельзя инлайнить и т.д.
Последний вызов visitor.tooHairy
определяет сложность функции. Метод заключается в установке начального максимального бюджета для этой итерации (посетителя), который является константой (inlineMaxBudget
) и в настоящее время имеет значение 80.
// $GOROOT/src/cmd/compile/internal/inline/inl.go
const (
inlineMaxBudget = 80
)
Затем выполните итерацию по отдельным элементам синтаксиса в этой реализации функции в visitor.tooHairy
функции.
// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
... ...
visitor := hairyVisitor{
budget: inlineMaxBudget,
extraCallCost: cc,
}
if visitor.tooHairy(fn) {
reason = visitor.reason
return
}
... ...
}
Расход бюджета варьируется от элемента к элементу. Например, если append вызывается один раз, значение бюджета посетителя вычитается из inlineExtraAppendCost
, а затем, если функция является промежуточной функцией (не конечной функцией), то значение бюджета посетителя также вычитается из v.extraCallCost
, т. е. 57. И так далее. всю дорогу вниз. Если бюджет израсходован, ievbudget < 0, то функция слишком сложна для встраивания; наоборот, если бюджет по-прежнему доступен полностью, то функция относительно проста и может быть оптимизирована в процессе работы.
Почему значение inlineExtraCallCost равно 57? Это эмпирическое значение, полученное из эталона.
Как только определено, что его можно встроить, компилятор Go сохраняет некоторую информацию в поле Inl этого функционального узла в IR.
// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
... ...
n.Func.Inl = &ir.Inline{
Cost: inlineMaxBudget - visitor.budget,
Dcl: pruneUnusedAutos(n.Defn.(*ir.Func).Dcl, &visitor),
Body: inlcopylist(fn.Body),
CanDelayResults: canDelayResults(fn),
}
... ...
}
Компилятор Go устанавливает значение бюджета равным 80, по-видимому, не желая, чтобы чрезмерно сложные функции оптимизировались в процессе работы, так почему же? Основная причина заключается в том, чтобы сопоставить преимущества встроенной оптимизации с накладными расходами. Делая более сложные функции встроенными, накладные расходы увеличатся, но польза может не увеличиться значительно, т.е. так называемое «отношение ввода-вывода» будет недостаточным.
Из приведенного выше описания принципа становится ясно, что может быть лучше встраивать функции небольшого размера (низкой сложности) и вызываемые многократно. Для тех функций, которые слишком сложны, накладные расходы на вызовы функций уже очень малы или незначительны, поэтому встраивание менее эффективно.
Многие скажут: неужели после встраивания больше возможностей для оптимизации компилятора? Проблема в том, что невозможно предсказать, есть ли возможности оптимизации и какие дополнительные оптимизации будут реализованы.
Вмешательство во встроенную оптимизацию компилятора Go
Наконец, давайте посмотрим, как вмешиваться в оптимизацию встраивания компилятора Go. компилятор Go по умолчанию включает глобальную оптимизацию встраивания и следует процессу принятия решения CanInline из inl.go выше, чтобы определить, можно ли встроить функцию.
Но Go также дает нам некоторые средства для управления встраиванием, например, мы можем явно указать компилятору функции не встраивать эту функцию, давайте возьмем add.go в приведенном выше примере.
//go:noinline
func add(a, b int) int {
return a + b
}
С помощью //go:noinline
индикатора мы можем отключить встраивание add.
$go build -gcflags '-m=2' add.go
./add.go:4:6: cannot inline add: marked go:noinline
Отключение встраивания функции не влияет на замену тела функции функции InlineCalls для встроенных функций, вызываемых внутри этой функции.
Мы также можем отключить встроенную оптимизацию в большем масштабе. С помощью -gcflags '-l'
опции мы можем отключить оптимизацию в глобальном масштабе, т.е. Flag.LowerL == 0
и InlinePackage компилятора Go не будет выполняться.
Давайте проверим это с ранее упомянутым reviewdog.
# Inline is turned on by default
$go build -o reviewdog-inline github.com/reviewdog/reviewdog/cmd/reviewdog
# Turn off inline
$go build -o reviewdog-noinline -gcflags '-l' github.com/reviewdog/reviewdog/cmd/reviewdog
После этого смотрим на размер сгенерированного бинарного файла.
$ls -l |grep reviewdog
-rwxrwxr-x 1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x 1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
Мы обнаружили, что неинлайн-версия на самом деле немного больше, чем инлайн-версия! Почему это? Это связано с тем, как -gcflags
передается аргумент. Если вы просто передаете, -gcflags '-l'
как в приведенной выше командной строке, закрытие встроенной строки применяется только к текущему пакету, то есть cmd/reviewdog, и ни одна из зависимостей пакета и т. д. не затрагивается. -gcflags
поддерживает сопоставление с образцом.
-gcflags '[pattern=]arg list'
arguments to pass on each go tool compile invocation.
Мы можем установить разные шаблоны для соответствия большему количеству пакетов, например, шаблон all
может включать все зависимости текущего пакета, давайте попробуем еще раз.
$go build -o reviewdog-noinline-all -gcflags='all=-l' github.com/reviewdog/reviewdog/cmd/reviewdog
$ls -l |grep reviewdog
-rw-rw-r-- 1 tonybai tonybai 3154 Sep 2 10:56 reviewdog.go
-rwxrwxr-x 1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x 1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
-rwxrwxr-x 1 tonybai tonybai 20745006 Oct 13 20:30 reviewdog-noinline-all*
На этот раз мы видим, что reviewdog-noinline-all немного меньше, чем reviewdog-inline, потому что all
отключает встраивание для каждого из пакетов, от которых зависит reviewdog.
Вывод
В этой статье я познакомил вас с концепцией встраивания, ролью встраивания, «накладными расходами» на оптимизацию встраивания и принципами решений встраивания функций, принимаемых компилятором Go, и, наконец, я дал вам средства управления встроенная оптимизация компилятором Go.
Встроенная оптимизация — это важный инструмент оптимизации, который при правильном использовании значительно улучшит производительность вашей системы.
Группа компиляторов Go также постоянно совершенствует оптимизацию встраивания Go, начиная с поддержки встраивания только листовых функций и заканчивая поддержкой встраивания функций, не являющихся конечными узлами, и я верю, что разработчики Go продолжат получать дивиденды от производительности в этой области в будущем.