Как сэкономить ресурсы в браузере и не сломать веб. Доклад Яндекса
Несмотря на рост производительности устройств, веб становится всё более требовательным к памяти и процессору. Правильный рендеринг и умное распределение ресурсов по вкладкам — важная часть решения этой проблемы. Константин Крамлих посвятил своё выступление на конференции «Я Frontend» алгоритмам, которые улучшают производительность и экономят ресурсы как в проекте Chromium, так и в Яндекс.Браузере.
Некоторые из них — например, технологию Hibernate — мы уже разбирали в отдельном посте. Доклад Кости освещает задачу более широко: не только с точки зрения переключения вкладок, но и с учетом методов отрисовки контента, тайлов и слоев страницы.
Ближе к концу разработчики веб-интерфейсов могут узнать, как выявлять и решать проблемы с производительностью сайтов.
— Меня зовут Костя, я руководитель группы разработки внутренних компонентов в команде Яндекс.Браузера. В Браузере я чуть больше пяти лет, занимался разными вещами: от всего декодирования в браузере, всех HTML5-видео, до отрисовки, рендеринга и других подобных процессов.
Последние полтора-два года я занимался проектами, связанными с экономией ресурсов в браузере: ЦПУ, память, батарейка.
Вы скажете — Костя, на дворе 2019, откуда проблемы с ресурсами? Ты можешь купить любое устройство, которое захочешь, с любыми ресурсами. Но если мы обратимся к открытой статистике Mozilla, то увидим, что у половины пользователей 4 ГБ памяти или меньше. И множество пользователей, у которых одно или два физических ядра, они составляют немалую долю вашей аудитории. В этом мире мы и живем.
Кто из вас часто видит вот такую вкладку? Именно так в итоге и происходит у простых пользователей, у которых мало оперативной памяти и старые компьютеры.
Что делать? Проблема — ни для кого не секрет, с ней начали активно бороться года три назад. С тех пор постепенно закручивают гайки разными способами. Покажу пример, который ходил по Stack Overflow примерно в 2016 году. Тут довольно простой сниппет. Эта штука просто обновляет тайтл и ставит время, которое прошло с момента последнего запуска этой функции. Что должно получаться в идеале? Каждые 100 мс в тайтле должно быть написано ±100. Как повезет.
Но что если мы откроем и будем делать вот так? Кто-то напарывался на это? На Stack Overflow ходил вопрос: какого черта у меня в фоновых вкладках перестает работать Cookie Clicker? Это была одна из первых инициатив Chromium, направленных на уменьшение потребления ЦПУ в браузере. Идея была в том, что если пользователь сейчас не использует вкладку, значит, она ему сейчас не нужна — давайте притушим на ней JS.
Браузер пытается поддерживать нагрузку ЦПУ на этой вкладке примерно на уровне 1% — начинает паузить всякие таймеры, выполнение JS и т. д. Это один из первых шажков в светлое будущее.
Через какое-то время в браузере вы получите ситуацию, что фоновые вкладки перестают работать вообще. Это то самое светлое будущее, о котором я говорю. По планам Chromium, которые они озвучивали на последнем BlinkOn, в 2020 году планируют сделать так: даем вкладке загрузиться и если она фоновая, она ничего не будет делать. К этому всегда нужно быть готовым.
В Яндекс.Браузере мы тоже озаботились такой проблемой, но решили ее менее категорично и не стали ломать весь веб. Создали режим энергосбережения, который выключает декодирование на процессоре и оставляет только декодирование на видеокарте, а также понижает FPS и отключает некоторые анимации, которые прямо сейчас не нужны и вместо которых пользователю лучше сэкономить батарейку. Нам это дало примерно час дополнительной работы от батарейки. Проверить может любой, ixbt, например, проверили.
Думаю, некоторые скажут: Костя, вы «сломали» веб, помогли каким-то пользователям, а ничего умнее не придумали. Добавим хардкора! Как вообще браузеры рисуют страницы?
Концепция слоев, в двух словах, это когда браузер старается разбить страницу на слои и отрисовывать их отдельно. Это сделано, чтобы какие-то анимации выполнялись и не заставляли перерисовываться то, что статично. Браузер это делает на разных эвристиках. Например — старается выделять в отдельный слой видеоэлемент, который, очевидно, быстро и часто перерисовывается. И если он где-то показывается, то не нужно перерисовывать все, что под ним.
Кроме того, каждый слой разделен на такие тайлы — прямоугольники 256 на 256. В инспекторе вы можете увидеть примерно такую картину. Есть кадр, который разбивается на кучу тайлов.
Что это и зачем это нужно? В первую очередь — для приоритизации отрисовки. Если у нас огромная колбаса, на которой мы все рисуем, то зачем нам все это рисовать, если сейчас юзер видит только то, что у него сейчас во ViewPort?
C помощью такого подхода мы сначала рисуем только то, что юзер видит прямо сейчас во ViewPort, затем один тайл вокруг, затем по направлению скролла. Если юзер скроллит вниз — прорисовываем вниз, если вверх — вверх. Все остальное будет нарисовано, только если у нас осталась квота на эти тайлы и мы можем их нарисовать, после чего юзер когда-нибудь их увидит. А может, и никогда.
Также это очень помогает при инвалидациях. Допустим, юзер открывает страницу, выделяет какой-то кусок, и нам не нужно перерисовывать все. Мы можем бóльшую часть оставить от предыдущей отрисовки. Шесть тайлов перерисуем тут, и все будет отлично.
Как раз на этом уровне и были сделаны несколько очень удачных оптимизаций. Например, в Chromium сделали такую оптимизацию примерно в 2017 году.
Если у нас есть только небольшая отрисовка, мы делаем только ее. Тут моргает курсор, и мы перерисовываем только область курсора, но не всю область этого тайла. Мы очень сильно экономим ЦПУ, чтобы не перерисовывать все подряд.
Также это помогает экономить память. В чем здесь проблема? Целые белые прямоугольники. Представьте, что это была бы текстура 256 на 256, четыре байта на пиксель. Хотя казалось бы, эту область можно закодировать всего пятью числами: координаты, ширина, высота и цвет.
В Chromium была сделана оптимизация одноцветных областей. Если браузер понимает, что в этом прямоугольнике нет никаких отрисовок, что он полностью одноцветный, не прозрачный и соответствует еще каким-то условиям, то мы просто говорим видеокарте — нарисуй белый прямоугольник, не выделяй целую текстуру.
Что еще тут можно оптимизировать? Если посмотреть на оставшиеся тайлы — в них немного контента и еще огромная область белого цвета. Мы в Яндекс.Браузере задумались об этом и сделали механизм, который назвали адаптивным тайлингом.
Есть один небольшой прямоугольник, тайл. В середине тайла небольшой контент. Мы выделяем его и — только под него — текстуру. Все остальное тоже разбивается на несколько областей, про которые мы говорим видеокарте: просто нарисуй белым цветом вот такого размера.
Страница также начинает экономить все то, что выделено красным. На более сложных страницах это выглядит как-то так.
Важно понимать, что еще есть куча слоев и каждый слой рисуется вот таким образом. На каждом слое можно сэкономить какое-то количество памяти. Такой подход позволил нам сэкономить около 40% видеопамяти в среднем для всех пользователей.
Еще больше хардкора! Тут немного памяти сэкономили, тут «сломали» веб — почему бы не «ломать» веб дальше?
В Chromium есть что-то вроде политики: если юзер не использует фоновые вкладки, если он с них ушел, значит, они ему не нужны. Если мы сейчас без памяти и браузер сейчас ляжет, то давайте возьмем самую старую вкладку, которую пользователь давно не использовал, и убьем ее. Она будет оставаться в интерфейсе, но процесса там уже не будет, весь JS умрет. Это ок или не ок? Странно задавать такой вопрос на фронтенд-тусовке и ожидать какого-либо ответа, кроме «Да вы что?».
Тогда это не сильно зашло. Вот реальные комментарии из блога Chromium: вы мне сломали все мои приложения, игра какая-то была — и хоп, у нее нет никакого состояния. Важно понимать, что там и unload handler не срабатывал, словно мы просто потушили эту вкладочку. Юзер потом на нее возвращается, и мы заново загружаем ее из сети, как будто ничего и не было.
Тогда от этого подхода временно отказались и пришли с более продуманной серьезной идеей. Назвали ее discard.
В чем смысл? Это все то же убивание вкладок, только контролируемое. Оно называется умным словом Page Lifecycle API. Если у вас есть вкладка, и пользователь ее уже давно не видел, она может перейти в состояние frozen. Браузер говорит через ивент: сейчас я тебя зафрижу. После обработки ивента вообще ничего не будет выполняться. Сделай, что тебе надо, готовься.
Затем из состояния frozen она может либо выйти через событие resume, якобы ничего не произошло. Либо, если браузеру действительно нужно прямо сейчас освободить память, он просто берет и убивает ее. Но если пользователь перейдет обратно на эту вкладку, мы ее перезагрузим, и у документа выставим поле was discarded.
Прямо сейчас вы уже можете подписаться на эти ивенты и ловить их, как-то обрабатывать. Если вкладку действительно убьют, можно проверить поле was discarded. Это значит, что вас восстановили после дискарда. Можно восстановить предыдущее состояние.
Мы в Яндекс.Браузере подумали еще несколько лет назад: почему бы не применить сложный кардинальный подход. Назвали его Hibernate.
В чем смысл? Есть несколько вкладок, выполняется какой-то JS, какое-то состояние. Для каждой вкладки создается отдельный процесс: тут может играться видео, тут вы что-то оставляете в форме. Приходит Hibernate — и процессов нет. Мы их все «покиллили». Но если мы сейчас снова переключимся на эти вкладки, то процесс вернется обратно, и все состояние будет на месте, видео продолжит воспроизводиться с нужного момента, весь текст в полях останется на месте.
Что мы сделали? Внутри каждого рендерера живут три самые важные вещи: V8, в котором крутится весь JS, Blink, в котором хранится весь DOM, и какая-то браузерная обвязка, которая помогает связать все вместе, с табами и всем прочим.
Рассмотрим для примера семпл. Тут мы ждем, когда случится onload, и добавляем новый элемент div в DOM-дерево. Для браузера это выглядит как-то так.
Естественно, есть DOM-дерево, у него есть какие-то поля, ассоциированные объекты, и есть вот такая сущность.
В V8 хранится состояние каждой ноды, и эти ноды связываются с блинковыми объектами через прослойку биндингов. Что мы сделали? Мы взяли сериализатор из V8, сериализовали весь V8 state, нашли все связанные объекты в Blink, написали плюсовые сериализаторы, которые сохраняют все DOM-дерево, сериализуют его, затем пишут на диск, сжато и зашифровано. И мы научили браузер восстанавливаться из такого снепшота обратно. То есть когда юзер переходит на такую вкладку, мы ее разжимаем, расшифровываем и показываем пользователю, восстанавливаем полностью. (Отдельный пост про Hibernate — прим. ред.)
Прямо сейчас Hibernate опубликован для всех в stable и позволяет каждому пользователю экономить в среднем одну-две вкладки. То есть у него в среднем одна вкладка всегда сохранена, а может, две. Это позволяет экономить память для пользователей, у которых больше 10 вкладок — как у нас с вами, но мы не репрезентативны.
Я рассказал, как браузер пытается помочь, но уже сейчас каждый из вас может что-то сделать, чтобы ускорить сайт, улучшить его производительность. Прямо сегодня можете прийти и сделать.
Сначала нужно понять, имеются ли проблемы с памятью.
Есть определенные симптомы: либо сайт начинает деградировать, либо появляются замирания. Обычно это значит, что срабатывает garbage collector, весь мир замер и ничего не отрисовывается. Или что сайт просто непрерывно тормозит — такие тоже бывают.
Нужно понять, есть ли проблема. Смотрим, что происходит с памятью JS.
Если она резко скачет туда-обратно или непрерывно растет, это нехороший симптом. Или непрерывно растет.
В этом случае всегда можно снять снепшот через DevTools. Кто-то вообще заморачивался с утечками в JS? Если кто-то не знает, в JS вполне можно сделать утечку.
Например, у вас есть глобальная переменная, куда вы складываете ноды, которые не вставлены в дерево. Вы про них забываете, а потом оказывается, что они жрут сотни килобайт, а то и мегабайт.
Строго говоря, непонятно, что делать с такими detached-нодами. Вы их можете вытащить из дерева, а потом вставить обратно. Потому что обычно состояние картинки или еще что-то остается вместе с ними. Таким образом можно их найти и полечить проблему.
Также можно посмотреть на выделения памяти на временной шкале. Если у вас идут планомерные выделения и никогда ничего не очищается — это тоже плохой знак, думаю, все это понимают.
И еще можно поиграться со слоями у вас на вкладке. Браузер вообще работает на эвристиках в этом плане — старается выделять в отдельные слои те объекты, которые считает часто меняющимися. Но иногда делает это не очень удачно, и получается, что у вас очень много слоев, которые потребляют очень много памяти. Всегда можете посмотреть, есть ли у вас такая ситуация, какие-то слои устранить и узнать, сколько памяти занимает такой слой.
Другой пласт проблем — с производительностью. setTimeout — плохая идея для анимации. Он работает не так, как браузер хочет отрисовать страницу. Он может работать не в фазу: браузер запросил кадр, а анимации еще нет, потому что setTimeout не сработал. Или он может работать, даже когда не нужно ничего рисовать. Пользователь ушел, еще какая-то фоновая работа остается, но мы будем с ним работать через setTimeout.
Вот правильный подход, который будет вызывать ваш колбек, только когда браузеру нужно нарисовать эту страницу.
Также хочу напомнить, что если браузер хочет рисовать 60 кадров в секунду, значит, на каждый кадр ему нужно примерно 16 мс.
И если вы занимаете главный поток дольше, чем на 16 мс, то у вас гарантированный пропуск кадра, что довольно неприятно воспринимается пользователем: сайт начинает подлагивать. Поэтому всю тяжелую работу правильно выносить в фоновые потоки, которые вам просто вернут результат.
Или другой подход — применять microtasking. Создаете очередь задач, обрабатываете ее через какое-то время, пока не кончилась квота, и даете браузеру спокойно нарисовать страницу.
Естественно, можно добавлять новые слои. Браузер работает на эвристиках и пытается выделить слой там, где он считает нужным. Но если вы знаете, что прямо сейчас этот элемент у вас будет анимироваться, лучше явно сказать браузеру, что нужно этот элемент выделить в отдельный слой. Тогда он будет отрисовываться более эффективно.
Самый последний интересный подход — жалуйтесь. Если у вас есть проблемы, всегда полезно прийти и поговорить. Может, эту проблему легко полечить, и мы поможем друг другу.
Небольшая предыстория. Эта фича прямо сейчас в процессе проработки. К нам пришла команда разработки страницы поисковой выдачи. У них есть такая метрика, как время отрисовки первого сниппета. Если пользователь раньше видит первую ссылку, на которую нужно нажать, и которая вероятнее всего дает ему правильный ответ, то гораздо лучше нарисовать ее как можно быстрее. Они пришли и спросили, можно ли как-то ускорить это время, потому что они уже все выжали.
Мы поисследовали, провели перф-тестирование, сделали прототип, и оказалось, что мы можем это сделать, если нам сайт скажет, что нужно рисовать первым. Провели тесты и видим, что это улучшает наши показатели. Сейчас тестируем это в продакшене на небольшой аудитории. Смотрим, что из этого получится. Stay tuned.
Мир вообще не стоит на месте. Пользователь начинает хотеть все больше, браузеры меняются, веб меняется. Это нормально. Мы стараемся не только делать продуктовые фичи, но и всячески помогать пользователю получать лучший опыт в плане потребления контента. И всегда нужно быть готовым к изменениям в любом популярном браузере. Естественно, если есть проблемы — приходите, мы готовы обсуждать и помогать друг другу.
Тут я собрал разные полезные ссылки:
— Hibernate в Яндекс.Браузере
— Tab discarding in Chrome
— Page Lifecycle API
— Measure Performance with the RAIL Model
— Энергосбережение в Яндекс.Браузере
— Steam hardware statistics
— The Firefox Public Data Report