Deep Engineering

MEASUREMENT

bench/goslice/internals.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/interview/golang/slices
Run on
go1.24.7 linux/amd64, Intel Xeon 2.80GHz
How to run it
go run bench/goslice/grow.go          # это работает и из корня
go run bench/goslice/clip.go
go run bench/goslice/race_shared.go   # собирает racesrc/ с -race
cd bench/goslice
go test -run '^$' -bench . -benchmem .

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: заголовок, общий массив и рост запаса»

Файл Что делает
grow.go восемь наблюдений без единого замера времени: из чего состоит заголовок, когда два среза делят массив, что отбирает трёхиндексная форма, как на самом деле растёт запас, сколько памяти держит маленький срез
cost_test.go цена: построить срез, скопировать, пройти диапазоном, удалить из середины
clip.go slices.Clip против slices.Clone: что делает каждая, от чего защищает и от чего нет. Ни одного замера времени
race_shared.go гонка на общем массиве под -race: два ломаных случая и два починенных, дословный отчёт детектора. Ни одного замера времени
racesrc/main.go подопытная программа к race_shared.go: четыре режима

Каталог — отдельный модуль Go, поэтому замеры запускаются из него, а не из корня репозитория:

go run bench/goslice/grow.go          # это работает и из корня
go run bench/goslice/clip.go
go run bench/goslice/race_shared.go   # собирает racesrc/ с -race
cd bench/goslice
go test -run '^$' -bench . -benchmem .

grow.go помечен //go:build ignore — он package main, а рядом лежит тест пакета goslice, и без метки go test ./... спотыкался бы о два пакета в одном каталоге. На go run с явным именем файла метка не влияет.

Что здесь важно прочитать правильно

Главное в статье — не наносекунды. Оно в grow.go: срез — это три машинных слова, массив под ними общий, и почти все неожиданности растут отсюда. Замеры отвечают на второй по важности вопрос — «сколько это стоит», — и запускать их надо с -benchmem: столбцы B/op и allocs/op говорят больше, чем ns/op.

По времени сопоставляются только строки внутри одного блока. В каждом блоке все строки дают одинаковый результат, отличается лишь способ. Блоки друг с другом не сопоставляются: там разный объём работы.

Числа роста зависят от размера элемента. Последовательность cap для []int8, []int и []struct{5×int64} разная, и это не шум: growslice округляет запрошенный объём до класса размера аллокатора, а классы заданы в байтах. Поэтому таблицу «рост запаса» нельзя читать в отрыве от столбца «байт».

Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.80GHz)

Заголовок

unsafe.Sizeof([]int{}) = 24 байта: указатель, длина, запас. Для сравнения: строка — 16 байт, массив [8]int — 64 байта, потому что массив хранит элементы, а срез — только ссылку.

Рост запаса

[]int, элемент 8 байт:

len cap отношение байт
257 512 ×2,000 4096
513 848 ×1,656 6784
849 1280 ×1,509 10240
1281 1792 ×1,400 14336
1793 2560 ×1,429 20480
2561 3408 ×1,331 27264
3409 5120 ×1,502 40960

Отношение не постоянно и даже не монотонно (1,400 → 1,429 → 1,331 → 1,502): формула даёт одно число, а roundupsize округляет его до класса размера, и округление у разных классов разное.

[]struct{5×int64}, элемент 40 байт: cap идёт 32 → 67 → 134 → 272 → 544 → 1024 → 1638 → 2252 → 3072. Числа 67 и 1638 никто не просил: это классы размера, поделённые на 40.

[]int8, элемент 1 байт: начинается с cap = 8, а не 1, — минимальный класс размера всё равно 8 байт.

Что стоит рост

Построить []int из N элементов:

N без запаса с запасом
100 2 040 байт / 8 массивов 800 байт / 1
1 000 25 208 байт / 12 массивов 8 000 байт / 1
100 000 4 101 368 байт / 28 массивов 800 000 байт / 1

Это сумма cap × размер элемента по всем шагам роста, посчитанная точно и воспроизводимая до байта. Она НЕ совпадает со столбцом B/op замеров ниже, и не должна: B/op — это то, что выдал аллокатор, вместе с округлением до класса размера. Оба числа честные, но считают разное, и складывать их не во что.

