В Go 1.27 включено ускоренное выделение памяти для объектов размером 80 байт и меньше. Выделение памяти может происходить на 20–30% быстрее, что ускоряет программы с интенсивным выделением памяти на величину до 1%. Среда выполнения Go повышает производительность таких операций за счет добавления специализированных функций, используемых для выделения памяти определенных размеров. Эти специализированные функции могут делать определенные допущения, что делает их быстрее и проще для оптимизации. В этой статье блога мы объясним, как это работает и как это ускоряет ваши программы.
Выделение памяти в куче осуществляется функцией mallocgc среды выполнения, которой требуется размер выделяемого блока и информация о том, содержит ли он указатели. Когда компилятор определяет, что объект выходит за пределы стека (escapes to the heap) или требует динамического выделения, он вставляет вызов newobject — простой функции-обертки, которая извлекает размер объекта и наличие в нем указателей, а затем передает эти данные в mallocgc.
Эти два параметра определяют большую часть работы, которую должен выполнить аллокатор. Размер важен, потому что аллокатор определяет диапазоны размеров, называемые «классами размеров» (size classes). Для большинства выделений, которые не являются слишком большими или слишком маленькими, он возвращает блок памяти из списка свободных объектов, размер которых соответствует максимальному размеру данного класса. Например, класс размера 3 охватывает 17–24 байта. Поэтому, независимо от того, выделяете ли вы 17 или 24 байта, аллокатор предоставит вам следующий свободный 24-байтовый объект из своего списка. Ниже приведена таблица диапазонов размеров для каждого класса до 80 байт. Существуют отдельные наборы списков свободных объектов, которые мы называем «спанами» (spans), в зависимости от того, содержит ли выделяемый объект указатели, что необходимо для учета при работе сборщика мусора.
Поскольку класс размера и наличие указателей определяют, из какого спана мы выделяем память, у аллокатора есть тип, называемый «классом спана» (span class), значение которого кодирует оба этих параметра: он определяется как sizeClass<<1 | noPointers. При реализации malloc, специализированного по размеру, стало ясно, что логичнее всего выполнять специализацию именно по этим классам спанов, так как большая часть поведения аллокатора определяется именно ими.
Мы генерируем специализированный вариант mallocgc для каждого класса спана. Например, функция, которая выделяет объекты без указателей в классе размера 3, называется mallocgcSmallNoScanSC3. «Small» означает, что объект не является крошечным или огромным, «NoScan» — отсутствие указателей, а «SC3» — класс размера 3 (17–24 байта). Специализированные функции спроектированы так, чтобы быть максимально простыми, и не могут обрабатывать все граничные случаи при выделении. Когда они обнаруживают такой случай, например, если активен сборщик мусора (GC), они переключаются на более общую процедуру выделения.
В итоге мы получаем новую специализированную функцию mallocgc для крошечных объектов (всегда без указателей) и по одной для каждого некрошечного класса спана: спаны без указателей для класса размера 2 и выше, и спаны с указателями для класса размера 1 и выше. Эти специализированные варианты mallocgc сами по себе быстрее, чем вызов mallocgc, но преимущества специализации уменьшаются по мере увеличения размеров: выделение памяти часто ограничивается необходимостью очистки памяти, и в какой-то момент остальная работа по выделению становится незначительной. И недостаточно просто быть быстрее, чем mallocgc! Благодаря специализированному по размеру выделению, когда компилятор знает, для какого класса спана нужно выделить память, он может напрямую вставить вызов специализированной функции вместо вызова newobject. Однако компилятор часто не знает, какой класс спана будет выделен во время генерации кода: вспомните срезы динамической длины. Поскольку компилятор не может определить размер выделения, он сохраняет вызов mallocgc, а сама mallocgc должна определить, доступна ли специализированная функция, какая именно, и затем вызвать ее. Поэтому производительность специализированной функции должна быть достаточно высокой, чтобы даже с учетом накладных расходов на динамический вызов она оставалась быстрее.
Проблема накладных расходов — не единственная. Если бы это было так, мы могли бы создавать более крупные специализированные функции и резервировать их для вставки компилятором, когда размер известен во время компиляции. И если бы мы не вызывали их динамически, нам не пришлось бы платить за динамические накладные расходы. Но каждая добавленная специализированная функция увеличивает размер исполняемых файлов, создаваемых компилятором, и, что более важно, занимает драгоценное место в кэше инструкций. Единственная функция mallocgc часто находится в кэше инструкций из-за частоты выделения памяти. Если у нас слишком много специализированных функций и они недоступны в кэше, накладные расходы на загрузку специализированного кода в кэш могут свести на нет все преимущества. И чем больше специализированного кода выделения находится в кэше инструкций, тем больше он вытесняет пользовательский код, замедляя его выборку и выполнение. Проведя ряд тестов на разных классах размеров, мы определили, что 80 байт — это оптимальная точка.
Конечно, добавление большего количества специализированных функций означает больше кода для поддержки. Если бы каждая функция писалась вручную, код в специализированных функциях легко мог бы разойтись и рассинхронизироваться. Чтобы помочь с этим, мы выделили общие части специализированных функций и написали инлайнер, используя стандартную библиотеку go/ast для парсинга и форматирования, а также пакет golang.org/x/tools/go/ast/astutil для манипуляции с AST. Общие части функций написаны на стандартном коде Go, который собирается и проверяется на типы вместе с остальной частью среды выполнения, что позволяет нашим инструментам обнаруживать проблемы, но по большей части они являются просто заглушками для инлайнера.
Нам удалось измерить улучшения в специализированных по размеру функциях выделения памяти, но почему они на самом деле быстрее? Наиболее очевидная оптимизация заключается в очистке памяти. Память, возвращаемая mallocgc, не всегда должна быть обнулена, но часто это необходимо, и это может занимать большую часть времени выделения. Функция очистки памяти memclrNoHeapPointers написана на высокооптимизированном ассемблере, но мы могли бы сделать лучше для небольших очисток. В специализированной функции, если размер очищаемой области является константой, компилятор может заменить вызовы memclrNoHeapPointers кодом, который напрямую генерирует инструкции для очистки памяти. Тогда выделение памяти может пропустить вызов функции и некоторые ветвления. Это имеет значение для очень маленьких выделений, но по мере их увеличения накладные расходы на вызов функции становятся незначительными.
Хотя более быстрая очистка памяти обеспечивает наибольший прирост производительности, в специализированных функциях возможны и другие приемы: поскольку они специализированы для каждого класса диапазона (span-class), функции не нужно вычислять класс диапазона при его извлечении. А поскольку размер выделения является константой, компилятор может выполнить некоторые оптимизации, чтобы ускорить учет, необходимый для выделения памяти. Один из таких случаев — маркировка расположения указателей в выделенной памяти. Специализированные функции также могут вручную встраивать (inline) некоторые свои вспомогательные функции. Компилятор Go умеет встраивать код, но избегает встраивания функций, которые считает слишком большими. Мы можем переопределить это в сгенерированном коде, вставив тела функций непосредственно в вызывающие стороны. С помощью генератора мы можем создавать копии каждого из тел функций, не беспокоясь о том, что эти копии будут различаться. Кроме того, мы могли бы перенести код, обрабатывающий менее распространенные случаи, такие как флаги отладки среды выполнения, в функции «медленного пути» (slow path), чтобы сделать специализированные функции компактнее.
Хотя мы надеемся, что это объяснение выделения памяти, специализированного по размеру, было интересным, вам как программисту на Go не нужно думать об этом при написании кода. Выделение памяти станет просто немного быстрее, причем наибольшую выгоду получат некоторые из наиболее распространенных размеров выделения, особенно 16 и 24 байта. Эти размеры являются одними из самых частых, поскольку они состоят из двух или трех 64-битных значений, поэтому они включают выделение памяти для таких объектов, как значения интерфейсов и строки, состоящие из двух значений, или срезы, состоящие из трех. Мы потратили много времени на настройку поведения malloc, специализированного по размеру, и на то, чтобы влияние эффектов кэша инструкций было минимальным: изначально мы планировали выпустить эту функцию в Go 1.26, но решили подождать еще один релиз, чтобы провести дополнительную настройку и максимально сократить объем дополнительного кода.
Все, что вам нужно сделать, чтобы получить улучшенную производительность от выделения памяти, специализированного по размеру, в ваших программах — это собрать их с помощью Go 1.27. Если вас интересуют более конкретные действия, которые можно предпринять для улучшения производительности выделения памяти и сборки мусора, пожалуйста, ознакомьтесь с Руководством по оптимизации сборщика мусора Go.
Хотя мы уверены, что выделение памяти, специализированное по размеру, не должно вызывать регрессий в вашем коде, при необходимости вы можете собрать свои программы, используя GOEXPERIMENT=nosizespecializedmalloc, чтобы отключить эту функцию. Если вам действительно пришлось это сделать для решения проблемы, связанной с выделением памяти, специализированным по размеру, пожалуйста, создайте тикет на go.dev/issue/new, чтобы мы могли провести расследование.
