Изучал коммиты, которые попали в релиз 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-канал.