grow.go печатает рядом и сверочный счёт по runtime.MemStats. Публиковать его нельзя: между двумя чтениями счётчика попадают выделения всей программы, и от прогона к прогону он даёт то 28, то 34 выделения. Он стоит там только чтобы убедиться, что порядок величины тот же.

Цена, N = 10 000

Блок 1 — построить срез. Все четыре строки дают []int длиной 10 000:

способ ns/op B/op allocs/op
var s []int + append 159 964 357 624 19
make([]int, 0, N) + append 40 542 81 920 1
make([]int, N) + индекс 39 154 81 920 1
slices.Grow + append 42 559 81 920 1

Три способа с запасом между собой неразличимы. Важно не какой из них выбрать, а что запас вообще заказан: без него — вчетверо больше байт и в девятнадцать раз больше выделений.

Блок 2 — скопировать 10 000 элементов в готовый массив:

способ ns/op
copy(dst, src) 1 668
append(dst[:0], src...) 1 655
ручной цикл по индексу 6 163

Первые две строки — один и тот же memmove. Ручной цикл втрое дороже.

Блок 3 — просуммировать одно поле у 10 000 структур по 136 байт:

способ ns/op
for _, r := range records 32 028
for j := range records 6 206

Выделений нет ни там, ни там: разница целиком — копирование 136 байт на каждой итерации.

Блок 4 — удалить элемент из середины. Удаление разрушает срез, поэтому каждая итерация восстанавливает его копированием; строка «только подготовка» показывает, сколько стоит именно это:

способ ns/op за вычетом подготовки
только подготовка (copy) 1 666
append(buf[:mid], buf[mid+1:]...) 2 326 ≈ 660
slices.Delete 2 327 ≈ 660
перестановка с последним 1 668 неотличимо от отсчёта

slices.Delete — это ровно тот же приём с append, и числа совпадают. Все 660 наносекунд — цена требования сохранить порядок. Перестановка с последним от строки отсчёта неотличима: разброс прогонов у обеих (1 650–1 698 и 1 663–1 681) перекрывается целиком, то есть само удаление в этом варианте не стоит ничего измеримого.

От прогона к прогону (три прогона по 2 с): блок 1 — 156 054–168 148 / 37 799–43 506 / 33 779–40 593 / 35 226–43 558; блок 2 — 1 654–1 678 / 6 151–6 191; блок 3 — 31 836–32 561 / 6 206–6 306; блок 4 — 1 650–1 698 / 2 318–2 366. Столбцы B/op и allocs/op от прогона к прогону не менялись вовсе.

Наблюдения grow.go

  • Срез — три слова, массив общий. b := a[1:3] даёт cap(b) = 4, а не 2: запас считается до конца массива. b[0] = 99 меняет a.
  • append в подсрез затирает соседа. b := append(a[:2], 9) при a = [1 2 3 4 5] оставляет a = [1 2 9 4 5], и массив у них один. Если запас исчерпан, тот же вызов выделяет новый массив — и заранее по коду это не видно.
  • Трёхиндексная форма отбирает запас. a[:2:2] даёт cap = 2, и append обязан выделить новый массив. Это единственный способ отдать наружу кусок своего массива и не бояться, что в него допишут.
  • Пустой срез — не nil. У var n []int указатель нулевой, у []int{} — нет: это общий нулевой объект, памяти под него не выделено. Наружу разница вылезает в json.Marshal: null против []. Для len, range и append они неразличимы.
  • Маленький срез держит большой массив. Срез из 10 байт от массива в 50 МБ оставляет в куче 50,1 МБ; после copy в свой массив — 0,1 МБ.
  • Срез передаётся копией заголовка. append внутри функции не виден снаружи (новая длина осталась в копии), а запись в существующий элемент — видна: указатель в копии тот же.

Что не подтвердилось

Расхожее «срез растёт вдвое до 1024 элементов, дальше в 1,25 раза» описывает рантайм до Go 1.18; заметки к тому выпуску говорят об этом прямо: «The built-in function append now uses a slightly different formula when deciding how much to grow a slice». Сейчас порог — 256, и это const threshold = 256 в runtime/slice.go. Сравнивается он с ЗАПАСОМ, а не с длиной: if oldCap < threshold. Но и «1,25» неверно: формула newcap += (newcap + 3*threshold) >> 2 даёт 1,25 только асимптотически, а измеренные отношения идут 1,656 → 1,509 → 1,400. Автор изменения сказал об этом прямо в сообщении коммита: «(Note that the real growth factor, both before and now, is somewhat larger because we round up to the next size class.)» — то есть настоящий множитель не совпадал с формулой ни до, ни после.

