Парсим css: performance tips & tricks

Post on 15-Jan-2017

340 Views

Category:

Technology

2 Downloads

Preview:

Click to see full reader

TRANSCRIPT

Парсим CSS performance tips & tricks

Роман Дворнов Avito

Москва, сентябрь 2016

Руководитель фронтенда в Avito

Основной интерес – SPA

Open source:basis.js, CSSO, component-inspector, csstree и другие

За любую движуху, кроме голодовки ;)

Парсим CSS (зачем? почему? как дальше жить?)

3

tinyurl.com/csstree-intro

Начало истории (доклад)

CSSTree

CSSTree – самый быстрый и детальный парсер CSS

5

Как я до этого докатился?

Чуть меньше года назад я стал мейнтейнером CSSO

(минификатор CSS)

7

github.com/css/csso

CSSO работал на основе парсера Gonzales

8

github.com/css/gonzales

Проблемы• Не развивается с 2013

• Неудобный формат AST, местами странный

• Много ошибок

• Запутанная и сложная кодовая база

• Медленный, потребляет много памяти, GC9

Парсер – последнее, что я собирался трогать…

10

Альтернатива?

Парсеров CSS на JavaScript достаточно много

12

Частые проблемы• Заброшены и не развиваются

• Устарели (не поддерживают новое в CSS)

• Содержат ошибки

• Неудачная структура

• Медленные13

Наилучшим выбором может быть парсер из PostCSS

14

postcss.org

Плюсы PostCSS• Развивается и поддерживается

• Хорошо справляется с синтаксисом CSS и даже будущим + tolerant mode

• Сохраняет информацию о форматировании

• Удобное API для работы с AST

• Быстрый15

Основная проблема: селекторы и значения свойств остаются не разобранными

(хранятся в виде строки)

16

Это вынуждает разработчиков

• Использовать костыли

• Писать свои парсеры

• Использовать дополнительные парсеры:postcss-selector-parser postcss-value-parser

17

Переход на PostCSS означал написание собственных парсеров селекторов и свойств, что не сильно отличается от

написания парсера целиком

18

Регулярный рефакторинг приводит к тому, что парсер может быть полностью переписан

(это норма 😳)

19

Парсер выделен в отдельный проект

github.com/csstree/csstree

20

Скорость

CSSO – история ускорения (в том числе про парсер)

22

tinyurl.com/csso-speedup

В предыдущих сериях (доклад)

После выступления разогнал парсер еще :)

23

* Вдохновленный общением с Вячеславом @mraleph Егоровым

24

CSSTree: 24 msMensch: 31 msCSSOM: 36 msPostCSS: 38 msRework: 81 msPostCSS Full: 100 msGonzales: 175 msStylecow: 176 msGonzales PE: 214 msParserLib: 414 ms

bootstrap.css v3.3.7 (146Kb)

github.com/postcss/benchmark

Не детальное AST

Детальное AST

PostCSS Full = + postcss-selector-parser

+ postcss-value-parser

Epic fail как выяснилось позже, я вынес

не ту версию парсера

25

😱github.com/csstree/csstree/commit/57568c758195153e337f6154874c3bc42dd04450

26

CSSTree: 24 msMensch: 31 msCSSOM: 36 msPostCSS: 38 msRework: 81 msPostCSS Full: 100 msGonzales: 175 msStylecow: 176 msGonzales PE: 214 msParserLib: 414 ms

bootstrap.css v3.3.7 (146Kb)

github.com/postcss/benchmark

На FrontTalks был показан результат

до разгона

13 ms

Парсеры: курс молодого бойца

Основные шаги

• Токенизация

• Построение дерева (лексер)

28

Токенизация

30

