Назад к блогу

Размерно-специализированное выделение памяти в Go: как устроено и что нового

Размерно-специализированное выделение памяти в Go: как устроено и что нового

В рантайме Go появился механизм размерно-специализированного выделения памяти: для каждого span class генерируется отдельная функция mallocgc, что позволяет ускорить аллокацию небольших объектов. Статья разбирает, как устроен этот механизм, почему разработчики ограничились объектами до 80 байт и как выбирается нужный путь выделения в зависимости от размера и наличия указателей.

Размерно-специализированное выделение (size-specialized allocation) — это набор специализированных вариантов mallocgc, сгенерированных по одному на каждый span class, для выделения объектов размером 80 байт и меньше. Вопрос нетривиален вот почему: обычный путь mallocgc принимает размер и тип, сам вычисляет span class и только потом попадает в общую логику. Специализированные функции знают span class и размер заранее, поэтому могут позволить себе предположения, ускоряющие работу, — но платят за это размером кода и давлением на кэш инструкций. Ниже — как именно устроен этот механизм и где проходят его границы.

Что такое span class и почему от него зависит выделение

размерный класс задаёт, сколько памяти нужно объекту. Наличие указателей в объекте определяет, должен ли сборщик мусора сканировать этот объект. Вместе эти два признака задают span class — именно от него зависит, из какого span берётся память. Поэтому для каждого span class и генерируется отдельная специализированная функция mallocgc.

Имена специализированных функций собираются из нескольких частей. Small означает, что объект не tiny-объект и не большой. NoScan означает, что объект не содержит указателей и его не нужно сканировать сборщиком мусора; Scan — что указатели есть и объект сканируется. NoHeader означает, что у объекта нет заголовка и биты указателей помещаются прямо в span. SC с числом — это номер размерного класса. Вариантов так много потому, что каждая комбинация этих признаков — отдельный span class, и для каждого генерируется своя функция.

Например, mallocgcSmallNoScanSC3 выделяет объекты без указателей в размерном классе 3 (17–24 байта). А mallocgcSmallScanNoHeaderSC1 — функция для объектов с указателями размерного класса 1.

Если компилятор знает span class на этапе компиляции, он может напрямую вставить вызов специализированной функции вместо newobject. Если же размер динамический и компилятор его не знает, вызов mallocgc сохраняется, и уже mallocgc сам определяет, доступна ли специализированная функция, какая именно, и вызывает её.

Зачем понадобились специализированные функции

Динамический вызов специализированной функции сам по себе стоит накладных расходов. Производительность такой функции должна быть достаточно высокой, чтобы даже с этими расходами она оставалась быстрее общего пути.

Простое увеличение числа специализированных функций проблему не решает. Каждая добавленная функция увеличивает размер исполняемых файлов и, что важнее, занимает место в кэше инструкций. Если специализированных функций слишком много и они не помещаются в кэш, накладные расходы на загрузку специализированного кода в кэш могут свести на нет всю выгоду. Хуже того, чем больше специализированного кода аллокации сидит в кэше инструкций, тем сильнее он вытесняет пользовательский код, замедляя его выборку и выполнение.

Экспериментально было определено, что остановиться на 80 байтах — оптимальное решение: прогон бенчмарков с остановкой на разных размерных классах показал, что именно 80 байт — это та точка, где выигрыш ещё есть, а давление на кэш уже приемлемо.

Как mallocgc выбирает путь выделения

mallocgc сначала отсекает нулевой размер, возвращая указатель на zerobase. Дальше при включённом sizeSpecializedMallocEnabled и размере меньше длины таблицы mallocNoScanTable выбор идёт по таблицам: для объектов без указателей при размере не меньше maxTinySize вызывается mallocNoScanTable[size], иначе — mallocgcTinySC2; для объектов с указателями требуется needzero и вызывается mallocScanTable[size].

В основной ветке при включённом sizeSpecializedMallocEnabled порог малого размера — size <= maxSmallSize-gc.MallocHeaderSize. Ниже него без указателей идёт mallocgcSmallNoscan, с указателями — mallocgcSmallScanNoHeader при heapBitsInSpan(size), иначе mallocgcSmallScanHeader. Выше порога вызывается mallocgcLarge.

Без sizeSpecializedMallocEnabled тот же порог maxSmallSize-gc.MallocHeaderSize отделяет малые от больших, но для объектов без указателей дополнительно проверяется size < maxTinySize && gp.secret == 0, и тогда вызывается mallocgcTiny, иначе mallocgcSmallNoscan.

