DarkRiDDeR12 мин

DConf 2017. Сборщик мусора D: наблюдаем паузу, а не обвиняем GC вслепую

DLangGC

Программа на D начинает отвечать рывками, и вся причина объявляется «медленным сборщиком». Цена ошибки — отключить полезную автоматическую память и получить утечки или ещё более дорогие ручные освобождения.

В исходной заметке сохраняю материалы DConf и объяснение устройства GC. Сокращаю повторения и добавляю практическую рамку: сначала измеряем аллокации и размер heap, затем смотрим профиль паузы, и только после этого выбираем настройку.

DConf 2017. Сборщик мусора D: наблюдаем паузу, а не обвиняем GC вслепую: схема границ проверки
Иллюстрация показывает границу между симптомом, техническим механизмом и проверяемым действием.

Что сохраняем из исходной заметки

DConf-2017. Дмитрий Ольшанский. 2017 июня 14 дня.

Оригинал (англ.): http://olshansky.me/gc/runtime/dlang/2017/06/14/inside-d-gc.html
Перевод: Глеб Куликов
Оригинал перевода: https://yadi.sk/i/dftROrt33KLww6
Небольшие правочки: DarkRiDDeR

Во время проходившего на конференции DConf-2017 хакатона, я самоуверенно возглавил группу из двух человек, хакающих Ди’шный сборщик мусора (GC). После нескольких часов я уже не мог избавиться от навязчивой мысли «эгей, парень, это надо бы переписать!». Так что я решил отправиться в квест в поисках лучшего мусорщика для Ди, где первым шагом стал бы более быстрый, клаcсический сборщик, отмечающий достижимые объекты (mark-sweep).

Для пояснения моих мотивов, я намерен описать внутренности современного сборщика, отмечая промахи архитектуры. В конце концов, надо же понять, «куды тыкать»!

Пулы, везде пулы!

Игнорируя излишние детали, мусорщик — это просто массив объектов пула. Каждый пул — это кусочек отображённой (mmap) памяти + немного метаданных в куче (таблиц маркирующих бит, освобождающих бит ну и так далее). Выделение памяти происходит внутри пула. Если одиночный пул не в состоянии обслужить выделение памяти, заводится новый пул. Размер пула определяется арифметической прогрессией от числа пулов (или 150% от размера выделения, смотря, что окажется больше).

Важно, что пулы бывают двух типов, для больших и маленьких объектов. В «маленьких» пулах выделяются объекты размером до 2 Кб, всё остальное обслуживается «большими» пулами. На самом деле, маленькие пулы более интересны, так что давайте на них первым делом и взглянем.

Прежде всего, размер памяти для любого маленького выделения округляется до подходящего (по степеням двойки) класса размера — 16, 32, 64, 128, 256, 512, 1024, 2048. Затем для данного размера проверяется глобальный список свободных блоков и, если такового найти не получается, продолжается поиск маленького пула.

Маленький пул выделит новую страницу памяти и внесёт её в список свободных блоков данного размера. Вот и «вылезла» первая большая ошибка существующей архитектуры: класс размера назначается на страничной основе, поэтому нам требуется таблица (чтобы жизнь мёдом не казалась, называемая таблицей страниц), ставящая в соответствие каждому классу размера соответствующую страницу. Теперь, чтобы найти начало объекта по внутреннему указателю, мы сперва ищем страницу, к которой он принадлежит, затем ищем класс размера и, наконец, накладываем битовую маску. Более того, метаданные представляют собой кучу простых битовых таблиц, которые теперь должны покрывать страницы разного размера, поэтому на каждые 16 байт требуется примерно 7 бит независимо от размера объекта.

Чем была обусловлена эта архитектура? У меня на этот счёт есть две гипотезы. Первая связана с нежеланием резервировать память для недоиспользуемых пулов (что, на самом деле, не является проблемой, так как для виртуальной памяти работает ленивая фиксация). Вторая — с опасениями получить слишком много пулов, замедлив выделение и полезную маркировку. Последнее больше похоже на причину, так как во время фазы маркировки, мусорщик действительно довольно часто производит линейное сканирование по пулам и бинарный поиск для каждого предполагаемого указателя (!).

Вот и вторая ошибка: поиск пула за log(P), где P — число пулов, и соответственно, отметка за N*log(P). Хэш–таблица могла бы малость сэкономить циклы.

Завершая наш обзор маленького пула, мы также должны взглянуть на выбор классов размеров. Это третья погрешность (не ошибка, скорее, спорный выбор): размеры, следующие степеням двойки, гарантируют нам внутреннюю фрагментацию, доходящую до 50 %. Современные выделители, подобные jemalloc’у, обычно предлагают ещё один класс, размер которого лежит между степеням двойки. Деление по модулю на константу, не являющуюся степенью двойки, несколько медленнее одиночного битового, но и вполне приемлемо.

Давайте взглянем на пулы больших объектов. Первое, что нужно заметить, так это гранулярность страницы памяти (4 КБ) как для метаданных, так и собственно выделений памяти. Наборы свободных страниц внесены в один список, который линейно сканируется при каждом запросе на выделение памяти. Это четвёртая ошибка, которая, впрочем, не оказывает влияния на производительность выделения больших объектов. Для поиска начала объекта организована отдельная таблица, в которой для каждой страницы хранится индекс начала объекта, к которой он принадлежит.

