Меня зовут Андрей Логвинов, я работаю в группе разработки рендеринг-движка Яндекс.Браузера в Нижнем Новгороде. Сегодня я расскажу читателям Хабра о том, как устроено управление памятью в проекте Chromium на примере одной загадочной проблемы, которая приводила к падению производительности в тесте Speedometer. Этот пост основан на моём докладе с мероприятия Яндекс.Изнутри.
Однажды на нашем дашборде производительности мы увидели ухудшение скорости работы теста Speedometer. Этот тест измеряет совокупную производительность браузера на приближенном к реальности приложении — списке дел, где тест добавляет пункты в список и затем вычёркивает их. На результаты теста влияет как производительность JS-движка V8, так и скорость отрисовки страниц в движке Blink. Тест Speedometer состоит из нескольких подтестов, где тестовое приложение написано с использованием одного из популярных JS-фреймворков, например jQuery или ReactJS. Общий результат теста определяется как среднее для результатов по всем фреймворкам, но тест позволяет посмотреть производительность по каждому фреймворку в отдельности. Стоит отметить, что тест не ставит своей целью оценить производительность фреймворков, они используются только для того, чтобы сделать тест менее синтетическим и более приближенным к реальным веб-приложениям. Детализация по подтестам показала, что ухудшение наблюдается только для версии тестового приложения, созданного с использованием jQuery. А это уже интересно, согласитесь.
Расследование таких ситуаций начинается достаточно стандартно — мы определяем, какой именно коммит в код привёл к проблеме. Для этого у нас хранятся сборки Яндекс.Браузера на каждый (!) коммит за последние несколько лет (собирать заново было бы непрактично, так как сборка занимает несколько часов). Пространства на серверах это занимает немало, но обычно помогает быстро найти источник проблемы. Но в этот раз быстро не получилось. Оказалось, что ухудшение результатов теста совпало с коммитом, интегрирующим очередную версию Хромиума. Результат не обнадеживающий, потому что новая версия Хромиума приносит огромное количество изменений разом.
Поскольку информации, указывающей на конкретное изменение, мы не получили, пришлось заняться исследованием проблемы по существу. Для этого мы при помощи Developer Tools сняли трейсы теста. Заметили странную особенность — «рваные» интервалы исполнения Javascript-функций теста.
Снимаем более технический трейс при помощи about:tracing и видим, что это сбор мусора (GC) в Blink.
На трейсе памяти ниже видно, что эти GC-паузы не только занимают много времени, но и никак не помогают остановить рост потребляемой памяти.
Но если вставить в тест явный вызов GC, то мы видим совсем другую картину — память держится в районе нуля и не утекает. Значит, утечек памяти у нас нет, а проблема связана с особенностями работы сборщика. Продолжаем копать. Запускаем отладчик и видим, что сборщик мусора обошел около 500 тысяч объектов! Такое количество объектов не могло не повлиять на производительность. Но откуда они взялись?
И здесь нам понадобится небольшой флешбек про устройство сборщика мусора в Blink. Он удаляет мертвые объекты, но не перемещает живые, что позволяет оперировать «голыми» указателями в локальных переменных в коде C++. Этот паттерн активно используется в Blink. Но он имеет и свою цену — при сборе мусора приходится сканировать стек потока, и если там обнаруживается что-то похожее на указатель на объект из кучи (heap), то считать объект и всё, на что он ссылается прямо или косвенно, живыми. Это приводит к тому, что некоторые фактически недоступные и поэтому «мертвые» объекты идентифицируются как живые. Поэтому такая форма сбора мусора ещё называется консервативной.
Проверяем связь со сканированием стека и пропускаем его. Проблема исчезла.
Что же может быть такого в стеке, что удерживает 500 тысяч объектов? Ставим точку остановки в функцию добавления объектов — в числе прочего видим там подозрительное:
blink::TraceTrait<blink::HeapHashTableBacking<WTF::HashTable<blink::WeakMember…
Ссылка на хэш-таблицу — вероятный подозреваемый! Проверяем гипотезу, пропуская добавление этой ссылки. Проблема исчезла. Отлично, мы стали ещё на шаг ближе к разгадке.
Вспоминаем другую особенность сборщика мусора в Blink: если он видит указатель на внутренности хэш-таблицы, то считает это признаком продолжающейся итерации по таблице, а значит, считает все ссылки этой таблицы полезными и продолжает их обходить. В нашем случае вхолостую. Но какая же функция является источником этой ссылки?
Продвигаемся на несколько кадров стека выше, берем текущую позицию сканера, смотрим, в кадр стека какой функции она попадает. Это функция с названием ScheduleGCIfNeeded. Казалось бы, вот он виновник, но… смотрим на исходный код функции и видим, что никаких хэш-таблиц там и в помине нет. Тем более что это уже часть собственно сборщика мусора, и ссылаться на объекты из кучи Blink ей просто незачем. Откуда же тогда взялась эта «плохая» ссылка?
Ставим брейкпойнт на изменение ячейки памяти, в которой нашли ссылку на хэш-таблицу. Видим, что пишет туда одна из внутренних функций под названием V8PerIsolateData::AddActiveScriptWrappable. Там происходит добавление создаваемых HTML-элементов некоторых типов, в том числе input, в единую хэш-таблицу active_script_wrappables_. Эта таблица нужна для предотвращения удаления элементов, на которые нет больше ссылок из Javascript или дерева DOM, но которые связаны с какой-либо внешней активностью, которая, например, может генерировать события.
Сборщик мусора при нормальном обходе таблицы учитывает состояние содержащихся в ней элементов и либо помечает их как живые, либо не помечает, тогда они удаляются на следующем этапе сборки. Однако, в нашем случае указатель на внутреннее хранилище данной таблицы всплывает при сканировании стека, и все элементы таблицы помечаются как живые.
Но как значение из стека одной функции попало в стек другой?!
Вспоминаем про ScheduleGCIfNeeded. Напомним, что в исходном коде этой функции ничего полезного найдено не было, но это лишь значит, что пора спуститься на более низкий уровень и проверить работу компилятора. Дизассемблированный пролог функции ScheduleGCIfNeeded выглядит так:
0FCDD13A push ebp
0FCDD13B mov ebp,esp
0FCDD13D push edi
0FCDD13E push esi
0FCDD13F and esp,0FFFFFFF8h
0FCDD142 sub esp,0B8h
0FCDD148 mov eax,dword ptr [__security_cookie (13DD3888h)]
0FCDD14D mov esi,ecx
0FCDD14F xor eax,ebp
0FCDD151 mov dword ptr [esp+0B4h],eax
Видно, что функция двигает esp вниз на 0B8h, и это место не используется дальше. Но из-за этого сканер стека видит то, что было ранее записано другими функциями. И случайно именно в эту «дыру» попадает указатель на внутренности хэш-таблицы, оставленный функцией AddActiveScriptWrappable. Как оказалось, причиной появления «дыры» в данном случае стал отладочный макрос VLOG внутри функции, который выводит в лог дополнительную информацию.
Но почему в таблице active_script_wrappable_ оказались сотни тысяч элементов? Почему ухудшение производительности наблюдается только на тесте jQuery? Ответ на оба вопроса один — в данном конкретном тесте, на каждое изменение (вроде отметки в чекбоксе) весь UI пересоздается полностью. Тест плодит элементы, которые почти сразу превращаются в мусор. Остальные тесты в Speedometer более благоразумны и не создают лишних элементов, поэтому для них ухудшения производительности не наблюдается. Если вы разрабатываете веб-сервисы, то стоит это учесть, чтобы не создавать лишнюю работу браузеру.
Но почему проблема возникла только сейчас, если макрос VLOG был раньше? Точного ответа нет, но, скорее всего, при обновлении изменилось взаимное расположение элементов в стеке, из-за чего указатель на хэш-таблицу стал случайно доступен сканеру. По сути, мы выиграли в лотерею. Чтобы быстро закрыть «дыру» и восстановить производительность, мы удалили отладочный макрос VLOG. Для пользователей он бесполезен, а для собственных нужд диагностики мы всегда сможем включить его обратно. Также мы рассказали о нашем опыте другим разработчикам из Chromium. Ответ подтвердил наши опасения: это фундаментальная проблема консервативного сбора мусора в Blink, не имеющая системного решения.
Интересные ссылки
1. Если вам интересно узнать о других необычных буднях нашей группы, то напомним историю о чёрном прямоугольнике, которая привела к ускорению не только Яндекс.Браузера, но и всего проекта Chromium.
2. А ещё приглашаю послушать другие доклады на следующем мероприятии Яндекс.Изнутри 16 февраля, регистрация открыта, трансляция тоже будет.
Комментарии (14)
myxo
22.01.2019 16:15Не совсем понял почему esp двигается вниз на 0B8h. Это как раз из-за этого макроса VLOG? Он выделяет память и не использует его?
Andrey_Logvinov Автор
22.01.2019 17:18+2Предполагаю, что под макрос VLOG память в стеке выделяется всегда, но используется она лишь тогда, когда логгирование включено в рантайме. Поэтому включение логгирования тоже лечило эту проблему.
hasu0
22.01.2019 16:53Вроде весь рассказ нить повествования не ускользала от меня, но переход от макроса VLOG к изменению esp я совершенно не уловил. Можно больше подробностей?
Также приведите, пожалуйста, ссылку на коммит в хромиум.Andrey_Logvinov Автор
22.01.2019 18:09+1Разворачивание макроса VLOG подразумевает создание временных объектов, под которые требуется память в стеке. Эта память резервируется в начале функции путем уменьшения значения регистра esp на нужную величину.
Мы обсуждали эту ситуацию с разработчиками Chromium, однако изменения в Chromium вносить не стали, поскольку данная проблема — следствие консервативного сканирования стека и «удачного» стечения обстоятельств, у нее нет системного решения. Мы нарвались на эту проблему случайно в одном конкретном тесте, сильно нагружающем GC.vesper-bot
23.01.2019 10:02А можно внести в VLOG изменения, что если логирование отключено, переменные, под которые выделена память, заполняются нулями? Должно хватить, по логике.
molnij
23.01.2019 13:10тогда при его активном использовании и отключенном логировании можно схватить некоторую деградацию производительности на заполнении памяти
vesper-bot
23.01.2019 17:16А насколько большую, если заполняться будет полу-нативно, скажем, через rep stosX, в результате отработки оптимизатора? Кроме того, насколько «активное использование» предусматривается в этой фразе? Не является ли она примером слишком ранней оптимизации? :) Хотя немного посмотрел, что это за VLOG такой — сдается мне, править надо не сам VLOG, а модуль логирования, а это может влиять на большее число мест, чем те, где используется именно VLOG. Да ещё эта проблема с разворачиванием макросов в препроцессинге, неясно в итоге, во что развернулся конкретный вызов макроса от макроса от макроса, и посмотреть бывает негде (правда, это воспоминания от работы с cc в универе 20-летней давности, может, сейчас и есть возможность развернуть макрос в конкретном месте и посмотреть, что за код получается). За это, собственно, я макросы и не любил — отлаживать неудобно.
QtRoS
23.01.2019 21:41То есть официально в Blink такой GC, который может недочищать память? Получается он на эвристиках?
Andrey_Logvinov Автор
24.01.2019 11:23Это зависит от ситуации, в которой происходит сбор мусора. Если в стеке нет ссылок на GC-объекты (например, в начале очередного цикла лупера главного потока) — тогда GC гарантированно соберет весь мусор. Когда в стеке могут быть ссылки на GC-объекты (например, если понадобилось освободить память для создания нового объекта), коллектор вынужден трактовать все похожие на ссылки значения как ссылки: иначе он может пропустить настоящую ссылку и удалить объект, не являющийся мусором.
vesper-bot
Странно, что локальные данные проблемной функции не занулялись после изменения ESP. Нелогично же. Или опять проблема в производительности, и зануление стека тупо отключили ради пары fps?
myxo
Да нет, это обычное поведение Си =) С самого начала же учат, что в только что созданной переменной находится мусор (возможно оставленный предыдущей функцией).
А так да, ради производительности. А точнее ради того, чтобы не платить за то, что не используешь. Все-таки случай приведенный в статье (когда мы анализируем данные со стека на наличие потенциальных указателей) — довольно редкий.
vesper-bot
Привык я к нулям в локальных переменных в Турбо-Паскале 6.0, видимо. :)
Имхо всё же, если ты не собираешься использовать столько локальных переменных, незачем выделять место на стеке. Так или иначе, stack trace сейчас довольно штатная процедура, и пусть обычно она не лазает в локальные данные, но если сделан, скажем, минидамп, и туда попало что-то не то, это непорядок.
da-nie
Только если она не статическая.
arteast
Вообще можно добавить в clang какую-нибудь опцию, чтобы занулять на выходе из функции (или после последнего использования) стековые указатели, и компилировать с ней файлы, обрабатываемые GC. Или даже не все указатели, а только те, которые вообще могут собираться GC. Падение производительности будет минимальным.
С другой стороны, в нормальных условиях текущее поведение приводит к мизерной пессимизации GC (не собрали свежесдохший объект — соберем в следующий раз, проблем-то), так что решение разработчиков о таком tradeoff совершенно понятно