Tiny-аллокатор: объединение мелких объектов

mallocgcTiny — специализированная функция для «крошечных» объектов без указателей. Она объединяет несколько таких запросов в один 16-байтовый блок (maxTinySize), чтобы уменьшить количество выделений и размер кучи. Худший случай потери памяти при этом — 2x, когда все подобъекты, кроме одного, недостижимы.

При выделении сначала проверяется, помещается ли объект в уже существующий tiny-блок: если off+size <= maxTinySize и c.tiny != 0, возвращается указатель на место в блоке и увеличивается c.tinyoffset. Если не помещается, выделяется новый 16-байтовый блок из span класса tinySpanClass, обнуляется и может заменить текущий, если в нём больше свободного места.

Наличие указателей учитывается тем, что tiny-выделение разрешено только для типов без указателей: при doubleCheckMalloc выбрасывается исключение, если typ.Pointers() истинно. Это гарантирует, что все подобъекты в tiny-блоке являются noscan, что и ограничивает потенциально теряемую память.

Что захардкожено в сгенерированных функциях

Функции вида mallocgcSmallScanNoHeaderSC1..SC7 и mallocgcSmallNoScanSC2..SC7 — это сгенерированные варианты mallocgc для конкретного span class. В каждой захардкожены номер размерного класса и размер элемента: в mallocgcSmallScanNoHeaderSC1 стоит const sizeclass = 1 и const elemsize = 8, в SC2 — sizeclass = 2 и elemsize = 16, в mallocgcSmallNoScanSC3 — sizeclass = 3 и elemsize = 24, в SC4 — sizeclass = 4 и elemsize = 32.

Наличие указателей закодировано в span class: у Scan-вариантов const spc = spanClass(sizeclass<<1) | spanClass(0), у NoScan-вариантов const spc = spanClass(sizeclass<<1) | spanClass(1), что соответствует определению sizeClass<<1 | noPointers. Отсутствие заголовка отражено в имени NoHeader и в том, что эти функции не вызывают mallocgcSmallScanNoHeader, а сразу работают с heap bits.

Биты указателей инициализируются через getGCMask(typ) и запись в heap bits: берётся src0 := readUintptr(getGCMask(typ)), вычисляется адрес через spanHeapBitsRange, после чего биты устанавливаются с маскированием и сдвигом. Для NoScan-вариантов биты указателей не устанавливаются, вместо этого при needzero && span.needzero != 0 память обнуляется через memclrNoHeapPointers(x, elemsize).

Быстрый путь выделения из текущего span

nextFreeFast — это быстрый путь выделения объекта из текущего span. Он проверяет, есть ли свободный объект в кэше битовой карты allocCache, и если да, возвращает адрес объекта, иначе возвращает 0. Позиция свободного объекта вычисляется как freeindex + число младших нулевых битов allocCache; результат проверяется на то, что он меньше nelems, и что следующий индекс не выходит за границу слова. Затем allocCache сдвигается, обновляются freeindex и allocCount, и возвращается адрес.

Если свободного объекта быстро нет, возвращается 0, и вызывающий код идёт более медленным путём. nextFree в mcache берёт текущий span из c.alloc[spc] и вызывает s.nextFreeIndex(). Если индекс равен nelems, span полон, и вызывается c.refill(spc), после чего берётся новый span из c.alloc[spc] и снова вызывается nextFreeIndex. refill возвращает текущий span в центральные списки, получает новый из mheap_.central[spc].mcentral.cacheSpan(), помечает его как кэшированный и сохраняет в c.alloc[spc].

Переиспользование объектов: reusable noscan

freegc помечает объект кучи как пригодный для немедленного повторного использования в последующем mallocgc, не дожидаясь продвижения цикла GC. Запись делается в free list в mcache текущего P, а вызывающий обязан передать пользовательский размер и признак наличия указателей.

Пометка происходит только при выполнении ряда условий. Механизм должен быть включён, а размер объекта — попадать в поддерживаемый диапазон: от maxTinySize до maxSmallSize-mallocHeaderSize включительно. Если включён sizeSpecializedMallocEnabled и объект содержит указатели, пометка не выполняется. Кроме того, указатель не должен быть nil, не должен указывать в стек текущей горутины, и в этот момент не должно идти другое выделение на том же M. При соблюдении всех условий вычисляется размерный класс и span class через makeSpanClass(sizeclass, noscan), и для noscan-объекта указатель кладётся в c.reusableNoscan[spc]. Для объектов с указателями механизм пока не реализован.

