Сегодня мы будем разгонять склеивание коротких строк в Go на 30%. Причём для этого нам не нужно будет модифицировать сам Go, всё это будет реализованно в виде сторонней библиотеки. Под катом вас ждут: Данную статью можно также считать предлогом обсудить CL123256: runtime,cmd/compile: specialize concatstring2. Идеи по улучшению этого change list'а приветствуются. Сравнение производилось с go tip (master) версией компилятора. Аналогичные результаты вы можете получить на версиях примерно с Go 1.5. Последним значительным изменением функции concatstrings был CL3120: cmd/gc: allocate buffers for non-escaped strings on stack. ConcatOperator использует +.
ConcatBuilder использует strings.Builder с правильной пред-аллокацией.
Concat использует функцию, которую мы реализуем в рамках этой истории. Сравнение через benchstat: Ассемблерная реализация под GOARCH=AMD64 немного быстрее и обладает дополнительной оптимизацией, которая присутствует у встроенного оператора +, но об этом ниже: Ассемблерную функцию будем брать как 100% производительности (относительно остальных рассматриваемых реализаций). Самым простым решением является использование оператора +. Семантика этого оператора такая: взять две строки и вернуть строку-результат, которая содержит сцепление обеих строк. При этом нет гарантии, что будет возвращена новая строка. Например, если происходит сцепление пустой строки и любой другой, runtime может вернуть непустой аргумент, избегая необходимости выделять новую память и копировать туда данные. Но, как видно из результатов в начале статьи, это самый медленный способ. Не так давно в Go добавили новый тип — strings.Builder. Это аналог bytes.Buffer, но при вызове метода String() не происходит повторного выделения памяти и копирования данных. В отличие от bytes.Buffer, builder не имеет оптимизации малого буфера и, следовательно, предварительно аллоцированной памяти под строку. Если не использовать метод Grow, производительность будет хуже, чем в случае с bytes.Buffer. Несколько регрессий в Go 1.11 вызваны именно этой особенностью (см. CL113235). В нашем коде, для чистоты эксперимента, мы будем избегать этой ошибки. Если посмотреть, какой код генерирует компилятор для оператора +, мы увидим вызовы функций concatstring2, concatstring3 и так далее (до concatstring5 включительно). Заглянем в сам runtime/string.go: Значит, осталось изучить функцию concatstrings.
Полный листинг доступен ниже под спойлером, а вот высокоуровневое описание: Здесь мы видим сразу несколько мест, которые могут быть оптимизированы для частного случая: При выполнении ./make.bash (сборка Go компилятора и stdlib) видим 445 конкатенаций с двумя операндами: Итого 89% конкатенаций от двух аргументов попадают пот оптимизацию. Для утилиты go имеем: Для реализации специализации нам потребуется знать, как в Go представлены строки. Нам важна бинарная совместимость, при этом unsafe.Pointer можно заменить на *byte без каких-либо жертв. Второй важный вывод, который мы можем сделать из рантайма: строки начинают свою жизнь мутабельными. Выделяется участок памяти, на который ссылается []byte, в который записывается содержимое новой строки, и только после этого []byte выбрасывается, а память, на которую он ссылался, сохраняется в stringStruct.  Для тех, кому хочется больше деталей, предлагается изучить функции rawstringtmp и rawstring. Мы можем провернуть примерно то же самое, воспользовавшись тёмной стороной пакета unsafe: Мы выделяем []byte, который используем для формирования содержимого новой строки. Затем нам остаётся лишь финализировать строку приведением её к ожидаемому рантаймом представлению. За это отвечает функция goString: К сожалению, предыдущая версия функции не имеет оптимизации для конкатенации с пустой строкой, а ещё мы выполняем некоторое количество лишних вычислений из-за невозможности выделить память напрямую, приходится работать со слайсом байт. Одной из интересных особенностей Go ассемблера является то, что он позволяет вызывать, например, неэкспортируемые функции рантайма. Мы можем вызвать runtime·mallocgc из ассемблерного кода даже если он не является частью пакета runtime. Этим свойством мы и воспользуемся. Также мы можем проверять принадлежность строк стековой памяти, что делает безопасной оптимизацию возврата одного из аргументов в качестве результата. Допустим, функция вызвана с аргументами concat2("", "123"). x — пустая строка, и если y не выделен на стеке, мы можем вернуть его в качестве результата конкатенации. MOVQ (TLS), AX переместит *g в регистр AX. Чтение по нулевому смещению даст поле g.stack.lo, а с 8-го байта начинается g.stack.hi (для 64-битной платформы). Тело concatenate выделяет память, заполняет его обеими строками, и возвращает новую строку. Если вам интересна природа NO_LOCAL_POINTERS в этом коде, можете почитать Calling a Go function from asm ("fatal error: missing stackmap"). Весь код предоставлен в качестве пакета concat. Готов ли мир к такой быстрой конкатенации? Who knows. В начале статьи был упомянут CL123256. У него есть несколько путей развития: Текущий предложенный вариант ускоряет только наиболее частый и простой случай конкатенации пары строк (арность=2). Если в Go не примут это изменение, то сравнимого ускорения можно будет добиться с помощью реализации операций над строками в виде сторонней библиотеки. Менее удобно, красиво и элегантно, но зато работает. Making developer tools & video games