• whitespaces – [ \n\r\t\f]+ • keyword – [a-zA-aZ…]+ • number – [0-9]+ • string – "string" или 'string' • comment – /* comment */ • punctuation – [;,.#\{\}\[\]\(\)…]

Разбиение текста на токены

31

.foo { width: 10px;}

[ '.', 'foo', ' ', '{', '\n ', 'width', ':', ' ', '10', 'px', ';', '\n', '}']

Нужна дополнительная информация о токене: тип и локация

32

На этапе токенизации мы знаем тип и позицию,

считать их после – дорого

33

.foo { width: 10px;}

[ { type: 'FullStop', value: '.', offset: 0, line: 1, column: 1 }, …]

Сборка

35

function getSelector() { var selector = { type: 'Selector', sequence: [] };

// main loop

return selector;}

Сборка

36

for (;currentToken < tokenCount; currentToken++) { switch (tokens[currentToken]) { case TokenType.Hash: // # selector.sequence.push(getId()); break; case TokenType.FullStop: // . selector.sequence.push(getClass()); break; … }

Main loop

37

{ "type": "StyleSheet", "rules": [{ "type": "Atrule", "name": "import", "expression": { "type": "AtruleExpression", "sequence": [ ... ] }, "block": null }]}

Результат

История ускорения #2

39

[ { type: 'FullStop', value: '.', offset: 0, line: 1, column: 1 }, …]

Стоимость токена: 24 + 5 * 4 + массив = min 50 bytes per token

В нашем проекте ~1Mb CSS 254 062 токена

= min 12.7 Mb

Прелюдия: меняем подход

Посчитать все токены, а потом из них собирать AST – проще,

но ведет к лишним затратам памяти и медленней

41

Scanner (ленивый токенайзер)

42

43

scanner.token // текущий токен или nullscanner.next() // переход к следующему токенуscanner.lookup(N) // заглядывание вперед, возвращает // токен на N-ой позиции от текущей

Основное API

44

• lookup(N) заполняет буфер токенов до позиции N, если еще не заполнен, возвращает N-1 токен из буфера

• next()делает shift из lookup буфера, если он не пустой, либо читает новый токен

Создается столько же токенов, но нужно меньше памяти в один

момент времени

45

Проблема: заставляем GC плакать работать

46

Уменьшаем стоимость токенов: «многоходовочка»

48

[ { type: 'FullStop', value: '.', offset: 0, line: 1, column: 1 }, …]

Строковые обозначения удобны при отладке, но они не выходят за рамки

сканера и можно заменить на числа

49

[ { type: FULLSTOP, value: '.', offset: 0, line: 1, column: 1 }, …]

…// '.'.charCodeAt(0)var FULLSTOP = 46;…

50

[ { type: 46, value: '.', offset: 0, line: 1, column: 1 }, …]

51

[ { type: 46, value: '.', offset: 0, line: 1, column: 1 }, …]

Можно не хранить подстроку – это особенно

расточительно для одиночных символов;

к тому же многие многие конструкции собираются из нескольких токенов – эффективнее брать одну

подстроку вместо конкатенации нескольких

52

[ { type: 46, value: '.', offset: 0, line: 1, column: 1 }, …]

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

53

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Look, Ma! No strings just numbers!

54

Да не просто Array, а TypedArray

Массив объектов

Массивы чисел

Array vs. TypedArray• Не могут содержать дырок

• В теории быстрее (т.к. меньше проверок)

• Хранятся вне heap (если достаточно большие)

• Предзаполнены нулями

55

56

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 4 4 4 4

17 per token(кол-во токенов) 254 062 x 17 = 4.3Mb

4.3Mb vs. 12.7Mb (min)

57

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

а мы не знаем сколько токенов будет

58

59

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 4 4 4 4

17 per token(кол-во символов) 983 085 x 17 = 16.7Mb

16.7Mb vs. 12.7Mb (min)

60

16.7Mb vs. 12.7Mb (min)

60

Не повод сдаваться, давайте немного

подумаем…

61

start = [ 0, 5, 6, 7, 9, 11, …, 35 ]

end = [ 5, 6, 7, 9, 11, 12, …, 36 ]

61

start = [ 0, 5, 6, 7, 9, 11, …, 35 ]

end = [ 5, 6, 7, 9, 11, 12, …, 36 ]

62

start = [ 0, 5, 6, 7, 9, 11, …, 35 ]

end = [ 5, 6, 7, 9, 11, 12, …, 36 ]

offset = [ 0, 5, 6, 7, 9, 11, …, 35, 36 ] start = offset[i] end = offset[i + 1]

+

=

63

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 4 4 4 4

13 per token983 085 x 13 = 12.7Mb

64

a { top: 0;}

lines = [ 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3]

columns = [ 1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1]

lines & columns

64

a { top: 0;}

lines = [ 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3]

columns = [ 1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1]

lines & columns

65

line = lines[offset];

column = offset - lines.lastIndexOf(line - 1, offset);

lines & columns

65

line = lines[offset];

column = offset - lines.lastIndexOf(line - 1, offset);

lines & columns

Ок для коротких строк, нужно кешировать для

длинных

66

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 4 4 4 4

9 per token983 085 x 9 = 8.8Mb

67

8.8Mb vs. 12.7Mb (min)

Меньше операций со строками

«Убийцы» производительности*• RegExp • Конкатенация строк • toLowerCase/toUpperCase • substr/substring • …

69

* Засоряют GC и он все портит

«Убийцы» производительности*• RegExp • Конкатенация строк • toLowerCase/toUpperCase • substr/substring • …

70

Без этого никак, но от остального можно избавиться

* Засоряют GC и он все портит

71

var start = scanner.tokenStart;

scanner.next();

scanner.next();

return source.substr(start, scanner.tokenEnd);

Нет конкатенации!

72

function cmpStr(source, start, end, str) { if (end - start !== str.length) { return false; }

for (var i = start; i < end; i++) { var sourceCode = source.charCodeAt(i); var strCode = str.charCodeAt(i - start);

if (sourceCode !== strCode) { return false; } }

return true;}

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

73

function cmpStr(source, start, end, str) { if (end - start !== str.length) { return false; }

for (var i = start; i < end; i++) { var sourceCode = source.charCodeAt(i); var strCode = str.charCodeAt(i - start);

if (sourceCode !== strCode) { return false; } }

return true;}

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

Быстрое отсечение по длине

74

function cmpStr(source, start, end, str) { if (end - start !== str.length) { return false; }

for (var i = start; i < end; i++) { var sourceCode = source.charCodeAt(i); var strCode = str.charCodeAt(i - start);

if (sourceCode !== strCode) { return false; } }

return true;}

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

Сравниваем код за кодом

Как сравнивать без учета регистра*?

75

* То есть без toLowerCase/toUpperCase

Эвристика• Сравниваем с заранее известными строками (str)

• Заранее заданные строки всегда в нижнем регистре и содержат только латинские буквы

• Читал я как то в твиттере…

76

Чтобы перевести из верхнего регистра в нижний, нужно выставить 6-й бит в 1

(работает только для латинских букв)

'A' = 01000001'a' = 01100001

'A'.charCodeAt(0) | 32 === 'a'.charCodeAt(0)

77

78

function cmpStr(source, start, end, str) { … for (var i = start; i < end; i++) { … // source[i].toLowerCase() if (sourceCode >= 65 && sourceCode <= 90) { // 'A' .. 'Z' sourceCode = sourceCode | 32; }

if (sourceCode !== strCode) { return false; } } …}

Сравнение строк без учета регистра

Бенефиты• Часто срабатывает быстрое отсечение

• Нет получения подстрок (не давим на GC)

• Нет получения временных строк (результат toLowerCase/toUpperCase)

• Операция сравнения не производит мусор

79

Результаты• RegExp • Конкатенация строк • toLowerCase/toUpperCase • substr/substring

80

Отказываемся от массивов (от слова совсем)

Что не так с массивами• Если растить массив, то происходит копирование памяти + нагрузка на GC

• Мы не можем заранее знать размер массива

82

Решение?

83

Двусвязные списки

84

85

85

AST node AST node AST node AST node

Требует немного больше памяти чем массивы, но…

86

Плюсы• Не вызывает копирование памяти

• Не засоряет GC при построении AST

• Мы получаем next/prev

• Дешевая вставка/удаление

• Лучше для мономорфности87

Всё это и многое другое позволило уменьшить потребление памяти,

нагрузку на GC и ускорить вдвое

88

Но это еще не конец 😋

89

История ускорения #3 неделя после FrontTalks

Общие моменты

• Упрощение структуры AST

• Меньше потребление памяти, переиспользование

• list.map().join() -> цикл + конкатенация

• и по мелочи…

91

И снова про стоимость токенов

93

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 types 4 offsets 4 4 lines 4

9 per token983 085 x 9 = 8.8Mb

lines можно считать не всегда и лениво

94

95

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 types 4 offsets 4 4 lines 4

5 per token983 085 x 5 = 4.9Mb

Действительно ли для offsets нужно 32 бита?

Эвристика: вряд ли кто-то будет парсить CSS больше 16Mb

96

97

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

98

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i]

+

=

99

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i]offsetAndType = [ 16777216, 788529157, … ]

+

=

100

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i]offsetAndType = [ 16777216, 788529157, … ]start = offsetAndType[i] & 0xFFFFFF;type = offsetAndType[i] >> 24;