При последующем выделении mallocgc для noscan-объекта сначала проверяет, есть ли в per-P кэше готовый к переиспользованию объект для нужного span class, и если есть — забирает его оттуда, минуя обычный путь поиска свободного слота в span.

nextReusableNoScan снимает указатель из c.reusableNoscan[spc], продвигая список через v.ptr().next, и при включённом writeBarrier.enabled возвращает также mspan (из mcache, если указатель в его границах, иначе через spanOf). doubleCheckNextReusable — это debug-проверка инвариантов: что span найден, находится в состоянии mSpanInUse, указатель в границах span, является базовым адресом объекта и объект не свободен.

Как mcache получает span из mcentral

Когда mcache нужно получить новый span для класса размеров, он обращается к центральному списку mcentral. Если подходящего span там не оказывается, это считается нехваткой памяти. Внутри mcentral сначала списывается кредит на очистку и берётся блокировка sweep, после чего в первую очередь берётся span из списка частично очищенных (partialSwept) для текущего поколения sweep.

Если такого нет, в цикле до 100 попыток берутся частично неочищенные (partialUnswept) span'ы: при успешном захвате владения span очищается и сразу используется. Затем аналогично перебираются полностью неочищенные (fullUnswept) span'ы: после очистки проверяется наличие свободного места, и если места нет, span кладётся в список полностью очищенных (fullSwept). Если ни один список не дал span, из кучи выделяется новый span.

При возврате span из mcache обратно в mcentral сначала проверяется, что span не пуст. Затем определяется, является ли span устаревшим (закэшированным до начала текущей фазы очистки) по условию stale := s.sweepgen == sg+1. Устаревший span очищается, а неустаревший кладётся в partialSwept, если в нём есть свободные слоты, иначе в fullSwept.

Битовые карты в mspan и эпохи разметки

allocBits и gcmarkBits — это указатели на битовые карты в структуре mspan: allocBits отмечает, какие объекты в спане выделены, а gcmarkBits используется сборщиком мусора для отметки достижимых объектов. Обе карты хранятся в наборе арен, названных относительно текущего цикла GC: free (грязные арены, которые больше не используются и могут быть переиспользованы), next (данные для следующего цикла GC), current (данные, используемые в текущем цикле GC) и previous (данные, использованные в прошлом цикле GC). эпоха разметки нужна, чтобы сборщик мусора мог отмечать объекты в новом цикле, не затирая биты, ещё нужные для предыдущего: каждая карта живёт в своей арене, и переход к следующему циклу сводится к сдвигу указателей между аренами, а не к копированию или очистке всех битов.

Новый цикл GC начинается с вызова finishsweep_m, который перемещает previous в free, current в previous, а next в current. nextMarkBitArenaEpoch выполняет именно это перемещение арен: previous освобождается в free, current становится previous, next становится current, а next обнуляется, чтобы newMarkBits мог при необходимости создать новую арену.

newMarkBits выделяет 8-байтово выровненную память под биты отметки из арены gcBitsArenas.next, а newAllocBits просто вызывает newMarkBits, поскольку для только что инициализированных спанов биты выделения берутся из той же арены next. Для уже существующих спанов при свипе gcmarkBits переиспользуются как allocBits.

Special-записи: финализаторы, cleanup, weak pointers, tiny-блоки

Special-запись — это структура special, которая хранит next (следующая запись в связном списке спана), offset (смещение объекта внутри спана) и kind (вид special). Виды заданы константами: _KindSpecialTinyBlock (крошечный блок, только при debug.checkfinalizers != 0), _KindSpecialFinalizer (финализатор), _KindSpecialWeakHandle (слабый указатель), _KindSpecialProfile (профилирование памяти), _KindSpecialReachable (отслеживание достижимости в тестах), _KindSpecialPinCounter (объекты, закреплённые несколько раз), _KindSpecialCleanup (cleanup-функция), _KindSpecialCheckFinalizer (дополнительный контекст для финализатора или cleanup, только при debug.checkfinalizers != 0), _KindSpecialBubble (связь объекта с synctest-пузырями) и _KindSpecialSecret (secret).

