В последней версии Amp подсветка синтаксиса была переработана. Этот пост посвящен редизайну, связанному с производительностью, который появился в версии 0.5.

Задний план

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

При визуализации буфера строки в прокручиваемой области (A) игнорируются, а строки в видимой области (B) отображаются на экране. Несмотря на то, что мы не визуализируем линии в прокручиваемой области, нам все равно необходимо их проанализировать, поскольку они устанавливают состояние перед переходом в видимую область. После визуализации видимой области процесс останавливается; строки в области ожидания (C) вообще не обрабатываются.

К сожалению, по мере увеличения прокручиваемой области все замедляется. Если вы прокрутите файл из 4000 строк до конца, для рендеринга только последних 60 строк потребуется проанализировать 3940 строк с прокруткой только для подготовки состояния анализатора. Также важно помнить, что буфер повторно отображается при каждом нажатии клавиши. Эта неэффективность оказывает ощутимое влияние: на Macbook Pro 2015 года задержка становится заметной около 2 тыс. Строк; при 7k это совершенно непригодно.

К счастью, есть интересное свойство форматов данных и языков, которое может быть не сразу очевидным: при редактировании можно переклассифицировать контент только в месте редактирования или после него. Мы будем называть это «прямое аннулирование» для дальнейшего использования.

Начнем с простого примера:

let buffer = Buffer::new();
buffer.insert(content);

Давайте добавим цитату в начало последней строки:

let buffer = Buffer::new();
"buffer.insert(content);

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

Так работает процесс рендеринга Amp начиная с версии 0.5. Мы возобновляем синтаксический анализатор непосредственно перед видимой областью и пропускаем большую часть работы.

Детали реализации

Конечно, дьявол всегда кроется в деталях, и «просто кешируйте состояние парсера» оказалось совсем не так. Во-первых, первоначальный дизайн парсера не работал:

let data = String::from("buffer content");
let tokens = TokenSet::new(data);
for token in tokens.iter() {
    // render token to the screen
}

Выше есть тонкая взаимосвязь данных, которая может быть неочевидной. Экземпляр TokenSet, который действует как синтаксический анализатор, становится владельцем data. Вызов iter() создает итератор, который затем заимствует и выдает срезы data (если вам интересно, зачем нам для этого нужны два типа, я тоже об этом писал).

Такой дизайн идиоматичен в Rust. Тип String стандартной библиотеки имеет аналогичный chars() метод перебора отдельных символов.

Вот проблема: все эти типы неразрывно связаны друг с другом, и если мы хотим кэшировать состояние синтаксического анализа, мы также застреваем в кешировании данных, на которые они указывают. Это почти комично: десятки, возможно, сотни копий буфера хранятся в памяти только для поддержки кеширования. Хуже того: это также не позволяет нам повторно использовать кешированный парсер с измененными данными. TL; DR: rm -rf.

В новом дизайне состояние анализатора и данные буфера хранятся отдельно. Парсер не владеет data; он заимствует его по одной строке за раз и дает смещения, указывающие на диапазоны данных, а не на сами данные. Это сокращает объем памяти анализатора и позволяет возобновить его с измененным содержимым.

Кэш

Заполнить кеш достаточно просто: представление Amp управляет коллекцией кешей рендеринга для каждого буфера. Когда буфер визуализируется, его состояние парсера кэшируется каждые 100 строк.

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

Неудивительно, что признание недействительности более сложным. Когда буфер модифицируется, любые кэшированные состояния во время или после модификации должны быть очищены (помните, что свойство «прямой недействительности» мы упоминали ранее?). Хитрость в том, что буферы изменяются в множестве разных мест в Amp. Чтобы справиться с этим, нам нужно копнуть немного глубже: мы будем отслеживать это событие в самом типе Buffer.

Дело в том, что Amp не определяет тип Buffer. Все общие, независимые от редактора типы были построены как отдельный ящик под названием Scribe. Было бы ошибкой засыпать этот ящик кучей мусора, связанного с аннулированием кеша, специфичным для Amp; мы хотим, чтобы это было как можно более тонким. Все, что нам действительно нужно, - это ловушка изменения; мы можем позволить вызывающей стороне решить, как с этим справиться.

Учитывая это, операции редактирования Scribe были обновлены, чтобы запускать необязательно настроенное change_callback закрытие. Вы можете создать закрытие, чтобы делать все, что вы хотите, в ответ на событие. Amp настраивает один с общим доступом к кешу рендеринга, охраняемым RefCell. Каждый раз, когда выполняется редактирование, точки кэша рендеринга за пределами местоположения становятся недействительными с помощью обратного вызова изменения.

Правильно. Теперь, когда реализация на месте, давайте посмотрим на цифры.

Измерение воздействия

Вот контрольные показатели процесса рендеринга до реализации кэширования:

рендеринг прокручиваемого буфера (127 строк)
время: [7,1372 мс 7,1492 мс 7,1728 мс]

рендеринг прокручиваемого буфера (6916 строк)
время: [453,05 мс 453,41 мс 453,83 мс]

А вот тесты, сделанные после:

прокрутка буфера отрисовки (127 строк)
время: [1,7706 мс 1,7723 мс 1,7754 мс]
изменение: [-75,698% -75,437 % -75,209%] (p = 0,00 ‹0,05)
Производительность улучшилась.

прокрутка буфера отрисовки (6916 строк)
время: [2,0095 мс 2,0107 мс 2,0121 мс]
изменение: [-99,558% -99,557 % -99,555%] (p = 0,00 ‹0,05)
Производительность улучшилась.

Приятно, даже при небольшом увеличении производительности буфера! Что еще более важно, производительность остается стабильной на обоих крайних значениях (в пределах 0,3 мс); Размер содержимого прокручиваемого буфера очень мало влияет на производительность рендеринга.

Ну, в основном: первый рендер еще должен заполнить кеш. На практике это незначительно (см. Начальный тест для получения цифр) и нечасто; редактировать большие файлы намного приятнее!

Хотите попробовать? Более подробную информацию об усилителе можно найти здесь.