slices.Clip против slices.Clone (clip.go)

Первоисточник — документация пакета slices (go1.24.7, $(go env GOROOT)/src/slices/slices.go). Тела процитированы целиком, потому что в них весь ответ:

// Clip removes unused capacity from the slice, returning s[:len(s):len(s)]. func Clip[S ~[]E, E any](s S) S { return s[:len(s):len(s)] }

(пер. комментария: «Clip убирает у среза неиспользуемый запас, возвращая s[:len(s):len(s)]».)

// Clone returns a copy of the slice. // The elements are copied using assignment, so this is a shallow clone. // The result may have additional unused capacity. func Clone[S ~[]E, E any](s S) S { // Preserve nilness in case it matters. if s == nil { return nil } // Avoid s[:0:0] as it leads to unwanted liveness when cloning a // zero-length slice of a large array; see https://go.dev/issue/68488. return append(S{}, s...) }

(пер. комментария: «Clone возвращает копию среза. Элементы копируются присваиванием, то есть это поверхностная копия. У результата может быть дополнительный неиспользуемый запас».)

Разница видна прямо в телах: Clip — трёхиндексное выражение и НИ ОДНОГО копирования; Cloneappend в пустой срез, то есть копирование всегда.

Прогон целиком:

=== 1. Что делает Clip: отбирает запас, массив не трогает ===
  buf                      len=5 cap=16 [1 2 3 4 5]
  head := buf[:3]          len=3 cap=16 [1 2 3]
  Clip(head)               len=3 cap=3 [1 2 3]
  Clone(head)              len=3 cap=3 [1 2 3]
  head и buf делят:      ТОТ ЖЕ массив
  Clip(head) и buf делят: ТОТ ЖЕ массив   <- Clip НЕ копирует
  Clone(head) и buf делят: другой массив  <- Clone копирует сразу

=== 2. От чего защищает Clip: append не залезает в чужой хвост ===
  buf ПОСЛЕ append(head,99) len=5 cap=5 [1 2 3 99 5]
  grown                    len=4 cap=5 [1 2 3 99]
  buf[3] было 4, стало 99 — append затёр чужой элемент

  buf ПОСЛЕ append(Clip(head),99) len=5 cap=5 [1 2 3 4 5]
  grownSafe                len=4 cap=6 [1 2 3 99]
  buf[3] остался 4 — Clip заставил append выделить новый массив
  grownSafe и buf делят: другой массив

=== 3. От чего Clip НЕ защищает: запись по индексу ===
  buf после clipped[0]=777 len=5 cap=5 [777 2 3 4 5]
  buf[0] стал 777 — Clip оставил тот же массив, запись видна снаружи
  buf после cloned[0]=777  len=5 cap=5 [1 2 3 4 5]
  buf[0] остался 1 — Clone отвязал полностью

=== 4. Clip и удержание памяти: массив остаётся живым ===
  cap(big)=1048576, cap(small)=3
  big[1]=42 → small[1]=42, делят: ТОТ ЖЕ массив
  Clip уменьшил cap, но массив на 1<<20 элементов держится
  указателем в small и собран не будет. Против удержания памяти
  работает Clone, а не Clip.

=== 5. nil и пустой срез ===
  Clone(nil)  == nil: true (документация: "Preserve nilness")
  Clip(nil)   == nil: true
  Clip(make([]int,0,8)):  len=0 cap=0
  Clone(make([]int,0,8)): len=0 cap=0

ИТОГ ПРОВЕРКИ
  Clip  = s[:len(s):len(s)]: ноль копирований, ноль выделений;
          защищает ТОЛЬКО от append в чужой запас.
  Clone = append(S{}, s...): копирование всегда;
          отвязывает и от append, и от записи по индексу,
          и отпускает большой массив.