+

=

101

[ { type: 46, start: 0, end: 1, line: 1, column: 1 }, …]

Uint8Array Uint32Array Uint32Array Uint32Array Uint32Array

1 types 4 offsets 4 4 lines 4

4 per token983 085 x 4 = 3.9Mb

3.9-7.8 Mb vs. 12.7 Mb (min)

102

103

class Scanner { ... next() { var next = this.currentToken + 1;

this.currentToken = next; this.tokenStart = this.tokenEnd; this.tokenEnd = this.offsetAndType[next + 1] & 0xFFFFFF; this.tokenType = this.offsetAndType[next] >> 24; }}

Нужно всего 2 чтения для 3 значений, т.к. конец становится началом

104

class Scanner { ... next() { var next = this.currentToken + 1;

this.currentToken = next; this.tokenStart = this.tokenEnd; this.tokenEnd = this.offsetAndType[next + 1] & 0xFFFFFF; this.tokenType = this.offsetAndType[next] >> 24; }}

Два чтения из массива – как то не круто…

105

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i]start = endend = offsetAndType[i + 1] & 0xFFFFFF;type = offsetAndType[i] >> 24;

105

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i]start = endend = offsetAndType[i + 1] & 0xFFFFFF;type = offsetAndType[i] >> 24;

106

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

