Внутри супер-быстрого CSS-движка: Quantum CSS (aka Stylo)

в 7:38, , рубрики: css, Firefox, Rust, браузеры, перевод

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

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

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

image

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

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

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

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.

Автор: azymohliad

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js