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

Меня зовут Андрей Логвинов, я работаю в группе разработки рендеринг-движка Яндекс.Браузера в Нижнем Новгороде. Сегодня я расскажу читателям Хабра о том, как устроено управление памятью в проекте Chromium на примере одной загадочной проблемы, которая приводила к падению производительности в тесте Speedometer. Этот пост основан на моём докладе с мероприятия Яндекс.Изнутри.




Однажды на нашем дашборде производительности мы увидели ухудшение скорости работы теста Speedometer. Этот тест измеряет совокупную производительность браузера на приближенном к реальности приложении — списке дел, где тест добавляет пункты в список и затем вычёркивает их. На результаты теста влияет как производительность JS-движка V8, так и скорость отрисовки страниц в движке Blink. Тест Speedometer состоит из нескольких подтестов, где тестовое приложение написано с использованием одного из популярных JS-фреймворков, например jQuery или ReactJS. Общий результат теста определяется как среднее для результатов по всем фреймворкам, но тест позволяет посмотреть производительность по каждому фреймворку в отдельности. Стоит отметить, что тест не ставит своей целью оценить производительность фреймворков, они используются только для того, чтобы сделать тест менее синтетическим и более приближенным к реальным веб-приложениям. Детализация по подтестам показала, что ухудшение наблюдается только для версии тестового приложения, созданного с использованием jQuery. А это уже интересно, согласитесь.

Расследование таких ситуаций начинается достаточно стандартно — мы определяем, какой именно коммит в код привёл к проблеме. Для этого у нас хранятся сборки Яндекс.Браузера на каждый (!) коммит за последние несколько лет (собирать заново было бы непрактично, так как сборка занимает несколько часов). Пространства на серверах это занимает немало, но обычно помогает быстро найти источник проблемы. Но в этот раз быстро не получилось. Оказалось, что ухудшение результатов теста совпало с коммитом, интегрирующим очередную версию Хромиума. Результат не обнадеживающий, потому что новая версия Хромиума приносит огромное количество изменений разом.

Поскольку информации, указывающей на конкретное изменение, мы не получили, пришлось заняться исследованием проблемы по существу. Для этого мы при помощи Developer Tools сняли трейсы теста. Заметили странную особенность — «рваные» интервалы исполнения Javascript-функций теста.

image

Снимаем более технический трейс при помощи about:tracing и видим, что это сбор мусора (GC) в Blink.

image

На трейсе памяти ниже видно, что эти GC-паузы не только занимают много времени, но и никак не помогают остановить рост потребляемой памяти.

image

Но если вставить в тест явный вызов 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)


  1. vesper-bot
    22.01.2019 14:14

    Странно, что локальные данные проблемной функции не занулялись после изменения ESP. Нелогично же. Или опять проблема в производительности, и зануление стека тупо отключили ради пары fps?


    1. myxo
      22.01.2019 16:21

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

      А так да, ради производительности. А точнее ради того, чтобы не платить за то, что не используешь. Все-таки случай приведенный в статье (когда мы анализируем данные со стека на наличие потенциальных указателей) — довольно редкий.


      1. vesper-bot
        22.01.2019 16:59

        Привык я к нулям в локальных переменных в Турбо-Паскале 6.0, видимо. :)

        Имхо всё же, если ты не собираешься использовать столько локальных переменных, незачем выделять место на стеке. Так или иначе, stack trace сейчас довольно штатная процедура, и пусть обычно она не лазает в локальные данные, но если сделан, скажем, минидамп, и туда попало что-то не то, это непорядок.


      1. da-nie
        22.01.2019 18:18

        С самого начала же учат, что в только что созданной переменной находится мусор


        Только если она не статическая.


      1. arteast
        23.01.2019 12:28

        Вообще можно добавить в clang какую-нибудь опцию, чтобы занулять на выходе из функции (или после последнего использования) стековые указатели, и компилировать с ней файлы, обрабатываемые GC. Или даже не все указатели, а только те, которые вообще могут собираться GC. Падение производительности будет минимальным.
        С другой стороны, в нормальных условиях текущее поведение приводит к мизерной пессимизации GC (не собрали свежесдохший объект — соберем в следующий раз, проблем-то), так что решение разработчиков о таком tradeoff совершенно понятно


  1. myxo
    22.01.2019 16:15

    Не совсем понял почему esp двигается вниз на 0B8h. Это как раз из-за этого макроса VLOG? Он выделяет память и не использует его?


    1. Andrey_Logvinov Автор
      22.01.2019 17:18
      +2

      Предполагаю, что под макрос VLOG память в стеке выделяется всегда, но используется она лишь тогда, когда логгирование включено в рантайме. Поэтому включение логгирования тоже лечило эту проблему.


  1. hasu0
    22.01.2019 16:53

    Вроде весь рассказ нить повествования не ускользала от меня, но переход от макроса VLOG к изменению esp я совершенно не уловил. Можно больше подробностей?
    Также приведите, пожалуйста, ссылку на коммит в хромиум.


    1. Andrey_Logvinov Автор
      22.01.2019 18:09
      +1

      Разворачивание макроса VLOG подразумевает создание временных объектов, под которые требуется память в стеке. Эта память резервируется в начале функции путем уменьшения значения регистра esp на нужную величину.
      Мы обсуждали эту ситуацию с разработчиками Chromium, однако изменения в Chromium вносить не стали, поскольку данная проблема — следствие консервативного сканирования стека и «удачного» стечения обстоятельств, у нее нет системного решения. Мы нарвались на эту проблему случайно в одном конкретном тесте, сильно нагружающем GC.


      1. vesper-bot
        23.01.2019 10:02

        А можно внести в VLOG изменения, что если логирование отключено, переменные, под которые выделена память, заполняются нулями? Должно хватить, по логике.


        1. molnij
          23.01.2019 13:10

          тогда при его активном использовании и отключенном логировании можно схватить некоторую деградацию производительности на заполнении памяти


          1. vesper-bot
            23.01.2019 17:16

            А насколько большую, если заполняться будет полу-нативно, скажем, через rep stosX, в результате отработки оптимизатора? Кроме того, насколько «активное использование» предусматривается в этой фразе? Не является ли она примером слишком ранней оптимизации? :) Хотя немного посмотрел, что это за VLOG такой — сдается мне, править надо не сам VLOG, а модуль логирования, а это может влиять на большее число мест, чем те, где используется именно VLOG. Да ещё эта проблема с разворачиванием макросов в препроцессинге, неясно в итоге, во что развернулся конкретный вызов макроса от макроса от макроса, и посмотреть бывает негде (правда, это воспоминания от работы с cc в универе 20-летней давности, может, сейчас и есть возможность развернуть макрос в конкретном месте и посмотреть, что за код получается). За это, собственно, я макросы и не любил — отлаживать неудобно.


  1. QtRoS
    23.01.2019 21:41

    То есть официально в Blink такой GC, который может недочищать память? Получается он на эвристиках?


    1. Andrey_Logvinov Автор
      24.01.2019 11:23

      Это зависит от ситуации, в которой происходит сбор мусора. Если в стеке нет ссылок на GC-объекты (например, в начале очередного цикла лупера главного потока) — тогда GC гарантированно соберет весь мусор. Когда в стеке могут быть ссылки на GC-объекты (например, если понадобилось освободить память для создания нового объекта), коллектор вынужден трактовать все похожие на ссылки значения как ссылки: иначе он может пропустить настоящую ссылку и удалить объект, не являющийся мусором.