Связь с объектом устанавливается через указатель: addspecial вычисляет offset := uintptr(p) - span.base() и вставляет запись в отсортированный по offset список спана. removespecial по указателю и виду находит и удаляет запись, возвращая её вызывающему. freeSpecial по виду special выполняет соответствующее действие: для финализатора ставит его в очередь через queuefinalizer, для weak handle обнуляет handle, для cleanup ставит cleanup в gcCleanups, для secret обнуляет память через memclrNoHeapPointers, а затем освобождает саму запись через соответствующий аллокатор.

setTinyBlockContext помечает выделение как tiny-блок для диагностики, такой как checkfinalizer. Оно берёт specialTinyBlock из mheap_.specialTinyBlockAlloc, ставит s.special.kind = _KindSpecialTinyBlock и вызывает addspecial(ptr, &s.special, false), после чего делает KeepAlive(ptr). inTinyBlock проверяет, находится ли указатель в tiny-блоке, выравнивая его вниз до maxTinySize, получая span через spanOfHeap и ища special по смещению.

Tiny-блоки требуют отдельного вида special, потому что tiny-аллокатор объединяет несколько мелких noscan-запросов в один блок памяти, и этот блок освобождается только когда все подобъекты недостижимы. Отдельный _KindSpecialTinyBlock нужен, чтобы отличать такие объединённые выделения от других special-записей; он упорядочен перед финализатором и cleanup, чтобы всегда идти первым в списке specials. При этом пометка делается только если блок реально содержит более одного различного значения — это используется для отладки.

Weak pointers

internal_weak_runtime_registerWeakPointer принимает сильный указатель и возвращает слабый хэндл, просто вызывая getOrAddWeakHandle, который ищет существующий канонический хэндл для данного адреса или создаёт новый. getOrAddWeakHandle сначала пробует getWeakHandle(p) без аллокации; если хэндла нет, он берёт specialWeakHandle из mheap_.specialWeakHandleAlloc, заполняет его вид и handle, кладёт через addspecial(p, &s.special, false) и при гонке освобождает special и повторно вызывает getWeakHandle.

gcParkStrongFromWeak ставит текущую горутину в очередь work.strongFromWeak.q и паркует её через goparkunlock, пока work.strongFromWeak.block истинно, чтобы дождаться окончания mark termination. gcWakeAllStrongFromWeak под блокировкой забирает весь список work.strongFromWeak.q.popList() и через injectglist разбуждает все ожидающие горутины; вызывается в конце цикла GC, при этом work.strongFromWeak.block должен быть false.

Хэндл дополняется через weakHandleBox с полем _ [maxTinySize - unsafe.Sizeof(atomic.Uintptr{})]byte, чтобы он не делил tiny-блок с другими аллокациями — иначе это могло бы приводить к утечкам.

Компромиссы дизайна

В дизайне специализированных функций заложен компромисс между производительностью и размером кода: каждая добавленная функция увеличивает размер исполняемых файлов и занимает место в кэше инструкций, а если специализированный код не помещается в кэш, выгода может быть сведена на нет. Поэтому было решено остановиться на 80 байтах.

Другой компромисс — дублирование логики: если бы функции писались вручную, код мог бы расходиться и терять синхронизацию. Поэтому общие части были вынесены, а для их вставки написан инлайнер на основе go/ast и astutil. В сгенерированном коде это отражено комментарием в начале файла: // Code generated by mkmalloc.go; DO NOT EDIT. и ссылкой на описание в заглушках: // See overview in malloc_stubs.go. Генератор позволяет создавать копии тел функций без риска их расхождения.

Что происходит на slow path

Когда размер объекта не покрыт специализированной функцией, mallocgc переходит к общему пути: если sizeSpecializedMallocEnabled истинно и размер не больше maxSmallSize-gc.MallocHeaderSize, то для объектов без указателей вызывается mallocgcSmallNoscan, а для объектов с указателями — mallocgcSmallScanNoHeader или mallocgcSmallScanHeader в зависимости от heapBitsInSpan(size); иначе вызывается mallocgcLarge.

Специализированные функции рассчитаны на простые случаи и при обнаружении сложного случая, например активного GC, откатываются к более общему пути. В mallocgcTinySC2 вычисляется forceSlowPath := debug.malloc || gcBlackenEnabled != 0 || (goexperiment.RuntimeSecret && getg().secret > 0), и при истине управление передаётся в mallocgcTinySlowPath.

