Изучал коммиты, которые попали в релиз Go 1.27 и наткнулся на ряд улучшений, которые не отобразили в release notes.
Интересующие нас коммиты в этом посте: f4bfb1a9c6, 9be7615aa2, 5af294bac7, 84e0c4965a, 1bcfdf2df2.
Главное: теперь на heap перестанут утекать аргументы переданные в замыкания, если компилятор сможет статически определить, какая функция будет вызвана. Проблема была в том, что компилятор не знал, кто именно будет вызван, и консервативно считал, что все аргументы вызова утекают в heap.
Давайте посмотрим на пару примеров:
func Apply() int {
var f func([]int) int
f = func(p []int) int { return p[0] * 2 }
arr := make([]int, 2)
return f(arr)
}
Пример достаточно вычурный, но явно показывает разницу.
Бенчмарки: Go 1.26:
BenchmarkApply-10 93288418 11.95 ns/op 16 B/op 1 allocs/op
Go 1.27:
BenchmarkApply-10 590856250 2.028 ns/op 0 B/op 0 allocs/op
Компиляция с -gcflags=-m сразу укажет на причину.
Go 1.27:
./demo.go:26:6: func literal does not escape
./demo.go:27:13: make([]int, 2) does not escape
Go 1.26:
./demo.go:26:6: func literal does not escape
./demo.go:27:13: make([]int, 2) escapes to heap
Но не все так просто. У меня есть и контрпример, когда escape analysis промахивается:
var g func(*int) int = func(p *int) int { return *p * 3 }
func ApplyUnresolvable() int {
var f func(*int) int
f = func(p *int) int { return *p * 2 }
f = g
return f(new(int))
}
А в этом примере аргумент замыкания f убежит на хип, потому что в момент вызова f(new(int)) в f может лежать любое из присвоенных значений. Значение g компилятор доказать не может — это переменная уровня пакета, её содержимое escape analysis не отслеживает, то есть «аргумент может утечь куда угодно». Например, что если g будет запускать goroutine внутри. Поэтому тут компилятор выбирает консервативное поведение.
Теперь я покажу, как с переходом с 1.26 на 1.27 можно получить почти восьмикратное ускорение!
type Node struct {
Val int
Children []*Node
}
func Sum(root *Node) int {
var visit func(*Node) int
visit = func(n *Node) int {
t := n.Val
for _, c := range n.Children {
t += visit(c)
}
return t
}
return visit(root)
}
func BuildAndSum(v int) int {
root := &Node{Val: v, Children: []*Node{{Val: 1}, {Val: 2}}}
return Sum(root)
}
Здесь ни один экземпляр Node не утечет на хип! Смотрим на бенчмарки. Go 1.26
BenchmarkBuildAndSum-10 17728094 66.86 ns/op 112 B/op 4 allocs/op
Go 1.27
BenchmarkBuildAndSum-10 148023318 8.561 ns/op 0 B/op 0 allocs/op
Как и обещал ускорение примерно в 8 раз :)
P.S. Вообще удивительно как интересные вещи пропускают в release notes. Я нашел как минимум еще три изменения, которых нет в release notes, но прямо влияющих на перф. Одна из них так вообще прямо влияет на то как мы пишем бенчмарки! Но об этом в другой раз ;)
Если вам понравилась статья и вы хотите узнать про три других изменения, то заглядывайте в мой Telegram-канал.