MEASUREMENT
bench/goiter/cost.sh
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.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
Script
148 lines#!/usr/bin/env bash
# Цена цикла по функции — чередующимися раундами, а не одним прогоном.
#
# ПОЧЕМУ ЧЕРЕДОВАНИЕМ. Частота процессора на виртуальной машине плавает, и два
# прогона подряд дают разницу, которой нет. Скрипт гоняет весь набор по кругу и
# собирает по всем раундам ДИАПАЗОН, а не одно число: если диапазоны двух
# строк перекрываются, разницы между ними нет, сколько бы её ни казалось по
# одному прогону.
#
# ЧТО СРАВНИМО. Только строки внутри одного блока: они делают одну и ту же
# работу (это проверяет TestSameWork в cost_test.go). Между блоками — нельзя.
#
# ЗАПУСК:
#
# cd bench/goiter && ./cost.sh
# ROUNDS=7 ./cost.sh > runs/cost.txt
#
# Снято на go1.24.7 linux/amd64.
set -euo pipefail
cd "$(dirname "$0")"
# Локаль нужна ровно для одного: выравнивания колонок. В POSIX-локали bash
# считает длину строки в БАЙТАХ, и русская подпись из 22 букв занимает 36 —
# колонки разъезжаются ровно на длину кириллицы. Здесь важно, чтобы таблицу
# можно было читать глазами, поэтому локаль задаётся явно, а не наследуется.
export LC_ALL=C.UTF-8
ROUNDS="${ROUNDS:-5}"
TIME="${TIME:-1s}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
ALL='^Benchmark(Plain|SlicesValues|Handwritten|Callback|SeqParam|NoinlinePlain|NoinlineSeqParam|NoinlineStoredSeq|PairPlain|PairSlicesAll|LoopPlain|LoopSlicesValues)$'
for round in $(seq 1 "$ROUNDS"); do
go test -run '^$' -bench "$ALL" -benchmem -benchtime "$TIME" -count 1 . \
> "$TMP/round-$round.txt" 2>&1
done
CPU=$(grep -h '^cpu:' "$TMP/round-1.txt" | sed 's/cpu: *//')
GOVER=$(go version | awk '{print $3, $4}')
# ЧИСЛО ОДНОГО РАУНДА НЕ ПЕЧАТАЕТСЯ, печатается диапазон по всем раундам.
#
# Это не оформление. Один «лучший» раунд превращает шум в результат: две
# строки, отличающиеся на 15 % при разбросе раундов в 9 %, выглядели бы как
# разные, хотя разными не являются. Диапазоны lo-hi читатель сравнивает сам:
# если они перекрываются, разницы нет.
lo() { grep -h "^$1-" "$TMP"/round-*.txt | awk -v c="$2" 'NR==1||$c+0<m{m=$c+0} END{printf "%.0f", m}'; }
hi() { grep -h "^$1-" "$TMP"/round-*.txt | awk -v c="$2" '$c+0>m{m=$c+0} END{printf "%.0f", m}'; }
# Строка таблицы без хвостовых пробелов: блок из этого файла попадает в текст
# статьи дословно, и хвост пробелов там был бы мусором в диффе.
line() { printf ' %s\n' "$(printf '%s ' "$@" | sed 's/ *$//')"; }
# Подпись, дополненная пробелами до нужной ШИРИНЫ В СИМВОЛАХ.
pad() {
local s="$1" w="$2"
while [ "${#s}" -lt "$w" ]; do s="$s "; done
printf '%s' "$s"
}
row() { # $1 — имя бенчмарка, $2 — подпись, $3 — база (нс/оп) для кратности
local nslo nshi ratio
nslo=$(lo "$1" 3); nshi=$(hi "$1" 3)
if [ -n "${3:-}" ]; then
ratio=$(awk -v a="$nslo" -v b="$3" 'BEGIN { printf "x%.1f", a / b }')
else
ratio="-"
fi
line "$(pad "$2" 34)" "$(pad "$nslo-$nshi" 13)" "$(pad "$(hi "$1" 5)" 6)" \
"$(pad "$(hi "$1" 7)" 7)" "$ratio"
}
# Шапка выравнивается тем же pad, что и строки: printf у кириллицы считает
# байты, и «нс/оп» в %13s занял бы девять знакомест вместо тринадцати.
header() {
line "$(pad "" 34)" "$(pad "нс/оп" 13)" "$(pad "Б/оп" 6)" \
"$(pad "выдел." 7)" "кратно"
}
echo "$GOVER | $CPU"
echo "срез из 10000 int, тело складывает значения; $ROUNDS чередующихся раундов по $TIME"
echo "в таблицах диапазон нс/оп по раундам; перекрываются — значит разницы нет"
echo
PLAIN=$(lo BenchmarkPlain 3)
echo "8. КОГДА КОМПИЛЯТОР ВИДИТ ИТЕРАТОР, ОБХОД СТОИТ КАК ОБЫЧНЫЙ"
printf -- '-%.0s' $(seq 1 66); echo
header
row BenchmarkPlain "обычный range по срезу" "$PLAIN"
row BenchmarkSlicesValues "range по slices.Values" "$PLAIN"
row BenchmarkHandwritten "range по рукописному iter.Seq" "$PLAIN"
row BenchmarkSeqParam "iter.Seq параметром функции" "$PLAIN"
row BenchmarkCallback "обратный вызов, без итератора" "$PLAIN"
echo
echo " Ни одного выделения памяти и то же время, что у обычного range."
echo " Компилятор вложил и итератор, и тело: вызова на элемент не осталось."
echo
echo " Про десятую долю, на которую итератор здесь оказался БЫСТРЕЕ обычного"
echo " range, и на которой диапазоны не перекрылись: из неё не следует, что"
echo " итератор быстрее. Это два разных, но равносильных цикла, и разница в"
echo " том, как их развернул компилятор на этой машине. Знак такой разницы"
echo " меняется от версии к версии. Следует из блока другое: ожидаемой"
echo " кратности 6 здесь нет, и выделений нет ни у одной строки."
echo
echo "9. КОГДА НЕ ВИДИТ, ПОЯВЛЯЕТСЯ ВЫЗОВ НА КАЖДЫЙ ЭЛЕМЕНТ"
printf -- '-%.0s' $(seq 1 66); echo
header
row BenchmarkNoinlinePlain "обычный range по срезу" "$PLAIN"
row BenchmarkNoinlineSeqParam "iter.Seq в функцию без вкладыв." "$PLAIN"
row BenchmarkNoinlineStoredSeq "iter.Seq из пакетной переменной" "$PLAIN"
echo
echo " Та же работа и тот же итератор. Изменилось одно: компилятор больше не"
echo " знает, какая функция придёт, и не может её вложить. Появились и вызов"
echo " на элемент, и выделения памяти под замыкание."
echo
echo "10. ПАРА КЛЮЧ-ЗНАЧЕНИЕ"
printf -- '-%.0s' $(seq 1 66); echo
header
PAIR=$(lo BenchmarkPairPlain 3)
row BenchmarkPairPlain "обычный range по срезу с индексом" "$PAIR"
row BenchmarkPairSlicesAll "range по slices.All (iter.Seq2)" "$PAIR"
echo
echo " Работа другая, чем в блоках 8 и 9, поэтому с ними эти строки не"
echo " сравниваются. Вывод тот же: вложенный итератор не стоит ничего."
echo
echo "11. ЛОВУШКА ЗАМЕРА: ВНУТРИ B.LOOP ВКЛАДЫВАНИЕ ВЫКЛЮЧЕНО"
printf -- '-%.0s' $(seq 1 66); echo
header
LOOPPLAIN=$(lo BenchmarkLoopPlain 3)
row BenchmarkLoopPlain "обычный range, замер на b.Loop" "$LOOPPLAIN"
row BenchmarkLoopSlicesValues "slices.Values, замер на b.Loop" "$LOOPPLAIN"
echo
echo " Те же две строки, что в блоке 8, и другой ответ. Причина не в"
echo " итераторе, а в замере: компилятор не вкладывает вызовы внутри тела"
echo " b.Loop. Это записано в самом компиляторе, в"
echo " cmd/compile/internal/inline/interleaved/interleaved.go:"
echo " «No inlining nor devirtualization performed on b.Loop body»."
echo
echo " Практический вывод для тех, кто будет мерить это у себя: на b.Loop"
echo " итератор всегда выглядит дорогим, потому что b.Loop измеряет только"
echo " ту половину правды, которая из блока 9."