ЗАМЕР
bench/goiter/cost_test.go
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/go/iteration/range-over-func
- Прогон
- go1.24.7 linux/amd64, Intel Xeon 2.80GHz
- Как запустить
go run bench/goiter/mechanics.go # это работает и из корня cd bench/goiter && ./cost.sh cd bench/goiter && go test . # проверка, что все способы дают одну сумму
Запись прогона
Замеры для статьи «Цикл по функции в Go»
| Файл | Что делает |
|---|---|
mechanics.go |
шесть наблюдений без единого замера времени: кто кого вызывает, во что превращаются break и continue, почему return из тела не выходит сразу, четыре ошибки итератора, которые ловит рантайм, вложенность и метки, iter.Seq2 со стандартной библиотекой |
cost_test.go |
цена: обход среза пятью способами, когда компилятор видит итератор и когда не видит |
cost.sh |
гоняет cost_test.go чередующимися раундами и печатает диапазоны |
Каталог — отдельный модуль Go, поэтому замеры запускаются из него:
go run bench/goiter/mechanics.go # это работает и из корня
cd bench/goiter && ./cost.sh
cd bench/goiter && go test . # проверка, что все способы дают одну сумму
mechanics.go помечен //go:build ignore: он package main, а рядом лежит
тест пакета goiter, и без метки go test ./... спотыкался бы о два пакета в
одном каталоге. На go run с явным именем файла метка не влияет.
Что здесь важно прочитать правильно
Главное в статье — не наносекунды. Оно в mechanics.go: тело цикла по
функции — это отдельная функция, которую вызывает итератор, и почти все
неожиданности растут отсюда. Замеры отвечают на второй по важности вопрос —
«сколько это стоит».
Цена итератора — не одно число, а два, и между ними разница в шесть раз. Она зависит ровно от одного: смог компилятор вложить итератор или нет. Поэтому блоки 7 и 8 обязаны читаться вместе; по отдельности каждый из них вводит в заблуждение.
Замерять это на b.Loop нельзя, и это не мелочь. Начиная с Go 1.24
компилятор не вкладывает вызовы внутри тела b.Loop — в
cmd/compile/internal/inline/interleaved/interleaved.go так и написано:
«No inlining nor devirtualization performed on b.Loop body». Замер на b.Loop
всегда показывает только дорогую половину. Блок 10 показывает обе, чтобы
разницу было видно, а не пришлось принимать на веру.
По времени сопоставляются только строки внутри одного блока. В каждом
блоке все строки дают одинаковый результат — это проверяет TestSameWork, —
отличается лишь способ обхода. Блоки друг с другом не сопоставляются.
Печатается диапазон, а не лучший раунд. Если диапазоны двух строк перекрываются, разницы между ними нет, сколько бы её ни казалось по одному прогону.
Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.80GHz)
Срез из 10 000 int, тело складывает значения. Семь чередующихся раундов.
Компилятор видит, какая функция придёт:
| как | нс/оп | Б/оп | выделений |
|---|---|---|---|
обычный range по срезу |
4719–4965 | 0 | 0 |
range по slices.Values |
4063–4484 | 0 | 0 |
range по рукописному iter.Seq |
4089–4250 | 0 | 0 |
iter.Seq параметром функции |
4015–4384 | 0 | 0 |
| обратный вызов, без итератора | 4042–4351 | 0 | 0 |
Компилятор не видит:
| как | нс/оп | Б/оп | выделений |
|---|---|---|---|
обычный range по срезу |
4713–5093 | 0 | 0 |
iter.Seq в функцию без вкладывания |
28691–30609 | 42 | 3 |
iter.Seq из пакетной переменной |
29448–31696 | 42 | 3 |
Это главный результат: одна и та же строка кода стоит либо ноль, либо вызов на каждый элемент, и решает это не итератор, а то, известна ли компилятору функция в точке обхода.
Про десятую долю, на которую вложенный итератор оказался быстрее обычного
range: из неё ничего не следует. Это два разных, но равносильных цикла, и
знак такой разницы меняется от версии к версии. Следует другое — ожидаемой
шестикратности в первой таблице нет.
Наблюдения mechanics.go
- Тело цикла — отдельная функция, и у неё есть имя. Стек вызовов из тела
двух вложенных циклов по функциям выглядит так (снизу вверх):
main.block1→main.one→main.block1-range2→main.other→main.block1.one.block1-range2-range4. Кадры чередуются: итератор, тело, итератор, тело. Суффикс-rangeNпридумывает компилятор; составное имя у верхнего кадра — след вкладывания. - Итератор не знает слов
breakиcontinue. Он видит только булев результатyield:continue— этоreturn true,break—return false. returnиз тела не выходит немедленно. Сначалаyieldвозвращаетfalse, итератор доигрывает и отрабатывает свойdefer, и только потом возвращается вызывающая функция. Практическое следствие важнее самого факта: уборка вdeferу итератора выполняется при любом выходе из тела.- Рантайм ловит четыре ошибки итератора, и все четыре — во время работы,
а не при компиляции:
range function continued iteration after function for loop body returned false,... after loop body panic,... after whole loop exit,range function recovered a loop body panic and did not resume panicking. - Последняя — самая неочевидная. Итератор с общим
defer recover()выглядит как разумная защита, но паника из ТЕЛА цикла проходит через кадр итератора, и такойrecoverсъедает чужую панику. breakс меткой через два уровня — не прыжок, а две остановки подряд. Оба итератора получаютfalseи оба доигрывают: сначала внутренний, потом внешний. Уборка обоих выполняется.
Источники
- Спецификация Go, «For statements with range clause» — https://go.dev/ref/spec#For_range
- Пакет
iter— https://pkg.go.dev/iter cmd/compile/internal/rangefunc/rewrite.goв GOROOT: как компилятор переписывает цикл, включая переменную#nextи состояния#stateNcmd/compile/internal/inline/interleaved/interleaved.goв GOROOT: почему внутриb.Loopничего не вкладываетсяruntime/panic.goв GOROOT: тексты четырёх ошибок- The Go Blog. Range Over Function Types — https://go.dev/blog/range-functions
Скрипт
294 строк// Цена цикла по функции: три группы, и внутри каждой строки делают ОДНУ И ТУ
// ЖЕ работу.
//
// ЧТО ЗДЕСЬ СРАВНИМО. Внутри группы — всё: каждая строка складывает одни и те
// же числа одного и того же среза и обязана дать одну и ту же сумму (это
// проверяется, см. TestSameWork). Отличается только способ обхода. Между
// группами сравнивать нельзя.
//
// ПОЧЕМУ СУММА, А НЕ ПУСТОЕ ТЕЛО. Пустое тело компилятор вправе выбросить
// вместе с циклом, и замер выродится в измерение пустоты. Сумма дешева, но
// наблюдаема: результат уходит в пакетную переменную, и выбросить его нельзя.
//
// ПОЧЕМУ ЦИКЛ ПО b.N, А НЕ b.Loop. Это здесь не стилистика, а условие
// осмысленности замера. Начиная с Go 1.24 компилятор НЕ ВКЛАДЫВАЕТ вызовы
// внутри тела b.Loop: в cmd/compile/internal/inline/interleaved/interleaved.go
// это записано прямым текстом — «No inlining nor devirtualization performed on
// b.Loop body». Для обычного кода это неважно, а для итератора важно как
// ничто другое: вся его цена и определяется тем, вложил компилятор функцию
// или нет. Замер на b.Loop измеряет только второй случай и о первом молчит.
// Обе стороны этой разницы измерены здесь отдельно — см. группу Noinline и
// одноимённые бенчмарки с суффиксом Loop.
//
// ЗАПУСК:
//
// cd bench/goiter && ./cost.sh
//
// Снято на go1.24.7 linux/amd64.
package goiter
import (
"iter"
"slices"
"testing"
)
const N = 10000
// Приёмник результата: не даёт компилятору выбросить тело цикла.
var sink int
func data() []int {
s := make([]int, N)
for i := range s {
s[i] = i
}
return s
}
// Рукописный итератор — ровно то, что показано в документации пакета iter.
func values(s []int) iter.Seq[int] {
return func(yield func(int) bool) {
for _, v := range s {
if !yield(v) {
return
}
}
}
}
// Обход обратным вызовом — способ, которым это писали до Go 1.23. Отличается
// от итератора ровно одним: тело не может ни выйти из внешней функции, ни
// прервать обход, потому что возвращать ему нечего.
func each(s []int, f func(int)) {
for _, v := range s {
f(v)
}
}
// Итератор, пришедший параметром. Компилятор вправе вложить и sumSeq, и
// пришедший в него итератор — и вкладывает.
func sumSeq(seq iter.Seq[int]) int {
sum := 0
for v := range seq {
sum += v
}
return sum
}
// То же самое, но вложить нельзя. Это не искусственный случай: ровно так
// выглядит любая функция, которая принимает iter.Seq и достаточно велика,
// чтобы не поместиться в бюджет вкладывания.
//
//go:noinline
func sumSeqNoinline(seq iter.Seq[int]) int {
sum := 0
for v := range seq {
sum += v
}
return sum
}
// Итератор, спрятанный за пакетной переменной: какая именно функция там
// лежит, компилятор знать не может.
var storedSeq iter.Seq[int]
// ------------------------------------------- группа 1: компилятор видит всё
func BenchmarkPlain(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for _, v := range s {
sum += v
}
sink = sum
}
}
func BenchmarkSlicesValues(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for v := range slices.Values(s) {
sum += v
}
sink = sum
}
}
func BenchmarkHandwritten(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for v := range values(s) {
sum += v
}
sink = sum
}
}
func BenchmarkCallback(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
each(s, func(v int) { sum += v })
sink = sum
}
}
func BenchmarkSeqParam(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sink = sumSeq(slices.Values(s))
}
}
// ------------------------------------ группа 2: компилятор не видит функцию
func BenchmarkNoinlinePlain(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for _, v := range s {
sum += v
}
sink = sum
}
}
func BenchmarkNoinlineSeqParam(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sink = sumSeqNoinline(slices.Values(s))
}
}
func BenchmarkNoinlineStoredSeq(b *testing.B) {
s := data()
storedSeq = slices.Values(s)
for i := 0; i < b.N; i++ {
sum := 0
for v := range storedSeq {
sum += v
}
sink = sum
}
}
// --------------------------------------------- группа 3: пара ключ-значение
func BenchmarkPairPlain(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for i, v := range s {
sum += i * v
}
sink = sum
}
}
func BenchmarkPairSlicesAll(b *testing.B) {
s := data()
for i := 0; i < b.N; i++ {
sum := 0
for i, v := range slices.All(s) {
sum += i * v
}
sink = sum
}
}
// ------------------------------------------------------- ловушка с b.Loop
//
// Те же две строки, что в группе 1, только на b.Loop. Разница между ними и
// группой 1 — не свойство итератора, а свойство замера: внутри b.Loop
// вкладывание выключено.
func BenchmarkLoopPlain(b *testing.B) {
s := data()
for b.Loop() {
sum := 0
for _, v := range s {
sum += v
}
sink = sum
}
}
func BenchmarkLoopSlicesValues(b *testing.B) {
s := data()
for b.Loop() {
sum := 0
for v := range slices.Values(s) {
sum += v
}
sink = sum
}
}
// --------------------------------------------------------------- проверка
// Все способы обхода обязаны давать одну и ту же сумму.
//
// Без этого замер сравнивал бы разную работу и молчал об этом: строка,
// случайно обходящая половину среза, выглядела бы просто быстрой.
func TestSameWork(t *testing.T) {
s := data()
want := 0
for _, v := range s {
want += v
}
cases := map[string]func() int{
"slices.Values": func() int {
got := 0
for v := range slices.Values(s) {
got += v
}
return got
},
"рукописный итератор": func() int {
got := 0
for v := range values(s) {
got += v
}
return got
},
"обратный вызов": func() int {
got := 0
each(s, func(v int) { got += v })
return got
},
"итератор параметром": func() int { return sumSeq(slices.Values(s)) },
"итератор параметром, noinline": func() int { return sumSeqNoinline(slices.Values(s)) },
}
for name, f := range cases {
if got := f(); got != want {
t.Errorf("%s: %d, ожидалось %d", name, got, want)
}
}
storedSeq = slices.Values(s)
got := 0
for v := range storedSeq {
got += v
}
if got != want {
t.Errorf("итератор в пакетной переменной: %d, ожидалось %d", got, want)
}
wantPair := 0
for i, v := range s {
wantPair += i * v
}
gotPair := 0
for i, v := range slices.All(s) {
gotPair += i * v
}
if gotPair != wantPair {
t.Errorf("slices.All: %d, ожидалось %d", gotPair, wantPair)
}
}