Что показал прогон — четыре утверждения, каждое проверено:

  1. Clip не копирует. После Clip(head) срез делит с buf ТОТ ЖЕ массив; Clone(head) — уже другой.
  2. Clip защищает от append в чужой запас. Без него append(buf[:3], 99) затирает buf[3]: было 4, стало 99. С Clip buf[3] остаётся 4, а append выделяет новый массив.
  3. Clip НЕ защищает от записи по индексу. clipped[0] = 777 меняет buf[0] — массив-то общий. От этого спасает только Clone.
  4. Clip не отпускает большой массив. cap стал 3, но массив на 1 << 20 элементов держится указателем и собран не будет. Против удержания памяти работает Clone, а не Clip.

Отсюда и правило выбора: Clip — когда нужно запретить чужому append трогать ваш хвост, и при этом не платить за копирование. Clone — когда значение уходит наружу и должно быть независимым.

Мелочь, которая иногда важна: Clone(nil) возвращает nil, а не пустой срез — в коде это отдельная ветка с комментарием «Preserve nilness in case it matters».

Гонка на общем массиве (race_shared.go)

Материал защитный: ломаные режимы существуют, чтобы было на чём показать диагностику, и рядом с каждым лежит починенный.

Почему это не ловится глазами. В обоих ломаных режимах ни один индекс не выглядит подозрительно. Общий у горутин не индекс, а МАССИВ:

  • режим overlap: buf[0:4] и buf[2:6] перехлёстываются, a[2] и b[0] — одна и та же ячейка buf[2];
  • режим appendshared: два среза len=1 cap=8, и оба append пишут в buf[1] — в ячейку, которой ни в одном из срезов ещё нет. Это ровно та ошибка, от которой спасает slices.Clip.

Прогон целиком:

go version: go version go1.24.7 linux/amd64

═══ режим overlap ═══
ЛОМАНЫЙ: buf[0:4] и buf[2:6], общая ячейка buf[2]

  | ==================
  | WARNING: DATA RACE
  | Write at 0x00c0000a6010 by goroutine 7:
  |   main.overlap.func1()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:67 +0xaa
  | 
  | Previous write at 0x00c0000a6010 by goroutine 8:
  |   main.overlap.func2()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:73 +0xa6
  | 
  | Goroutine 7 (running) created at:
  |   main.overlap()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:64 +0x124
  |   main.main()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:40 +0x10a
  | 
  | Goroutine 8 (running) created at:
  |   main.overlap()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:70 +0x1ce
  |   main.main()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:40 +0x10a
  | ==================

  код возврата: 66  — гонка найдена, как и ожидалось

═══ режим fixedsplit ═══
ПОЧИНЕННЫЙ: buf[0:4] и buf[4:8], диапазоны не пересекаются

  | fixedsplit: buf = [0 0 1999 0 1999 0 0 0]

  код возврата: 0  — детектор молчит

═══ режим appendshared ═══
ЛОМАНЫЙ: два среза len=1 cap=8, оба append пишут в buf[1]

  | ==================
  | WARNING: DATA RACE
  | Write at 0x00c0000a6008 by goroutine 8:
  |   main.appendShared.func2()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:100 +0x150
  | 
  | Previous write at 0x00c0000a6008 by goroutine 7:
  |   main.appendShared.func1()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:94 +0x150
  | 
  | Goroutine 8 (running) created at:
  |   main.appendShared()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:97 +0x1d1
  |   main.main()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:42 +0x184
  | 
  | Goroutine 7 (finished) created at:
  |   main.appendShared()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:91 +0x124
  |   main.main()
  |       /home/claude/deep-engineering-app/bench/goslice/racesrc/main.go:42 +0x184
  | ==================

  код возврата: 66  — гонка найдена, как и ожидалось

═══ режим fixedclip ═══
ПОЧИНЕННЫЙ: slices.Clip отобрал запас, append выделяет своё

  | fixedclip: buf[:cap] = [0 0 0 0 0 0 0 0] (запас не тронут)

  код возврата: 0  — детектор молчит

Код возврата 66 — это не «ошибка запуска», а штатный ответ
детектора: программа доработала, но гонки были найдены.

Что показал прогон:

  1. Детектор называет адрес, а не переменную. Write at 0x00c00001c450 by goroutine 8 и Previous write at 0x00c00001c450 by goroutine 7 — один адрес, две горутины. Имя buf в отчёте не появляется вообще: гонка живёт на уровне памяти, а не имён.
  2. Он даёт обе стороны и место создания каждой горутины. Четыре стека: где записали сейчас, где записали до того, и где обе горутины были запущены. Этого достаточно, чтобы найти нарезку.
  3. Код возврата 66 — штатный ответ детектора: программа доработала, но гонки найдены. Через go run его не видно (go подменяет его своим 1 и печатает exit status 66 строкой), поэтому драйвер собирает двоичный файл и запускает его сам.
  4. Оба лечения работают, и детектор молчит. fixedsplit — непересекающиеся диапазоны buf[0:4] и buf[4:8]; fixedclipslices.Clip, после которого cap == len и append обязан выделить своё.

