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 — трёхиндексное выражение и НИ ОДНОГО
копирования; Clone — append в пустой срез, то есть копирование всегда.
Прогон целиком:
=== 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, и от записи по индексу,
и отпускает большой массив.
Что показал прогон — четыре утверждения, каждое проверено:
Clipне копирует. ПослеClip(head)срез делит сbufТОТ ЖЕ массив;Clone(head)— уже другой.Clipзащищает отappendв чужой запас. Без негоappend(buf[:3], 99)затираетbuf[3]: было 4, стало 99. СClipbuf[3]остаётся 4, аappendвыделяет новый массив.ClipНЕ защищает от записи по индексу.clipped[0] = 777меняетbuf[0]— массив-то общий. От этого спасает толькоClone.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 — это не «ошибка запуска», а штатный ответ
детектора: программа доработала, но гонки были найдены.
Что показал прогон:
- Детектор называет адрес, а не переменную.
Write at 0x00c00001c450 by goroutine 8иPrevious write at 0x00c00001c450 by goroutine 7— один адрес, две горутины. Имяbufв отчёте не появляется вообще: гонка живёт на уровне памяти, а не имён. - Он даёт обе стороны и место создания каждой горутины. Четыре стека: где записали сейчас, где записали до того, и где обе горутины были запущены. Этого достаточно, чтобы найти нарезку.
- Код возврата 66 — штатный ответ детектора: программа доработала, но
гонки найдены. Через
go runего не видно (go подменяет его своим 1 и печатаетexit status 66строкой), поэтому драйвер собирает двоичный файл и запускает его сам. - Оба лечения работают, и детектор молчит.
fixedsplit— непересекающиеся диапазоныbuf[0:4]иbuf[4:8];fixedclip—slices.Clip, после которогоcap == lenиappendобязан выделить своё.
Отдельно стоит заметить, что fixedsplit оставляет массив ОБЩИМ и это
нормально: гонка бывает не от общего массива, а от общей ячейки.
Важная оговорка. Детектор не доказывает отсутствие гонок. Документация Go («Data Race Detector») говорит прямо, что инструмент находит гонки, случившиеся во время выполнения; те, что на этом прогоне не случились, он не находит. Молчание на починенных режимах — свидетельство, а не доказательство.
Побочный результат, стоивший одной итерации отладки: первая версия подопытной
программы копила результат append в ОДНУ общую переменную sink, и детектор
честно показал гонку на ней — в том числе в «починенном» режиме. Пришлось
развести на sinkA и sinkB. Мораль ровно та же, что и у самой темы: общая
ячейка находится там, где её не искали.
Источники
- Спецификация Go. Slice types, Slice expressions, Appending to and copying slices — https://go.dev/ref/spec
runtime/slice.go, функцииgrowsliceиnextslicecap— https://go.dev/src/runtime/slice.go- Коммит «runtime: make slice growth formula a bit smoother» — https://github.com/golang/go/commit/2dda92ff6f9f07eeb110ecbf0fc2d7a0ddd27f9d
- Go Slices: usage and internals — https://go.dev/blog/slices-intro
- Документация пакета
slices, функцииClipиClone— https://go.dev/src/slices/slices.go - Data Race Detector — https://go.dev/doc/articles/race_detector
- Заметки к выпуску Go 1.18, раздел Runtime — https://go.dev/doc/go1.18
runtime/sizeclasses.go, таблицаclass_to_size— https://go.dev/src/runtime/sizeclasses.go
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()
}