Привет, Хабр! Представляю вашему вниманию перевод статьи Inside a super fast CSS engine: Quantum CSS (aka Stylo) автора Лин Кларк.


Вы возможно слышали о Project Quantum… Это проект по существенной переработке внутренностей Firefox с целью ускорить браузер. По частям мы внедряем наработки нашего экспериментального браузера Servo и значительно улучшаем остальные элементы движка.


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


image


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


И первый крупный компонент из Servo — новый CSS-движок Quantum CSS (ранее известный как Stylo) — теперь доступен для тестирования в ночной сборке Firefox (прим. переводчика: в комментариях подсказали, что уже и в stable 55 есть). За его включение отвечает опция layout.css.servo.enabled в about:config.


Новый движок воплощает лучшие инновации из других браузеров.


image


Quantum CSS использует преимущества современного железа, распараллеливая работу между всеми ядрами процессора, что дает ускорение вплоть до 2, 4 или даже 18 раз.


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


image


Но чем конкретно занимается CSS-движок? Для начала давайте рассмотрим что такое движок CSS в целом и каково его место в браузере, а после разберём, как Quantum CSS все это дело ускоряет.


Что такое CSS-движок?


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


image


Каждый браузер имеет движок рендеринга. У Chrome это Blink, у Edge — EdgeHTML, у Safari — WebKit, ну а у Firefox — Gecko.


Чтобы переварить файлы в пиксели, все они делают примерно одно и то же:


1) Парсинг файлов в понятные браузеру объекты, включая DOM. На этом этапе DOM знает о структуре страницы, знает о родительских отношениях между элементами, но не знает, как эти элементы должны выглядеть.


image


2) Определение внешнего вида элементов. Для каждого узла DOM движок CSS выясняет, какие CSS-правила применить. Потом он определяет значение для каждого свойства CSS. Стилизирует каждый узел в DOM-дереве, прикрепляя рассчитанные стили.


image


3) Определение размеров и положения для каждого узла. Для всего, что должно быть отображено на экране, создаются блоки (boxes). Они представляют не только узлы DOM, но и то, что может быть внутри них. Например, строки текста.


image


4) Отрисовка блоков. Она может происходить на нескольких слоях. Я представляю это себе, как старые, нарисованные от руки анимации на нескольких листах полупрозрачной бумаги. Это позволяет изменить один слой, без необходимости перерисовывать другие.


image


5) Объединение слоев в одно изображение, предварительно применив к ним необходимые свойства композитора (например, трансформации). Это как сделать фотографию слоев, совмещенных вместе. Далее это изображение будет отображено на экране.


image


То есть, перед началом просчёта стилей на входе CSS-движка имеется:


  • DOM-дерево
  • Список правил стилей

И так, он поочередно определяет стили для каждого узла DOM, одного за другим. Значение назначается каждому свойству CSS, даже если оно не задано в таблицах стилей.


Я представляю это себе, как заполнение формы, где все поля обязательны. Нужно заполнить такую форму для каждого узла DOM.


image


Чтобы сделать это, CSS-движок должен выполнить две вещи:


  • Выбрать правила, которые должны быть применены к узлу (сопоставление селекторов, selector matching)
  • Заполнить все отсутствующие значения стандартными или унаследовать родительские (каскадирование, the cascade)

Сопоставление селекторов


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


image


Кроме того, браузер сам добавляет некоторые стандартные стили (user agent style sheets). Так как же CSS-движок определяет, какое значение использовать?


Вот где нам приходит на помощь "правило конкретности" (specificity rule). Движок CSS создает таблицу определений, которую потом сортирует по разным столбцам.


image


Правило с наибольшей конкретностью побеждает. Основываясь на такой таблице, CSS-движок вносит все заданные в ней значения в форму.


image


Остальные высчитываются за счет каскадирования.


Каскадирование


Каскадирование упрощает написание и сопровождение CSS. Благодаря ему Вы можете установить свойство color у body, и знать, что цвет текста в элементах p, span, li будет таким же (если только Вы не переопределите его самостоятельно).


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