Отдельно стоит заметить, что fixedsplit оставляет массив ОБЩИМ и это нормально: гонка бывает не от общего массива, а от общей ячейки.

Важная оговорка. Детектор не доказывает отсутствие гонок. Документация Go («Data Race Detector») говорит прямо, что инструмент находит гонки, случившиеся во время выполнения; те, что на этом прогоне не случились, он не находит. Молчание на починенных режимах — свидетельство, а не доказательство.

Побочный результат, стоивший одной итерации отладки: первая версия подопытной программы копила результат append в ОДНУ общую переменную sink, и детектор честно показал гонку на ней — в том числе в «починенном» режиме. Пришлось развести на sinkA и sinkB. Мораль ровно та же, что и у самой темы: общая ячейка находится там, где её не искали.

Источники

Script

386 lines
//go:build ignore

// Срез изнутри: что печатает рантайм, когда его спрашивают по-настоящему.
//
// ЗАЧЕМ ЭТОТ ФАЙЛ ОТДЕЛЬНО ОТ ОСТАЛЬНЫХ. Соседний grow.go отвечает на вопрос
// «делят ли два среза массив», practice.go — на две задачи урока. Здесь
// собрано то, что нужно уроку для РАЗБОРА МЕХАНИЗМА: как именно считается
// новый запас, во что обходится амортизация, сколько на самом деле копируется
// за время роста, чем удаление из середины отличается от удаления с конца и
// почему `range` по срезу структур дороже, чем по индексам.
//
// ПОЧЕМУ ВСЁ ЭТО СЧИТАЕТСЯ, А НЕ ОБЪЯСНЯЕТСЯ. Про срез легко рассказать
// связную историю, которая при проверке рассыпается: «удваивается до 1024»,
// «append амортизированно O(1), значит копирование неважно», «удаление из
// середины — это просто append». Каждое из этих утверждений здесь проверяется
// числом, и два из трёх числа не подтверждают.
//
// ЗАПУСК:
//
//	go run bench/goslice/internals.go
//
// Файл помечен `//go:build ignore`: он package main, а рядом лежит тест пакета
// goslice, и без метки `go test ./...` спотыкался бы о два пакета в одном
// каталоге.
package main

import (
	"fmt"
	"runtime"
	"strings"
	"testing"
	"unsafe"
)