Схема разумна до тех пор, пока не коснётся больших (более 100 МБ) выделений памяти. В этом случае, скорее всего, не удастся перераспределить память «по месту», в результате чего будет выделен новый пул и огромный объём памяти метаданных будет истрачен на всего один объект.

Процесс сбора

До сих пор мы наблюдали конвейер выделения памяти, освобождение проходит примерно также. Более интересен автоматический возврат памяти, который и является смыслом сборщика мусора. Прежде всего позвольте мне отметить очевидное: во первых, сборщик мусора в Ди является консервативным, то есть, он не знает, является ли что-то указателем или нет. Во-вторых, он поддерживает финализаторы — действия, которые выполняются на объекте, прежде чем возвратить его память в общий пул. Эти два решения сильно ограничивают архитектуру сборщика.

С точки зрения высокого уровня, процесс сборки, как ни странно, представляет собой целостный 4-фазный процесс: подготовка — полная маркировка – подчистка — возврат памяти.

Стадия подготовки даёт наибольшие основания для сомнений. В сущности, для предотвращения сканирования свободной памяти, на этой стадии следовало бы скопировать биты–признаки свободных блоков в биты–признаки отметок. Однако всю малину портит то, что требуется вычислить полное свободное место, для чего перебрать списки свободных блоков. Это уже пятая(хм?) ошибка, потому что резкое распутывание бессчётного количества указателей — явно последнее, что нужно сделать во время «приостановки мира». Лучшей архитектурой было бы переворачивание битов–признаков свободных блоков во время выделения / освобождения памяти, тем более, что список свободных блоков поддерживает указатели на пул для каждого объекта, так что искать нужный пул не потребуется.

Фаза маркировки сводится, на самом деле, к вызову MarkAll, который просто передаёт подходящие участки памяти в маркировочную функцию, заслуживающую более пристального рассмотрения.

Механизм без лишних обещаний

GC в D освобождает недостижимые объекты, но стоимость возникает не только в момент полной коллекции. Лишние временные объекты увеличивают объём работы, а долгоживущие ссылки удерживают память и меняют частоту циклов.

Пауза зависит от реализации runtime, размера набора объектов и режима приложения. Один замер в debug-сборке не говорит, что такой же результат будет у release-бинарника с другой нагрузкой.

Ручной `GC.collect` может быть полезен на границе batch-операции, но вызов в каждом цикле обычно только переносит работу в горячее место. Сначала нужно увидеть, где создаётся давление.

Минимальный воспроизводимый пример

Ниже — маленькая проверка, которую можно запустить или адаптировать в отдельном тестовом окружении. Значения демонстрационные; проектные идентификаторы, пути и версии нужно заменить своими и сохранить рядом с результатом.

import core.memory : GC;
import std.stdio : writeln;

void processBatch(const int[] values) {
    auto before = GC.stats();
    foreach (value; values) {
        // Не создаём временную строку на каждой итерации без причины.
        auto squared = value * value;
        writeln(squared);
    }
    auto after = GC.stats();
    writeln("allocated: ", after.usedSize - before.usedSize);
}

// GC.collect() — отдельный эксперимент на границе batch,
// а не универсальный вызов в каждой функции.

Матрица диагностики

Сигнал: рабочая матрица проверки
СигналЧто измеритьНе делать первым
Редкие длинные паузыДлительность и частоту GCСразу отключать GC
Рост heapКто удерживает долгоживущие ссылкиУвеличивать лимит без профиля
Много мелких объектовАллокации в горячем циклеОптимизировать только сборщик
Batch-задачаПауза на границе операцииВызывать collect на каждой итерации

Порядок действий

  1. Собрать release-профиль на повторяемом наборе данных.
  2. Разделить время приложения и время GC, а также размер занятой памяти.
  3. Найти горячие места, где создаются временные объекты.
  4. Повторить замер после уменьшения аллокаций, не меняя сразу режим GC.
  5. Проверить `GC.collect` только на явной границе batch и сравнить паузу.
  6. Оставить настройку рядом с числом измерения и условием, при котором она нужна.

Ограничения и безопасный следующий шаг

Детали реализации и профили GC зависят от версии runtime D.

Слайды конференции объясняют модель, но не заменяют профиль вашей программы.

В примере нет реального production-нагрузочного замера.

После проверки должен остаться конкретный артефакт: вывод команды, тест, diff конфигурации или запись результата. Если его нет, формулировку нужно вернуть к симптому и не выдавать гипотезу за исправление.

Что записать в ревью

Короткая запись должна отвечать на четыре вопроса: какой вход использовали, какой результат увидели, какая граница была проверена и какое действие разрешено дальше. Такая форма полезнее длинного вывода «всё работает»: другой инженер сможет повторить проверку и понять, где заканчивается пример.

Если результат зависит от версии Windows, PHP, Bitrix, D или браузера, версию фиксируем рядом с командой. Если проверка не охватывает сеть, production или реальные пользовательские данные, это ограничение пишем прямо. Тогда следующий шаг расширяет evidence, а не расширяет обещание.

Проверяемые источники

  • D language: garbage collection — описывает модель автоматической памяти D
  • D: core.memory — показывает API runtime для наблюдения и управления GC
  • DConf — сохраняет исторический контекст конференции 2017 года