Deep Engineering

ЗАМЕР

bench/gostring/internals.go

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

Цитируется в статье
/ru/interview/golang/strings-runes-bytes

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

У этого замера записи прогона нет — только скрипт.

Скрипт

337 строк
//go:build ignore

// Строки изнутри: что такое строка, что она удерживает и какие конвертации
// бесплатны.
//
// ЧЕТЫРЕ БЛОКА — четыре утверждения, каждое из которых проверяется, а не
// пересказывается:
//
//  1. строка — это заголовок из указателя и длины, и подстрока не копирует
//     ничего;
//  2. ровно поэтому подстрока УДЕРЖИВАЕТ весь исходный массив. Это та же
//     ловушка, что у срезов, и в текстовой обработке она встречается чаще;
//  3. часть конвертаций string↔[]byte компилятор делает бесплатно — их надо
//     знать поимённо, иначе «конвертация всегда копирует» превращается в
//     ненужный ручной обход;
//  4. склейка в цикле против strings.Builder — и цена, и число выделений.
//
// ЗАПУСК:
//
//	go run bench/gostring/internals.go
package main

import (
	"fmt"
	"runtime"
	"strings"
	"testing"
	"unicode/utf8"
	"unsafe"
)

// Отрицательная дельта — не результат, а шум сборщика: после GC куча может
// оказаться на десятые доли мегабайта меньше исходной. Печатать «-0.0 МБ»
// значило бы предъявлять читателю разрешение прибора вместо числа.
func clamp(v float64) float64 {
	if v < 0 {
		return 0
	}
	return v
}

func heapMB() float64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return float64(m.HeapAlloc) / (1 << 20)
}

// ------------------------------- 0. три уровня: байты, руны, графемы

// ТРИ УРОВНЯ, КОТОРЫЕ ПОСТОЯННО СМЕШИВАЮТ — и печатаются они первыми потому,
// что без этого различения весь остальной урок читается как набор исключений.
//
// Байт — единица хранения. Руна — кодовая точка Unicode. Графемный кластер —
// то, что человек называет символом. Эти три числа НЕ совпадают, и ни одно из
// них не является «правильным ответом на вопрос сколько символов»: правилен
// тот, который соответствует задаче.
//
// ПОЧЕМУ é ЗАПИСАН ЭКРАНИРОВАНИЕМ, А НЕ БУКВОЙ. Один и тот же видимый знак
// существует в двух формах: готовой (U+00E9) и составной (e + U+0301). Если
// написать его в исходнике буквой, форма будет зависеть от того, чем этот
// файл когда-то сохранили, — и замер начнёт печатать разное на разных
// машинах. Экранирование делает обе формы явными и воспроизводимыми.
func blockLevels() {
	fmt.Println("ТРИ УРОВНЯ: БАЙТЫ, РУНЫ, ГРАФЕМЫ")
	fmt.Println("────────────────────────────────")
	fmt.Println("  строка                       байт   рун   видимых знаков")

	rows := []struct {
		label   string
		s       string
		visible int
	}{
		{`"A世🙂"`, "A\u4e16\U0001F642", 3},
		{`"é" готовая форма`, "\u00e9", 1},
		{`"é" составная форма`, "e\u0301", 1},
		{`"👩‍👩‍👧" семья`, "\U0001F469\u200D\U0001F469\u200D\U0001F467", 1},
	}
	for _, r := range rows {
		fmt.Printf("  %-28s %4d %5d %14d\n",
			r.label, len(r.s), utf8.RuneCountInString(r.s), r.visible)
	}

	fmt.Println()
	fmt.Println("  Две средние строки — один и тот же знак на экране и разные")
	fmt.Println("  числа рун. Значит, руна НЕ равна символу, и счётчик рун на")
	fmt.Println("  вопрос «сколько символов» отвечает верно только тогда, когда")
	fmt.Println("  текст заведомо в готовой форме.")
	fmt.Println()
	fmt.Println("  Последняя строка доводит это до предела: восемнадцать байт,")
	fmt.Println("  пять рун, один видимый знак. Пять рун — это три эмодзи и два")
	fmt.Println("  соединителя нулевой ширины; разрезать такую последовательность")
	fmt.Println("  по руне значит получить на экране трёх отдельных людей.")
	fmt.Println()
	fmt.Println("  Столбец видимых знаков посчитан вручную: разбиение на")
	fmt.Println("  графемные кластеры стандартной библиотекой не делается вовсе.")
	fmt.Println("  Это и есть содержательный ответ — «посчитать символы» в общем")
	fmt.Println("  случае требует отдельного пакета, а не len и не []rune.")
}

// ------------------------------------- 0б. нарезка режет по байтам