func rule(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

// --------------------------------------------------------------- окно

// ОКНО НАД МАССИВОМ — самая первая модель темы, и она печатается ПЕРВОЙ
// намеренно.
//
// Аудит линии назвал причину: урок начинался с трёх машинных слов, то есть с
// физического представления, и читателю приходилось понимать append, запас и
// рост одновременно. Наблюдаемая семантика проще и первична: срез — это ОКНО
// над чужим массивом, и два окна могут смотреть в одни и те же ячейки.
//
// Здесь это не рассказано, а напечатано: одна запись через первое окно видна
// во втором и в самом массиве. Из этого одного факта дальше выводится всё
// остальное — и зачем append возвращает срез, и почему s[1:3] опасен.
func window() {
	rule("ОКНО: ОДИН МАССИВ, ДВА СРЕЗА")

	arr := [6]int{10, 20, 30, 40, 50, 60}
	a := arr[1:4]
	b := arr[3:6]

	fmt.Printf("  массив          %v\n", arr)
	fmt.Printf("  a := arr[1:4]   %v\n", a)
	fmt.Printf("  b := arr[3:6]   %v\n", b)
	fmt.Println()

	a[2] = 99

	fmt.Printf("  после a[2] = 99\n")
	fmt.Printf("  массив          %v\n", arr)
	fmt.Printf("  a               %v\n", a)
	fmt.Printf("  b               %v\n", b)
	fmt.Println()
	fmt.Println("  Одна запись — три изменившихся значения, и ни одно из них")
	fmt.Println("  не копия. a[2] и b[0] — ОДНА И ТА ЖЕ ячейка массива, потому")
	fmt.Println("  что срез не владеет данными: он описывает, какой отрезок")
	fmt.Println("  чужого массива через него виден.")
	fmt.Println()
	fmt.Println("  Всё остальное в теме выводится отсюда. Раз данные общие,")
	fmt.Println("  append может писать в чужие ячейки; раз массив может")
	fmt.Println("  смениться, append обязан возвращать новый заголовок; раз")
	fmt.Println("  окно держит весь массив, короткий срез удерживает длинный.")
}

// ------------------------------------------------------------- заголовок

type header struct {
	array unsafe.Pointer
	len   int
	cap   int
}

func layout() {
	rule("ЗАГОЛОВОК: ЧТО ИМЕННО КОПИРУЕТСЯ ПРИ ПЕРЕДАЧЕ")

	var s []int
	var a [5]int
	fmt.Printf("  []int          %2d байт   — три слова: указатель, длина, запас\n", unsafe.Sizeof(s))
	fmt.Printf("  структура      %2d байт   — та же форма, объявленная руками\n", unsafe.Sizeof(header{}))
	fmt.Printf("  string         %2d байт   — два слова: указатель и длина\n", unsafe.Sizeof(""))
	fmt.Printf("  [5]int         %2d байт   — МАССИВ это значение, копируется целиком\n", unsafe.Sizeof(a))
	fmt.Println()
	fmt.Println("  Отсюда разница, которую спрашивают: массив передаётся копией")
	fmt.Println("  своих данных, срез — копией трёх слов над чужими данными.")
}

// ------------------------------------------------------------- рост запаса

// capSteps возвращает последовательность запасов при append по одному.
func capSteps[T any](n int) []int {
	var xs []T
	steps := []int{}
	prev := 0
	var zero T
	for i := 0; i < n; i++ {
		xs = append(xs, zero)
		if cap(xs) != prev {
			steps = append(steps, cap(xs))
			prev = cap(xs)
		}
	}
	return steps
}

type odd struct{ a, b, c [5]byte } // 15 байт: специально не степень двойки

func growth() {
	rule("РОСТ ЗАПАСА: ПОЧЕМУ ЧИСЛА НЕ КРУГЛЫЕ")

	ints := capSteps[int](20000)
	fmt.Printf("  []int   (8 байт):  %v\n", ints)

	odds := capSteps[odd](2000)
	fmt.Printf("  []odd  (15 байт):  %v\n", odds)
	fmt.Println()
	fmt.Println("  Один и тот же рантайм, одна и та же формула — а числа разные.")
	fmt.Println("  Потому что считает он не в элементах, а в БАЙТАХ: нужный размер")
	fmt.Println("  округляется вверх до класса размеров аллокатора, и обратно в")
	fmt.Println("  элементы это делится уже с остатком.")
	fmt.Println()

	// Показать это делением: сколько байт просила формула и сколько дали.
	fmt.Println("  проверка на []int, шаг 512 →", ints[len(ints)-6:][0], ":")
	want := 512 + (512+3*256)/4
	fmt.Printf("    формула nextslicecap даёт      %d элементов = %d байт\n", want, want*8)
	got := 0
	for _, c := range ints {
		if c > 512 {
			got = c
			break
		}
	}
	fmt.Printf("    аллокатор выдал                %d элементов = %d байт\n", got, got*8)
	fmt.Printf("    разница — округление до класса размеров: +%d байт\n", got*8-want*8)
	fmt.Println()

	fmt.Println("  отношения соседних запасов у []int:")
	fmt.Print("   ")
	for i := 1; i < len(ints); i++ {
		fmt.Printf(" %.2f", float64(ints[i])/float64(ints[i-1]))
	}
	fmt.Println()
	fmt.Println("  Не монотонны: округление то добавляет, то нет. К 1,25 ряд")
	fmt.Println("  сходится, но не идёт по нему.")
}

// ------------------------------------------------------- цена амортизации

func amortized() {
	rule("АМОРТИЗАЦИЯ: СКОЛЬКО НА САМОМ ДЕЛЕ СКОПИРОВАНО")

	for _, n := range []int{1000, 100_000, 1_000_000} {
		var xs []int
		copied, moves, prev := 0, 0, 0
		for i := 0; i < n; i++ {
			before := cap(xs)
			xs = append(xs, i)
			if cap(xs) != before {
				// Переезд: рантайм скопировал всё, что уже лежало.
				copied += before
				moves++
			}
			prev = cap(xs)
		}
		fmt.Printf("  n = %-9d переездов %2d, скопировано %d элементов (%.2f×n), итоговый запас %d\n",
			n, moves, copied, float64(copied)/float64(n), prev)
	}
	fmt.Println()
	fmt.Println("  Вот что стоит за словами «амортизированно O(1)»: суммарное")
	fmt.Println("  копирование остаётся ЛИНЕЙНЫМ от n, а не квадратичным, потому")
	fmt.Println("  что размеры переездов складываются в геометрическую прогрессию.")
	fmt.Println("  Но множитель при n — не ноль, и он тем больше, чем МЕДЛЕННЕЕ")
	fmt.Println("  растёт запас: сумма прогрессии со знаменателем r равна n/(r−1).")
	fmt.Println("  При удвоении это ≈1·n, при множителе 1,25 — уже ≈4·n. Поэтому")
	fmt.Println("  на тысяче, где ещё идёт удвоение, выходит 1,9·n, а на миллионе,")
	fmt.Println("  где давно работает медленная формула, — 4,2·n.")
	fmt.Println()
	fmt.Println("  Это и есть настоящий размен в nextslicecap: более медленный")
	fmt.Println("  рост экономит ПАМЯТЬ и доплачивает КОПИРОВАНИЕМ. Убирает это")
	fmt.Println("  доплату только make с заранее известным запасом.")
	fmt.Println()
	fmt.Println("  И отдельно: ОДИН append в худшем случае стоит O(n). Амортизация")
	fmt.Println("  говорит про сумму, а не про каждый вызов, — для хвостовых")
	fmt.Println("  задержек это разные утверждения.")
}

// --------------------------------------------------------------- удаление

const delN = 10000

// ПОЧЕМУ ЗДЕСЬ ОТСЧЁТ, А НЕ ПРОСТО ДВА ЗАМЕРА. Удаление разрушает срез, и
// каждая итерация обязана его восстановить. Восстановление — это копирование
// десяти тысяч элементов, и оно дороже самого удаления: первая редакция этого
// файла мерила без отсчёта и получила кратность 1,0, то есть не измерила
// ничего, кроме подготовки. Отсчёт делает ровно подготовку и ничего больше;
// вычитание его времени и оставляет цену операции.

func prepOnly(b *testing.B) {
	src := make([]int, delN)
	work := make([]int, delN)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		copy(work, src)
		runtime.KeepAlive(work)
	}
}