В slow path сначала при doubleCheckMalloc проверяется, что gcphase не равен _GCmarktermination, затем при gcBlackenEnabled != 0 уменьшается gcAssistBytes и при отрицательном значении вызывается gcAssistAlloc, после чего захватывается M через acquirem и устанавливается mp.mallocing = 1. Далее из mcache берётся span для нужного span class, ищется свободный слот через allocCache и freeindex, при неудаче вызывается c.nextFree(spc); для scan-объектов дополнительно записываются heap bits и увеличивается c.scanAlloc, для no-scan при runtimeFreegcEnabled и c.hasReusableNoscan(spc) используется mallocgcSmallNoscanReuse.

В конце выполняется publicationBarrier, при writeBarrier.enabled вызывается gcmarknewobject, иначе span.freeIndexForScan = span.freeindex, корректируется c.nextSample и при необходимости вызывается profilealloc, сбрасывается mp.mallocing и вызывается releasem, а при checkGCTrigger проверяется gcTrigger{kind: gcTriggerHeap} и при срабатывании вызывается gcStart.

Preemption и GC во время выделения

Во время выделения памяти mp.mallocing выставляется в 1, чтобы текущий M (поток ОС) не был вытеснен сборщиком мусора: в mallocgcSmallScanHeader и mallocgcLarge это прямо описано как «Set mp.mallocing to keep from being preempted by GC», а в freegc — «Otherwise, the GC could flush our mcache or otherwise cause problems». Перед этим вызывается acquirem(), а после завершения выделения mp.mallocing сбрасывается в 0 и вызывается releasem(mp).

addAssistCredit — функция, которая, в отличие от deductAssistCredit, добавляет кредит, а не снимает, и никогда не вызывает gcAssistAlloc: она берёт текущую G (или m.curg, если он есть) и увеличивает её gcAssistBytes на размер. Это связано с assist-кредитами: в mallocgc при включённом gcBlackenEnabled вызывается deductAssistCredit(size), а после выделения долг корректируется на внутреннюю фрагментацию через assistG.gcAssistBytes -= int64(elemsize - size).

Как сравнивали и что получилось

Прогон бенчмарков с остановкой на разных размерных классах показал, что остановка на 80 байтах — оптимальная точка. Наибольшую выгоду получают распространённые размеры 16 и 24 байта, потому что это два или три 64-битных значения — например, интерфейсы, строки и срезы.

Основное улучшение связано с очисткой памяти, и именно ускоренная очистка даёт наибольший выигрыш. Специализированные функции могут не вычислять span class при получении span, а благодаря константному размеру компилятор оптимизирует bookkeeping.

Для маленьких очисток компилятор может заменить вызов memclrNoHeapPointers на прямые инструкции, а с ростом размера накладные расходы на вызов функции становятся незначительными. Дополнительные приёмы включают отсутствие вычисления span-класса, оптимизацию учёта и ручной инлайн вспомогательных функций, поскольку компилятор Go избегает инлайна слишком больших функций. Проводилась настройка, чтобы влияние эффектов кэша инструкций было минимальным.

Что из этого следует на практике

Программисту достаточно собрать программу с Go 1.27, чтобы получить ускорение от размерно-специализированного выделения. Механизм можно отключить при сборке через GOEXPERIMENT=nosizespecializedmalloc.

Всё остальное — внутреннее устройство рантайма: выбор специализированной функции по span class, вычисление размерного класса, работа со span и free list, очистка памяти — программисту не нужно обдумывать при написании кода. Скрыты и детали генерации специализированных вариантов mallocgc, и внутренние поля span (allocCache, freeindex, nelems, allocCount), и вызовы вроде acquirem, getMCache, c.nextFree, memclrNoHeapPointers. Не видно программисту и то, что компилятор может напрямую вставлять вызов специализированной функции вместо newobject, когда span class известен на этапе компиляции, а иначе mallocgc сам определяет и вызывает нужную специализацию.

Практический вывод из механики: наибольший эффект от нововведения получают программы, которые много выделяют объекты размером 16 и 24 байта без указателей, — а это интерфейсы, строки и срезы. Для крупных объектов выигрыш меньше, потому что накладные расходы на вызов функции там незначительны, а для объектов с указателями reusable-путь пока не реализован.

Где смотреть в коде

Источники

Похожее