// РЕЗАТЬ СТРОКУ МОЖНО ТОЛЬКО ПО БАЙТАМ, и это стоит увидеть, а не запомнить.
// Индексы среза строки — байтовые, поэтому s[:n] с произвольным n способно
// разрубить последовательность UTF-8 пополам. Результат не паникует и не
// ругается: получается строка, которая просто некорректна.
func blockSlicing() {
	fmt.Println()
	fmt.Println("НАРЕЗКА РЕЖЕТ ПО БАЙТАМ, А НЕ ПО СИМВОЛАМ")
	fmt.Println("─────────────────────────────────────────")

	s := "A\u4e16\U0001F642"
	for _, n := range []int{1, 2, 4, 8} {
		part := s[:n]
		fmt.Printf("  s[:%d]  %-14q корректный UTF-8: %-5v рун: %d\n",
			n, part, utf8.ValidString(part), utf8.RuneCountInString(part))
	}

	fmt.Println()
	fmt.Println("  Вторая строка — разрубленный иероглиф. Программа не упала и")
	fmt.Println("  ничего не сообщила: строка в Go — последовательность")
	fmt.Println("  ПРОИЗВОЛЬНЫХ байтов, и некорректный UTF-8 в ней законен.")
	fmt.Println()
	fmt.Println("  Обратите внимание на счётчик рун во второй строке: он")
	fmt.Println("  насчитал руну там, где её нет. Обход подставляет вместо")
	fmt.Println("  каждого некорректного байта символ замены U+FFFD — то есть")
	fmt.Println("  повреждение превращается в вопросительный знак в ромбике, а")
	fmt.Println("  не в ошибку.")
	fmt.Println()
	fmt.Println("  Практическое: обрезать текст по длине (например, до 100")
	fmt.Println("  символов для превью) байтовым срезом нельзя. Резать надо по")
	fmt.Println("  границам, которые даёт range, — он идёт ровно по началам рун.")
}

// ------------------------------------------------- 1. что такое строка

var (
	sinkStr   string
	sinkBytes []byte
	sinkRunes []rune
	sinkBool  bool
	sinkInt   int
)