// deleteAt возвращает замер удаления по индексу idx вместе с восстановлением.
func deleteAt(idx int) func(*testing.B) {
	return func(b *testing.B) {
		src := make([]int, delN)
		work := make([]int, delN)
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			copy(work, src)
			xs := work[:delN]
			xs = append(xs[:idx], xs[idx+1:]...)
			runtime.KeepAlive(xs)
		}
	}
}

func deleteTail(b *testing.B) {
	src := make([]int, delN)
	work := make([]int, delN)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		copy(work, src)
		xs := work[:delN]
		xs = xs[:len(xs)-1]
		runtime.KeepAlive(xs)
	}
}

// ПОЧЕМУ ЗДЕСЬ ПЕЧАТАЮТСЯ ОБА ЧИСЛА — И СЫРОЕ, И ЗА ВЫЧЕТОМ ОТСЧЁТА.
// Первая редакция печатала только разность, и на тихой машине она давала
// осмысленный ряд. На загруженной та же разность выходила ОТРИЦАТЕЛЬНОЙ на
// коротком хвосте: подготовка среза стоит примерно столько же, сколько сам
// сдвиг одного элемента, и шум одной величины больше разницы между ними.
// Отрицательные наносекунды — не результат, а признак того, что метод
// исчерпан. Поэтому сырое время осталось в таблице, а разность помечена как
// «ниже разрешения», когда она перестаёт быть положительной: утверждение
// урока — про ЛИНЕЙНОСТЬ по длине хвоста, и его несёт верхняя часть ряда,
// а не нижняя.
func deletion() {
	rule("УДАЛЕНИЕ: ЦЕНА ЗАВИСИТ ОТ ТОГО, ОТКУДА УДАЛЯЮТ")

	base := best(prepOnly)
	fmt.Printf("  отсчёт: только восстановление среза   %9.0f нс\n", base)
	fmt.Println("  (сырое время включает его; во втором столбце он вычтен)")
	fmt.Println()

	show := func(name string, raw float64) {
		net := raw - base
		if net <= 0 {
			fmt.Printf("  %-36s %9.0f нс   %s\n", name, raw, "ниже разрешения метода")
			return
		}
		fmt.Printf("  %-36s %9.0f нс   %9.0f нс\n", name, raw, net)
	}

	cases := []struct {
		name string
		idx  int
	}{
		{"удалить первый     (сдвинуть 9999)", 0},
		{"удалить средний    (сдвинуть 4999)", delN / 2},
		{"удалить предпоследний (сдвинуть 1)", delN - 2},
	}
	for _, c := range cases {
		show(c.name, best(deleteAt(c.idx)))
	}
	show("укоротить с конца  (сдвинуть 0)", best(deleteTail))
	fmt.Println()
	fmt.Println("  Видно линейность: цена пропорциональна ХВОСТУ, который надо")
	fmt.Println("  сдвинуть, а не длине среза. Удаление из середины — O(n),")
	fmt.Println("  укорачивание с конца — O(1), и это одна и та же запись")
	fmt.Println("  append(s[:i], s[i+1:]...) при разных i.")
	fmt.Println()
	fmt.Println("  Нижние строки ряда — про то же. Сдвиг одного элемента и")
	fmt.Println("  сдвиг нуля стоят так мало, что вычитание отсчёта уходит в")
	fmt.Println("  шум: число выходит либо крошечным, либо не печатается")
	fmt.Println("  вовсе. Это тоже ответ — их цена НЕ РАСТЁТ с длиной среза,")
	fmt.Println("  в отличие от верхних.")
	fmt.Println()

	// Утечка: длина уменьшилась, ссылка осталась.
	type node struct{ payload []byte }
	live := make([]*node, 3)
	for i := range live {
		live[i] = &node{payload: make([]byte, 1<<20)}
	}
	shrunk := live[:2]
	fmt.Printf("  после s = s[:2] третий элемент всё ещё в массиве: %v\n",
		shrunk[:3:3][2] != nil)
	fmt.Println("  Длина не стирает то, что за ней. Указатель жив, и мегабайт")
	fmt.Println("  за ним — тоже. Обнулять хвост приходится руками (или брать")
	fmt.Println("  slices.Delete, который это делает сам).")
}

