Deep Engineering

MEASUREMENT

bench/goiter/cost_test.go

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/go/iteration/range-over-func
Run on
go1.24.7 linux/amd64, Intel Xeon 2.80GHz
How to run it
go run bench/goiter/mechanics.go   # это работает и из корня
cd bench/goiter && ./cost.sh
cd bench/goiter && go test .       # проверка, что все способы дают одну сумму

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для статьи «Цикл по функции в 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.block1main.onemain.block1-range2main.othermain.block1.one.block1-range2-range4. Кадры чередуются: итератор, тело, итератор, тело. Суффикс -rangeN придумывает компилятор; составное имя у верхнего кадра — след вкладывания.
  • Итератор не знает слов break и continue. Он видит только булев результат yield: continue — это return true, breakreturn 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
  • Пакет iterhttps://pkg.go.dev/iter
  • cmd/compile/internal/rangefunc/rewrite.go в GOROOT: как компилятор переписывает цикл, включая переменную #next и состояния #stateN
  • cmd/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

Script

294 lines
// Цена цикла по функции: три группы, и внутри каждой строки делают ОДНУ И ТУ
// ЖЕ работу.
//
// ЧТО ЗДЕСЬ СРАВНИМО. Внутри группы — всё: каждая строка складывает одни и те
// же числа одного и того же среза и обязана дать одну и ту же сумму (это
// проверяется, см. 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)
	}
}