func blockHeader() {
	var s string
	var b []byte

	fmt.Println("ЧТО ТАКОЕ СТРОКА")
	fmt.Println("────────────────")
	fmt.Printf("  заголовок string                %2d байт  (указатель + длина)\n", unsafe.Sizeof(s))
	fmt.Printf("  заголовок []byte                %2d байт  (указатель + длина + ёмкость)\n", unsafe.Sizeof(b))
	fmt.Println()

	long := strings.Repeat("абвгд", 200)
	fmt.Printf("  подстрока long[10:20]           выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkStr = long[10:20] }))
	fmt.Printf("  []byte(long)                    выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkBytes = []byte(long) }))
	fmt.Printf("  string(bytes)                   выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkStr = string(sinkBytes) }))
	fmt.Println()
	fmt.Println("  Подстрока не копирует НИЧЕГО: она берёт указатель внутрь")
	fmt.Println("  того же массива и свою длину. Отсюда и неизменяемость —")
	fmt.Println("  запись в строку испортила бы все подстроки разом, поэтому")
	fmt.Println("  её просто нет в языке: s[0] = 'x' не компилируется.")
	fmt.Println()
	fmt.Println("  А конвертация в []byte и обратно копирует, и это не")
	fmt.Println("  недосмотр: срез изменяем, и без копии изменяемое и")
	fmt.Println("  неизменяемое смотрели бы в одну память.")
}

// ------------------------------------- 2. подстрока удерживает весь массив

func blockRetained() {
	fmt.Println()
	fmt.Println("ЧТО УДЕРЖИВАЕТ КОРОТКАЯ ПОДСТРОКА ДЛИННОЙ СТРОКИ")
	fmt.Println("────────────────────────────────────────────────")

	base := heapMB()

	// 8 МБ текста, из которого нужны первые десять байт.
	huge := strings.Repeat("x", 8<<20)
	full := heapMB()

	// Держим ТОЛЬКО подстроку. Исходная строка недостижима.
	part := huge[:10]
	huge = ""
	kept := heapMB()

	// Та же подстрока, но скопированная.
	copied := strings.Clone(part)
	part = ""
	after := heapMB()

	fmt.Printf("  строка на 8 МБ создана                    %5.1f МБ\n", clamp(full-base))
	fmt.Printf("  оставили подстроку из %d байт             %5.1f МБ\n", len(copied), clamp(kept-base))
	fmt.Printf("  после strings.Clone                       %5.1f МБ\n", clamp(after-base))
	fmt.Println()
	fmt.Println("  Десять байт удерживают восемь мегабайт. Причина та же,")
	fmt.Println("  по которой подстрока бесплатна: она указывает ВНУТРЬ")
	fmt.Println("  исходного массива, а сборщик собирает массив целиком или")
	fmt.Println("  не собирает вовсе.")
	fmt.Println()
	fmt.Println("  Где это встречается: разобрали большой ответ, сохранили из")
	fmt.Println("  него идентификатор — и ответ остался в памяти вместе с ним.")
	fmt.Println("  Лечится strings.Clone: он копирует, и ссылка на большой")
	fmt.Println("  массив пропадает.")
	_ = copied
}

// -------------------------------------------- 3. бесплатные конвертации

func blockFreeConversions() {
	fmt.Println()
	fmt.Println("КАКИЕ КОНВЕРТАЦИИ НЕ ВЫДЕЛЯЮТ")
	fmt.Println("──────────────────────────────")

	m := map[string]int{"ключ": 1}
	key := []byte("ключ")
	str := "ключ"

	fmt.Printf("  m[string(b)]                    выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkInt = m[string(key)] }))
	fmt.Printf("  string(b) == s                  выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkBool = string(key) == str }))
	fmt.Printf("  for range string(b)             выделений: %.0f\n",
		testing.AllocsPerRun(100, func() {
			for range string(key) {
			}
		}))
	fmt.Printf("  s := string(b), строка сохранена выделений: %.0f\n",
		testing.AllocsPerRun(100, func() { sinkStr = string(key) }))
	fmt.Println()
	fmt.Println("  Три первые формы компилятор узнаёт и копию не строит:")
	fmt.Println("  строка нужна только на время выражения и наружу не")
	fmt.Println("  попадает. Четвёртая сохраняет строку — и копия обязательна.")
	fmt.Println()
	fmt.Println("  Практический вывод: считать ключ из []byte и искать в карте")
	fmt.Println("  можно прямо, без ручных ухищрений и без unsafe. Правило")
	fmt.Println("  «конвертация всегда копирует» верно только там, где")
	fmt.Println("  результат переживает выражение.")
}

// ------------------------------------------------- 4. склейка в цикле

func concatPlus(n int) float64 {
	r := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			s := ""
			for j := 0; j < n; j++ {
				s += "abcdefgh"
			}
			sinkStr = s
		}
	})
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

func concatBuilder(n int, grow bool) float64 {
	r := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			var sb strings.Builder
			if grow {
				sb.Grow(n * 8)
			}
			for j := 0; j < n; j++ {
				sb.WriteString("abcdefgh")
			}
			sinkStr = sb.String()
		}
	})
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

func blockConcat() {
	fmt.Println()
	fmt.Println("СКЛЕЙКА В ЦИКЛЕ: ПОЧЕМУ ЭТО НЕ ПРО СКОРОСТЬ ОПЕРАЦИИ")
	fmt.Println("────────────────────────────────────────────────────")
	fmt.Println("  кусков      s += x       Builder    Builder+Grow      s+=x / Builder")

	for _, n := range []int{10, 100, 1000} {
		p, b, g := concatPlus(n), concatBuilder(n, false), concatBuilder(n, true)
		fmt.Printf("  %6d  %9.0f нс %9.0f нс %9.0f нс %14.1f\n", n, p, b, g, p/b)
	}

	fmt.Println()
	fmt.Printf("  выделений на 1000 кусков:  s += x  %5.0f   Builder %3.0f   Builder+Grow %3.0f\n",
		testing.AllocsPerRun(3, func() {
			s := ""
			for j := 0; j < 1000; j++ {
				s += "abcdefgh"
			}
			sinkStr = s
		}),
		testing.AllocsPerRun(3, func() {
			var sb strings.Builder
			for j := 0; j < 1000; j++ {
				sb.WriteString("abcdefgh")
			}
			sinkStr = sb.String()
		}),
		testing.AllocsPerRun(3, func() {
			var sb strings.Builder
			sb.Grow(8000)
			for j := 0; j < 1000; j++ {
				sb.WriteString("abcdefgh")
			}
			sinkStr = sb.String()
		}))
	fmt.Println()
	fmt.Println("  Смотреть надо не на кратность — она растёт с числом кусков и")
	fmt.Println("  потому ничего не значит сама по себе, — а на её РОСТ. Строки")
	fmt.Println("  неизменяемы, поэтому каждый += строит новую строку и копирует")
	fmt.Println("  в неё всё накопленное: работа квадратична по числу кусков.")
	fmt.Println()
	fmt.Println("  Builder пишет в растущий буфер и отдаёт его строкой без")
	fmt.Println("  копии — выделений у него единицы против тысячи. Grow убирает")
	fmt.Println("  и их, когда итоговый размер известен заранее.")
	fmt.Println()
	fmt.Println("  Оговорка, без которой правило вредит: на двух-трёх кусках")
	fmt.Println("  a + b + c быстрее Builder и читается лучше. Компилятор")
	fmt.Println("  склеивает такое одним вызовом concatstrings.")
}

func main() {
	fmt.Println("go1.24.7 linux/amd64 | строки изнутри")
	fmt.Println()
	blockLevels()
	blockSlicing()
	blockHeader()
	blockRetained()
	blockFreeConversions()
	blockConcat()
	_ = sinkRunes
}