Первое смещение всегда ноль

107

offset = [ 0, 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

Сдвигаем влево

108

offset = [ 5, 6, 7, 9, 11, 11, …, 1234 ]

type = [ 1, 47, 47, 4, 4, 47, 5, …, 3 ]

offsetAndType[i] = type[i] << 24 | offset[i + 1]offsetAndType[i] = type[i] << 24 | offset[i]start = endend = offsetAndType[i] & 0xFFFFFF;type = offsetAndType[i] >> 24;

109

class Scanner { ... next() { var next = this.currentToken + 1;

this.currentToken = next; this.tokenStart = this.tokenEnd; this.tokenEnd = this.offsetAndType[next] & 0xFFFFFF; this.tokenType = this.offsetAndType[next] >> 24; }}

Теперь можно в одно чтение

110

class Scanner { ... next() { var next = this.currentToken + 1;

this.currentToken = next; this.tokenStart = this.tokenEnd; next = this.offsetAndType[next]; this.tokenEnd = next & 0xFFFFFF; this.tokenType = next >> 24; }}

-50% чтений (~250k)

👌

Переиспользование

Сканер каждый раз создавал новые массивы на каждый

разбор

112

Сканер каждый раз создавал новые массивы на каждый

разбор

112

Новая стратегия• По дефолту создается буфер в 16Kb

• Создается новый буфер, только если он мал для разбираемого CSS

• Значительный прирост скорости, особенно в сценариях разбора малых фрагментов CSS

113

114

CSSTree: 24 msMensch: 31 msCSSOM: 36 msPostCSS: 38 msRework: 81 msPostCSS Full: 100 msGonzales: 175 msStylecow: 176 msGonzales PE: 214 msParserLib: 414 ms

bootstrap.css v3.3.7 (146Kb)

github.com/postcss/benchmark

13 ms 7 ms

Текущий результат

И это еще не конец… 😋

115

Минутка «рекламы»

CSSTree – не только про скорость

117

Новая фича*: Разбор и матчинг синтаксиса

CSS значений

118

* Пока уникальная среди CSS парсеров

Пример

119

120

csstree.github.io/docs/syntax.html

Документация синтаксиса

121

csstree.github.io/docs/validator.html

Валидатор синтаксиса CSS значений

122

var csstree = require('css-tree');var syntax = csstree.syntax.defaultSyntax;var ast = csstree.parse('… your css …');

csstree.walkDeclarations(ast, function(node) { if (!syntax.match(node.property.name, node.value)) { console.log(syntax.lastMatchError); }});

Свой валидатор в 8 строк

Кое что еще• csstree-validator – npm пакет + консольная команда

• stylelint-csstree-validator – плагин для stylelint

• gulp-csstree – плагин для gulp

• SublimeLinter-contrib-csstree – плагин для Sublime Text

• vscode-csstree – плагин для VS Code

• csstree-validator – плагин для Atom

More is coming…123

Заключение

Хотите чтобы ваш JavaScript работал так же быстро как Си, сделайте его похожим на Си

125

Изучайте алгоритмы, структуры данных, как работают JS-движки и GC –

у вас будет больше вариантов для оптимизаций

126

– К.О.

Доклады по теме• CSSO – история ускорения

tinyurl.com/csso-speedup

• Парсим CSStinyurl.com/csstree-intro

127

github.com/csstree/csstree

128

Нужен ваш фидбек

Роман Дворнов @rdvornov

github.com/lahmatiy rdvornov@gmail.com

Вопросы?

github.com/csstree/csstree

top related