image


Так что теперь все стили для заданного узла DOM просчитаны, форма заполнена.


Примечание: совместное использование структур стилей


Описанная форма немного упрощена. CSS имеет сотни свойств. Если бы CSS-движок сохранял значение каждого свойства для каждого DOM-узла, он быстро использовал бы всю доступную память.


Вместо этого, движки обычно используют механизм совместного использования структур стилей (style struct sharing). Они сохраняют значения, которые обычно используются вместе (например свойства шрифта) в другом объекте под названием "структура стилей". Далее, вместо хранения всех свойств в одном объекте, объекты рассчитанных стилей содержат только указатель. Для каждой категории свойств существует указатель на структуру стилей с нужными значениями.


image


Это экономит и память, и время. Узлы с похожими стилями могут просто указывать на те же структуры стилей для общих свойств. И поскольку многие свойства наследуются, родитель может делиться своей структурой с любыми дочерними узлами, которые не переопределяют собственные значения.


Так как же мы все это ускоряем?


Так выглядит неоптимизированный процесс расчёта стилей.


image


Здесь выполняется достаточно много работы. При чём не только в момент первой загрузки страницы. А снова и снова, по ходу взаимодействия со страницей, при наведении курсора на элементы или изменении DOM вызывается перерасчёт стилей.


image


Это значит, что вычисление CSS стилей — отличный кандидат для оптимизации… И за последние 20 лет браузеры перетестировали множество разных стратегий оптимизации. Quantum CSS пытается совместить лучшие из них для создания нового супер-быстрого движка.


Давайте рассмотрим, как это всё работает вместе.


Распараллеливание


Проект Servo (из которого вышел Quantum CSS) — это экспериментальный браузер, который пытается распараллелить всё в процессе отрисовки веб-страницы. Что это значит?


Можно сравнить компьютер с мозгом. Есть элемент, отвечающий за мышление (АЛУ). Возле него располагается что-то типа краткосрочной памяти (регистры), последние сгруппированы вместе на центральном процессоре. Кроме того есть долгосрочная память (ОЗУ).


image


Ранние компьютеры могли думать только одну мысль за раз. Но за последние десятилетия процессоры изменились, теперь они имеют несколько сгруппированных в ядра АЛУ и регистров. Так что теперь процессоры могут думать несколько мыслей одновременно — параллельно.


image


Quantum CSS использует эти преимущества, разделяя вычисление стилей для разных узлов DOM по разным ядрам.


Может показаться, что это легко… Всего лишь разделить ветви дерева и обрабатывать их на разных ядрах. На самом деле всё гораздо сложнее по нескольким причинам. Первая причина в том, что DOM-деревья часто неравномерные. То есть, одни ядра получат значительно больше работы, чем другие.


image


Чтобы распределить работу более равномерно Quantum CSS использует технику под названием "воровство работы" (work stealing). Когда узел DOM обрабатывается, программа берет его прямые дочерние элементы и разделяет их на одну или несколько "единиц работы" (work unit). Эти единицы работы ставятся в очередь.


image


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


image


В большинстве браузеров будет сложно реализовать это правильно. Параллелизм — это заведомо сложная задача, а CSS-движок достаточно сложный и сам по себе. Он также находится между двумя другими самыми сложными частями движка рендеринга — DOM и разметки. В общем, ошибку допустить легко, и распараллеливание может привести к достаточно трудноотловимым багам, под названием "гонки данных" (data races). Я описываю эти баги подробнее в другой статье (есть и перевод на русский).


Если Вы принимаете правки от сотен тысяч контрибьюторов, как Вы можете применять параллелизм без страха? Для этого у нас есть Rust.


image


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


Всё это сильно упрощяет дело. Теперь почти ничего не останавливает Вас реализовать вычисление CSS стилей эффективно параллельно. Это значит, что мы можем приблизиться к линейному ускорению. Если Ваш процессор 4-х ядерный, то распараллеливание даст прирост в скорости почти в 4 раза.


