Deep Engineering

ЗАМЕР

bench/goslice/cost_test.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/go/data-structures/slices
Прогон
go1.24.7 linux/amd64, Intel Xeon 2.80GHz
Как запустить
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 .

Запись прогона

Замеры для статьи «Срез в 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. Мораль ровно та же, что и у самой темы: общая ячейка находится там, где её не искали.

Источники

Скрипт

206 строк
// Цена среза: четыре блока, и в каждом строки делают ОДНУ И ТУ ЖЕ работу.
//
// ЧТО ЗДЕСЬ СРАВНИМО. Внутри блока — всё: имена подобраны так, чтобы результат
// у всех строк блока был одинаковым, а отличался только способ. Между блоками
// сравнивать нельзя: там разный объём работы.
//
// ПОЧЕМУ ВАЖЕН НЕ ТОЛЬКО ns/op. Главный вывод статьи про срез — не про
// наносекунды, а про число выделений и объём аллокаций, поэтому запускать
// нужно с -benchmem, и в README опубликованы все три столбца.
//
// ЗАПУСК:
//
//	go test -run '^$' -bench . -benchmem ./bench/goslice/
//
// Снято на go1.24.7 linux/amd64.
package goslice

import (
	"slices"
	"testing"
)

const N = 10000

// Приёмники результата: без них компилятор вправе выбросить тело цикла, и
// замер выродится в измерение пустоты. Типы конкретные, а не `any`, намеренно:
// присваивание среза в интерфейс само по себе выделяет 24 байта под копию
// заголовка, и в столбце B/op появлялась бы лишняя строка, к срезу отношения
// не имеющая.
var (
	sinkSlice []int
	sinkInt   int64
)

// ---------------------------------------------------------------------------
// Блок 1. Построить срез из N элементов.
// Все четыре строки дают []int длиной N с теми же значениями.
// ---------------------------------------------------------------------------

func BenchmarkBuildAppendNoPrealloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var s []int
		for j := 0; j < N; j++ {
			s = append(s, j)
		}
		sinkSlice = s
	}
}

func BenchmarkBuildAppendPrealloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := make([]int, 0, N)
		for j := 0; j < N; j++ {
			s = append(s, j)
		}
		sinkSlice = s
	}
}

func BenchmarkBuildIndexPrealloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := make([]int, N)
		for j := 0; j < N; j++ {
			s[j] = j
		}
		sinkSlice = s
	}
}

// slices.Grow — тот же запас, но заказанный отдельно от длины. Полезен, когда
// сколько-то элементов уже есть и добавится ещё известное количество.
func BenchmarkBuildSlicesGrow(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := slices.Grow([]int(nil), N)
		for j := 0; j < N; j++ {
			s = append(s, j)
		}
		sinkSlice = s
	}
}

// ---------------------------------------------------------------------------
// Блок 2. Скопировать N элементов в готовый массив.
// Все три строки дают одинаковое содержимое dst.
// ---------------------------------------------------------------------------

var src = func() []int {
	s := make([]int, N)
	for i := range s {
		s[i] = i
	}
	return s
}()

func BenchmarkCopyBuiltin(b *testing.B) {
	dst := make([]int, N)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		copy(dst, src)
	}
	sinkSlice = dst
}

func BenchmarkCopyAppendSpread(b *testing.B) {
	dst := make([]int, 0, N)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		dst = append(dst[:0], src...)
	}
	sinkSlice = dst
}

func BenchmarkCopyLoop(b *testing.B) {
	dst := make([]int, N)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		for j := range src {
			dst[j] = src[j]
		}
	}
	sinkSlice = dst
}

// ---------------------------------------------------------------------------
// Блок 3. Просуммировать поле у N крупных структур.
// Обе строки дают одну и ту же сумму; отличается только форма диапазона.
// ---------------------------------------------------------------------------

type record struct {
	id   int64
	name [64]byte
	tags [8]int64
}

var records = make([]record, N)

func BenchmarkRangeByValue(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var total int64
		for _, r := range records { // r — копия 136 байт на каждой итерации
			total += r.id
		}
		sinkInt = total
	}
}

func BenchmarkRangeByIndex(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var total int64
		for j := range records {
			total += records[j].id
		}
		sinkInt = total
	}
}

// ---------------------------------------------------------------------------
// Блок 4. Удалить элемент из середины среза длиной N.
// Все три строки дают срез длиной N−1 с тем же содержимым.
// ---------------------------------------------------------------------------

const mid = N / 2

// Точка отсчёта для блока 4. Удаление разрушает срез, поэтому каждая итерация
// обязана восстановить его — и восстановление стоит дороже самого удаления.
// Без этой строки блок читался бы как «все три способа почти одинаковы», а на
// деле почти всё измеренное в них — вот этот copy.
func BenchmarkDeleteSetupOnly(b *testing.B) {
	buf := make([]int, N)
	for i := 0; i < b.N; i++ {
		copy(buf, src)
		sinkSlice = buf
	}
}

func BenchmarkDeleteAppend(b *testing.B) {
	buf := make([]int, N)
	for i := 0; i < b.N; i++ {
		copy(buf, src)
		s := append(buf[:mid], buf[mid+1:]...)
		sinkSlice = s
	}
}

func BenchmarkDeleteSlicesDelete(b *testing.B) {
	buf := make([]int, N)
	for i := 0; i < b.N; i++ {
		copy(buf, src)
		s := slices.Delete(buf, mid, mid+1)
		sinkSlice = s
	}
}

// Порядок не важен — тогда дырку затыкают последним элементом, и копировать
// нечего вовсе. Строка стоит здесь, чтобы было видно, сколько стоит именно
// требование сохранить порядок.
func BenchmarkDeleteSwapLast(b *testing.B) {
	buf := make([]int, N)
	for i := 0; i < b.N; i++ {
		copy(buf, src)
		buf[mid] = buf[len(buf)-1]
		s := buf[:len(buf)-1]
		sinkSlice = s
	}
}