Deep Engineering

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.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

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."