Ускорение перерасчёта с помощью дерева правил


Для каждого DOM-узла CSS-движок должен пройтись по всем правилам и выполнить сопоставление селекторов. Для большинства узлов соответствующие селекторы скорее всего будут меняться не очень часто. К примеру, если пользователь наводит указатель мыши на какой-то элемент, то соответствующие ему правила могут измениться. Нам необходимо пересчитать стили для всех его потомков, чтобы обработать наследование свойств. Но правила, соответствующие этим потомкам, вероятно не изменятся.


Было бы неплохо запоминать, какие правила соответствуют этим потомкам, чтобы не пришлось сопоставлять селекторы снова… И дерево правил, пришедшее из предыдущих версий Firefox, делает именно это.


Движок CSS выбирает селекторы, соответствующие элементу, а потом сортирует их по конкретности (specificity). В результате выходит связанный список правил.


Этот список добавляется в дерево.


image


CSS-движок пытается минимизировать количество веток в дереве, переиспользуя их, когда возможно.


Если большинство селекторов в списке совпадает с существующей веткой, он проследует по ней. Но он может достичь точки, где следующее правило в списке не совпадает с правилом из существующей ветки. Только в таком случае создается новая ветка.


image


DOM-узел получит указатель на то правило, которое было добавлено последним (в нашем примере, div#warning). Оно самое конкретное.


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


image


И так, это помогает сэкономить время при перерасчёте стилей, но начальный расчет все-равно трудоемкий. Если есть 10000 узлов, то необходимо проделать сопоставление селекторов 10000 раз. Но есть способ ускорить и это.


Ускорение начального рендеринга при помощи общего кеша стилей


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


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


Именно это делает общий кеш правил, черпающий вдохновение из Safari и Chrome. После обработки элемента, рассчитанный стиль ложится в кеш. Далее, перед началом расчёта стилей следующего элемента, выполняются несколько проверок, чтобы проверить, нельзя ли использовать что-то из кеша.


Проверки следующие:


  • Имеют ли 2 узла одинаковые ID, классы, и т.д. Если да — они будут соответствовать тем же правилам.
  • Имеют ли они одинаковые значения для всего, что не основывается на селекторах (например, встроенные стили). Если да, то вышеупомянутые правила не будут переопределены, либо будут переопределены одинаково для обеих.
  • Указывают ли родители обеих на тот же объект рассчитанных стилей. Если да, то наследуемые значения тоже будут одинаковыми.

image


Эти проверки были реализованы еще в ранних версиях общего кеша стилей с самого начала. Но существует много мелких ситуаций, в которых стили не совпадут. Например, если CSS правило использует селектор :first-child, то стили двух параграфов могут не совпадать, даже если вышеописанные проверки утверждают обратное.


WebKit и Blink в таких ситуациях сдаются и не используют общий кеш стилей. И чем больше сайтов используют эти современные селекторы, тем менее становится польза от такой оптимизации, поэтому команда Blink недавно удалила ее совсем. Но оказывается, что есть возможность поспеть за всеми этими обновлениями и с общим кешем стилей.


В Quantum CSS мы собираем все те странные селекторы и проверяем, применяются ли они к узлу DOM. Потом мы сохраняем результат этой проверки в виде единиц и нолей для каждого такого селектора. Если два элемента имеют идентичный набор единиц и нолей — мы знаем, что они точно совпадают.


image


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


image


Вывод


Это первый крупный трансфер технологии из Servo в Firefox. Мы многому научились, о том, как вносить современный, высокопроизводительный код на Rust в ядро Firefox.


Мы очень рады, что большой кусок Project Quantum готов для бета-использования. Будем благодарны, если Вы попробуете его и, в случае ошибок, сообщите о них.


О Лин Кларк


Лин — инженер в команде Mozilla Developer Relations. Она работает с JavaScript, WebAssembly, Rust и Servo. А также рисует code cartoons.

Комментарии (15)


  1. End1
    30.08.2017 11:42

    … а есть какие-нибудь программы или ресурсы для проверки скорости с отключённой этой опцией layout.css.servo.enabled и с включённой?


    1. Fedcomp
      30.08.2017 12:23

      В хроме есть профилировщик загрузки, вроде показывает в том числе сколько он CSS обрабатывает. Я думаю в firefox есть нечто подобное?


  1. azymohliad Автор
    30.08.2017 12:36
    +2

    В Firefox тоже есть: Shift+F5, или F12 -> Performance.
    А еще я вот нагуглил простенький CSS бенчмарк. У меня после нескольких итерацый результат с включеным Quantum CSS в диапазоне 260-380 мс, с выключеным 280-750 мс.


    1. azymohliad Автор
      30.08.2017 12:41


    1. Firefoxic
      01.09.2017 18:24

      Простите, а можно уточнить, что именно в том бенчмарке нужно сделать. Как я понял нужно скачать, иначе будут неверные результаты. Что-то там с хромом и сафари оно не очень дружит, ну да это сейчас и не важно. Вот открыл я файл с этим бенчмарком. Сверху (перед предупреждениями) мне пишет несколько десятков миллисекунд (варьируется от 46 до 78 ms). При нажатии на кнопку «Get all DIV's» вылетает алерт с миллисекундами (от 5 до 10 ms).
      Я что-то делаю не так, раз не вижу сотен миллисекунд?


      1. azymohliad Автор
        01.09.2017 19:10

        Да, все верно (по крайней мере, я делал так само). Просто я тестировал на очень старом железе (Core 2 Duo T6670), поэтому у меня сотни. Сейчас дома на i5-7200U (Arch Linux, Firefox Nightly 57.0a1) пробую: вверху пишет от 50 до 130 мс, Get all DIV's — от 10 до 15 мс.
        Что именно этот бенчмарк тестирует, что конкретно означают эти миллисекунды — я сам не знаю, это первое что мне удалось нагуглить. Прошу извинения, если посоветовал что-то неадекватное.


        1. Firefoxic
          01.09.2017 20:29

          Думаю адекватное, но судя по уменьшению количества ms (всё сильно ускоряется в последнее время) и судя по коду бенчмарка — уже довольно сильно устаревшее. Видимо сейчас нужно уже гораздо сильнее нагружать и чем-то более изощрённым, чтобы действительно разницу видно было. И скорее всего бенчмарк не зависящий от железа сделать практически невозможно. У меня, кстати, тоже Arch на MacBook Pro Intel Core i7-4850HQ @ 8x 3.5GHz — отсюда и десятки ms, а не сотни. Я думал, что это я чего не то делаю или не туда смотрю.


  1. slonopotamus
    30.08.2017 21:59

    А насколько оно «super fast» в результате?


    1. azymohliad Автор
      31.08.2017 04:34

      Судя по бенчмарку выше, на моем древнем Core 2 Duo T6670 ускорение в лучшем случае до 2х раз (по сравнению со старым движком). Дома еще попробую на 4х-ядерном i5. Как на счет памяти — не знаю.


  1. isnofreedom
    31.08.2017 04:24

    Так значит уже скоро в stable лисе появится? Есть инфа?


    1. azymohliad Автор
      31.08.2017 04:25

      В 57-й планируют, то есть в ноябре.


      1. azymohliad Автор
        01.09.2017 19:25

        Извиняюсь за дезинформацию. Видел где-то, что в 57-й планируют включить по умолчанию. Но не знал, что в стабильной 55-й уже добавили опцию. Добавил примечание в статью.


    1. Crandel
      31.08.2017 09:34
      +2

      в убунте в 55 версии есть такая настройка в "about:config", включил и заметно быстрее стало все работать на і5 процессоре, когда DOM на раст переведут, возможно будет так же плавно, как и в хроме.


      1. ZoomLS
        01.09.2017 17:27

        Действительно. Всё заметно быстрее стало.


    1. fedorro
      31.08.2017 13:49
      +1

      macOS и Windows, в последних стабильных тоже присутствует, но отключена по умолчанию. По ощущениям стало действительно быстрее.