Перевод/заметки fmt.Sprintf vs String Concat


Конкатенация строк не всегда является самым элегантным решением:

getFieldName := gf.tableName + "+" + gf.ColName

Однако она гораздо быстрее, чем fmt.Sprintf:

getFieldName := fmt.Sprintf("%s+%s", gf.tableName, gf.ColName)

Недавно мы столкнулись с распространённым мнением о том, что строковые операции выполняются нечасто и не занимают много времени по сравнению с накладными расходами на сеть и ввод-вывод. Для серверов в ДЦ это, конечно, верно. Но переход от Sprintf к + улучшает производительность Dolt в целом для локального сервера на 1-3%, (бенчмарки здесь):

goos: darwin
goarch: arm64
pkg: github.com/dolthub/dolt/go/performance/microsysbench
                         │  before.txt   │             after.txt             │
                         │    sec/op     │   sec/op     vs base              │
OltpPointSelect-12          22.85µ ± 13%   21.61µ ± 1%  -5.44% (p=0.002 n=6)
OltpJoinScan-12             267.5µ ±  2%   259.2µ ± 1%  -3.13% (p=0.002 n=6)
ProjectionAggregation-12    11.76m ± 14%   11.67m ± 9%       ~ (p=0.818 n=6)
SelectRandomPoints-12       74.46µ ±  1%   71.45µ ± 1%  -4.05% (p=0.002 n=6)
SelectRandomRanges-12      101.69µ ±  1%   98.14µ ± 4%  -3.49% (p=0.015 n=6)
geomean                     222.4µ         214.9µ       -3.39%

                         │  before.txt   │             after.txt              │
                         │     B/op      │     B/op      vs base              │
OltpPointSelect-12         19.88Ki ±  0%   19.46Ki ± 0%  -2.13% (p=0.002 n=6)
OltpJoinScan-12            164.6Ki ±  0%   162.8Ki ± 0%  -1.08% (p=0.002 n=6)
ProjectionAggregation-12   2.930Mi ± 14%   2.945Mi ± 8%       ~ (p=0.937 n=6)
SelectRandomPoints-12      68.56Ki ±  0%   67.87Ki ± 0%  -1.01% (p=0.002 n=6)
SelectRandomRanges-12      99.81Ki ±  0%   99.46Ki ± 0%  -0.36% (p=0.002 n=6)
geomean                    146.4Ki         145.2Ki       -0.82%

                         │  before.txt  │             after.txt             │
                         │  allocs/op   │  allocs/op   vs base              │
OltpPointSelect-12          443.0 ±  0%    416.0 ± 0%  -6.09% (p=0.002 n=6)
OltpJoinScan-12            6.019k ±  0%   5.903k ± 0%  -1.93% (p=0.002 n=6)
ProjectionAggregation-12   70.30k ± 14%   70.69k ± 8%       ~ (p=0.937 n=6)
SelectRandomPoints-12      1.255k ±  0%   1.211k ± 0%  -3.51% (p=0.002 n=6)
SelectRandomRanges-12      2.375k ±  0%   2.353k ± 0%  -0.93% (p=0.002 n=6)
geomean                    3.544k         3.458k       -2.41%

Не всегда легко добиться повышения производительности на несколько процентов, особенно когда требуется внести изменения всего лишь в 4 строках кода:

func (p *GetField) String() string {
-	return fmt.Sprintf("%s.%s", p.table, p.name)
+	return p.table + "." + p.name
}

Как работает fmt.Sprintf

Функция принимает на вход строку для форматирования и несколько аргументов типа any.

Упрощенный псевдокод Sprintf может выглядеть следующим образом:

type formatter struct {
	buf   []byte
	lastI int
	argI  int
}

func (f *formatter) Sprintf(format string, args []any) string {
	for i := 0; i < len(format); i++ {
		if format[i] == '%' {
			if i != f.lastI {
				f.buf = append(f.buf, format[f.lastI:i])
			}
			f.lastI = i
			i++
			switch format[i] {
			case 's':
				f.lastI = i+1
				f.fmtString(args[f.argI])
				f.argI++
			case 'd':
				f.lastI = i+1
				f.fmtInt(args[f.argI])
				f.argI++
			case '%': // percent literal, passthrough
			default:
				panic("unknown format sequence")
			}
		}
        i++
	}
	if f.lastI != len(format) {
		f.buf = append(f.buf, format[f.lastI:])
	}
	return string(f.buf)
}

Мы ограничились рассмотрением только символов %s и %d, не углубляясь в более сложные детали форматирования и не объединяя объекты formatter. Тем не менее, основной цикл наглядно демонстрирует суть процесса:

  1. Найти следующий форматирующий символ
  2. Записать строку между текущим и последним форматирующим символом
  3. Записать следующий аргумент, основываясь на текущем символе
  4. Запись префиксной строки

Помимо отслеживания состояний, значительная часть сложности связана с форматорами, которые зависят от типа:

func (f *formatter) fmtString(a any) {
	switch s := a.(type) {
	case string:
		copy(f.buf[f.bufI:], s)
		f.bufI += len(s)
	case fmt.Stringer:
		sS := s.String()
		copy(f.buf[f.bufI:], sS)
		f.bufI += len(sS)
	default:
		panic("unable to format %T as string")
	}
}

Почему fmt.Sprintf медленный?

Первая сложность связана с интерфейсом any. Эта 8-байтовая обертка вмещает любой тип данных, который мы хотели бы вывести, и автоматически использует интерфейс Stringer.

Вторая проблема заключается в аллокации объектов для отслеживания состояния форматирования и промежуточных буферов для записи новых строк.

Наконец, третья проблема — это объем и гибкость кода, необходимые для обеспечения полной корректности. Стандартная библиотека содержит несколько сотен строк кода, который сильно разветвлён.

Почему конкатенация строк быстрая?

Оператор + в языке Go преобразуется в операцию accumulate/reduct. Все аргументы собираются в стеке и передаются оператору runtime.stringconcat. stringconcat подсчитывает общее количество байт в строке и использует его, чтобы определить, поместится ли результат в стек для возврата [32]byte или потребуется выделить буфер в куче.

s, b := rawstringtmp(buf, strLength)
	for _, x := range args {
		copy(b, x)
		b = b[len(x):]
	}
	return s

Если идти по быстрому пути и сохранять размер строк небольшим, то конкатенация будет выполняться очень быстрой.


Комментарии в Telegram-группе!