// ------------------------------------------------------------------ range

type wide struct{ a, b, c, d, e, f, g, h int64 } // 64 байта

var sink int64

func rangeCopy(b *testing.B) {
	xs := make([]wide, 10000)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		for _, v := range xs {
			sink += v.a
		}
	}
}

func rangeIndex(b *testing.B) {
	xs := make([]wide, 10000)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		for j := range xs {
			sink += xs[j].a
		}
	}
}

func ranging() {
	rule("RANGE ПО ЗНАЧЕНИЮ: КОПИЯ НА КАЖДОМ ШАГЕ")

	byValue := best(rangeCopy)
	byIndex := best(rangeIndex)
	fmt.Printf("  for _, v := range xs   %9.0f нс   — структура в 64 байта копируется\n", byValue)
	fmt.Printf("  for j := range xs      %9.0f нс   — копии нет\n", byIndex)
	fmt.Printf("  кратность              %9.2f\n", byValue/byIndex)
	fmt.Println()
	fmt.Println("  Здесь же корень второго вопроса: v — КОПИЯ, и запись в v.a")
	fmt.Println("  срез не меняет. Менять надо через xs[j].")
}

// ------------------------------------------------------------------ общее

const rounds = 7

// best — лучший из нескольких кругов; протокол тот же, что во всём корпусе.
func best(f func(*testing.B)) float64 {
	out := 0.0
	for r := 0; r < rounds; r++ {
		ns := float64(testing.Benchmark(f).NsPerOp())
		if out == 0 || ns < out {
			out = ns
		}
	}
	return out
}

func main() {
	fmt.Printf("%s %s/%s | срез изнутри\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
	fmt.Printf("замеры: лучший из %d чередующихся кругов\n", rounds)

	window()
	layout()
	growth()
	amortized()
	deletion()
	ranging()
}