Deep Engineering

MEASUREMENT

bench/gostring/practice.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/strings-runes-bytes

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

This measurement has no recorded run — only the script.

Script

173 lines
//go:build ignore

// Две задачи урока про строки, руны и байты: обе отвечают прогоном.
//
// ЗАДАЧА 1 — ПРЕДСКАЗАТЬ ВЫВОД. Всё вокруг одной путаницы: строка — это
// последовательность БАЙТОВ, а не символов. len даёт байты; индексация даёт
// байт; range даёт руны и байтовые смещения (поэтому индексы идут не подряд);
// []rune даёт руны. Разные числа на одной и той же строке.
//
// Строка «A世🙂» выбрана так, чтобы покрыть все три длины UTF-8 сразу: 1, 3
// и 4 байта. На чисто латинской строке ни одна ошибка темы не проявляется
// вовсе — и ровно поэтому живёт в коде до первого нелатинского ввода.
//
// Последняя строка — отдельная ловушка. string(s[0]) НЕ декодирует байт: это
// конвертация ЧИСЛА в руну с таким кодом. Для 230 получается «æ» длиной два
// байта — символ, не имеющий к исходной строке никакого отношения. Байтовый
// срез s[:1] дал бы другое неверное: один байт некорректного UTF-8.
//
// ЗАДАЧА 2 — ОЦЕНИТЬ КРАТНОСТЬ. Во сколько раз дороже посчитать руны через
// материализованный срез `r := []rune(s)`, чем через utf8.RuneCountInString.
// Работа одна — узнать число рун; отличается то, что первый способ ВЫДЕЛЯЕТ
// срез под все руны, а второй идёт по строке и не выделяет ничего.
//
// ПОЧЕМУ ИМЕННО МАТЕРИАЛИЗОВАННЫЙ СРЕЗ, А НЕ len([]rune(s)). Первая редакция
// сравнивала ровно то, что советуют во всех статьях, — `len([]rune(s))`
// против RuneCountInString, — и получила кратность 1,06, то есть НИКАКОЙ
// разницы. Проверка выделений объяснила почему: у `len([]rune(s))` ноль
// выделений. Компилятор узнаёт это выражение и считает руны на месте, не
// строя срез; советы, повторяющие «замените len([]rune(s)) на
// RuneCountInString», описывают компилятор, которого давно нет.
//
// Разница появляется там, где срез действительно НУЖЕН — когда его сохраняют
// и индексируют. Тогда выделение настоящее (проверено: 1), и его цена и есть
// содержательный ответ. Публиковать первую редакцию было бы враньём, а
// выбросить её молча — потерей самого интересного в теме, поэтому оба числа
// печатаются ниже.
//
// ПОЧЕМУ НЕ ВЗЯТА КОНКАТЕНАЦИЯ В ЦИКЛЕ. Кратность `s += x` против
// strings.Builder уходит в сотни раз, но квадратично зависит от числа витков:
// такое число измеряет длину цикла, а не разницу приёмов.
//
// ВЫВОД НАМЕРЕННО БЕЗ ПОДПИСЕЙ: те же строки печатаются в русской и английской
// версиях урока, поэтому в них не должно быть слов ни одного языка.
//
// ЗАПУСК:
//
//	go run bench/gostring/practice.go
package main

import (
	"fmt"
	"strconv"
	"strings"
	"testing"
	"unicode/utf8"
)

// ---------------------------------------------------------------- задача 1

func task1() {
	// «A世🙂» — три знака и восемь байт: латинская буква занимает один,
	// иероглиф три, эмодзи четыре. Одна строка покрывает все три случая
	// UTF-8, и потому проходит через весь урок.
	//
	// ПОЧЕМУ ПЕЧАТАЮТСЯ ОБА БАЙТА, s[0] И s[1]. Первый — полноценный
	// символ сам по себе, второй — начало трёхбайтовой последовательности.
	// Пара показывает, что «взять первый байт» иногда случайно работает, и
	// именно поэтому ошибка живёт в коде: на латинице она не проявляется.
	//
	// КОММЕНТАРИЕВ ВНУТРИ БЛОКА НЕТ НАМЕРЕННО: эти строки целиком
	// переносятся в оба перевода урока, поэтому в них не должно быть слов
	// ни одного языка.
	s := "A世🙂"

	fmt.Println(len(s))
	fmt.Println(utf8.RuneCountInString(s))
	fmt.Println(s[0])
	fmt.Println(s[1])
	fmt.Println(len([]rune(s)))

	var offsets []string
	for i := range s {
		offsets = append(offsets, strconv.Itoa(i))
	}
	fmt.Println(strings.Join(offsets, " "))

	fmt.Println(len(string(s[0])))
	fmt.Println(len(string(s[1])))
}

// ---------------------------------------------------------------- задача 2

// Строка нарочно смешанная: латиница по одному байту, кириллица по два.
// На чистой латинице разрыв был бы меньше — и урок получил бы число,
// не переносимое на реальные тексты.
var text = "Ёжик в тумане шёл и считал руны: one, two, three, four, five."

func runeCount() float64 {
	r := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = utf8.RuneCountInString(text)
		}
	})
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

// sink нужен, чтобы срез не остался локальным: иначе escape-анализ разместит
// его на стеке и замер покажет цену, которой в реальном коде не будет.
var sink []rune

func viaKeptSlice() float64 {
	r := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sink = []rune(text)
		}
	})
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

// Та самая форма из советов: компилятор узнаёт её и срез не строит.
func viaLenExpr() float64 {
	r := testing.Benchmark(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = len([]rune(text))
		}
	})
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

func task2() {
	// Чередующиеся круги: если машина в середине замера занялась чем-то
	// своим, это ударит по всем трём замерам, а не по одному.
	countBest, keptBest, lenBest := runeCount(), viaKeptSlice(), viaLenExpr()
	for i := 1; i < 7; i++ {
		if v := runeCount(); v < countBest {
			countBest = v
		}
		if v := viaKeptSlice(); v < keptBest {
			keptBest = v
		}
		if v := viaLenExpr(); v < lenBest {
			lenBest = v
		}
	}

	ratio := keptBest / countBest
	fmt.Printf("\n  utf8.RuneCountInString(s)     %6.2f нс   выделений: %.0f\n",
		countBest, testing.AllocsPerRun(100, func() { _ = utf8.RuneCountInString(text) }))
	fmt.Printf("  len([]rune(s))                %6.2f нс   выделений: %.0f\n",
		lenBest, testing.AllocsPerRun(100, func() { _ = len([]rune(text)) }))
	fmt.Printf("  r := []rune(s), срез сохранён %6.2f нс   выделений: %.0f\n",
		keptBest, testing.AllocsPerRun(100, func() { sink = []rune(text) }))
	fmt.Printf("\n  кратность (сохранённый срез / RuneCountInString) %6.2f\n", ratio)
	fmt.Printf("  она же округлённо                                %6.1f\n", ratio)
	fmt.Printf("\n  Лучший из 7 чередующихся кругов. go1.24.7 linux/amd64\n")
	fmt.Printf("\n  Строка — %d байт, %d рун.\n", len(text), utf8.RuneCountInString(text))
	fmt.Printf("\n  Средняя строка — та самая, которую советуют заменить на\n")
	fmt.Printf("  RuneCountInString. Замер совета не подтверждает: у неё ноль\n")
	fmt.Printf("  выделений и то же время. Компилятор узнаёт выражение\n")
	fmt.Printf("  len([]rune(s)) и считает руны на месте, среза не строя.\n")
	fmt.Printf("\n  Разница появляется там, где срез НУЖЕН — когда его сохраняют\n")
	fmt.Printf("  и индексируют. Там выделение настоящее, и вот его цена.\n")
}

func main() {
	fmt.Println("ЗАДАЧА 1 — предсказать вывод")
	fmt.Println()
	task1()
	fmt.Println()
	fmt.Println("ЗАДАЧА 2 — во сколько раз дороже сохранённый срез рун")
	task2()
}