19 KiB
План улучшения архитектуры Glint Runtime
Цель
Обеспечить плоский график производительности: рендеринг 1000 строк байткода должен занимать ~0.04 секунды сегодня и столько же через годы, независимо от роста количества компонентов, стилей и страниц. Время обработки события должно быть пропорционально размеру изменения, а не размеру всей системы.
Фаза 0: Бенчмарки и профилирование
Цель: зафиксировать текущие метрики, чтобы объективно оценивать прогресс.
- 0.1 Добавить Criterion в
Cargo.toml - 0.2 Написать бенчмарки для:
evaluate_vdom()наdesktop.glbcmatching_rules()— 10, 100, 1000 правилComputedStyle::compute()— пустой, 5, 20 свойствresolve_string()— без$, с 1$var, с 3$varRheiContext::eval_expr()— простое выражение, сложноеRheiContext::execute_action()— короткий скриптElement::clone()— замерить копирование глубокого дерева
- 0.3 Запустить
perf record/flamegraph-rsна горячем пути:cargo run -- run desktop.glbc+ интерактив- Выявить фактический bottleneck (гипотеза: style matching, string parsing)
- 0.4 Записать baseline в
BENCHMARKS.mdилиREADME.md
Файлы: новый benches/bench.rs, Cargo.toml
Фаза 1: Типизированные значения (Value enum)
Цель: устранить постоянный round-trip через строки (parse/format на каждое свойство).
Текущая проблема: HashMap<String, String> для переменных. Каждый SliderChanged →
format!(...), потом rhei.rs:139-142 — три парсинга подряд (i64, f64, bool).
- 1.1 Определить
Valueenum вinterpreter/types.rs:#[derive(Clone, Debug)] pub enum Value { Str(CompactStr), Int(i64), Float(f64), Bool(bool), Array(Vec<Value>), None, } - 1.2 Заменить
HashMap<String, String>наHashMap<String, Value>:Document::variablesGlintApp::update()— входящие значения
- 1.3 Переписать
str_to_dyn()иdyn_to_str()вrhei.rs:- Убрать парсинг, конвертировать напрямую
Value ↔ Dynamic
- Убрать парсинг, конвертировать напрямую
- 1.4 Переписать
resolve_string():- Если значение уже
Int/Float— не парсить, форматировать один раз
- Если значение уже
- 1.5 Переписать
evaluate_condition():- Числовые сравнения без парсинга строк
resolve_string()→ преобразование к Value
- 1.6 Переписать
is_truthy(): работа с Value напрямую - 1.7 Обновить
renderer.rs:SliderChanged,ToggleChanged,InputChanged— приниматьValue, не строку- Парсинг slider/toggle/input значений — один раз, а не на каждый чих
- 1.8 Обновить
ComputedStyle::compute()иparse_*()функции:- Принимать
&Valueгде возможно, а не&str parse_color,parse_size,parse_length— работа с Value
- Принимать
- 1.9 Написать тесты для Value: конверсии, сравнения, форматирование
Изменяемые файлы: interpreter/types.rs, interpreter/mod.rs, interpreter/rhei.rs,
interpreter/style.rs, app.rs, renderer.rs
Фаза 2: Интернирование строк (String Interning)
Цель: ускорить сравнение строк (ключи свойств, названия типов, классы).
Заменить million == "padding-top" на O(1) сравнение ID.
- 2.1 Выбрать библиотеку intern-строк:
lasso(thread-safe, производительный) илиstrena(лёгкая)- Или самодельный
StringArenaсHashMap<&str, usize>
- 2.2 Заменить
&'a strнаInternedStr(newtype вокругu32/usize):Element::type_name- Ключи в
Element::properties - Селекторы, имена классов, ID
- 2.3
lookup()вstyle.rs:- Сравнение ключей через ID вместо
==
- Сравнение ключей через ID вместо
- 2.4
CompoundSelector::matches_element():- Сравнение
tag,id,classesчерез internd-строки
- Сравнение
- 2.5 Все
HashMap<String, String>где ключи повторяются →HashMap<InternedStr, Value>
Изменяемые файлы: interpreter/types.rs, interpreter/style.rs, interpreter/mod.rs,
renderer.rs (много el.type_name == "Button")
Фаза 3: Граф зависимостей (Reactive Dependency Tracking)
Цель: вместо полного evaluate_vdom() на каждое событие — пересчитывать только
элементы, которые зависят от изменившейся переменной. Это главный этап для плоского графика.
Текущая проблема: app.rs:44-51 — на любое событие полный VDOM rebuild. Слайдер
меняет volume_level, но пересчитываются все 500 элементов.
- 3.1 Создать
interpreter/reactive.rs:pub struct ReactiveTracker { subscribers: HashMap<InternedStr, HashSet<ElementId>>, dependencies: HashMap<ElementId, HashSet<InternedStr>>, dirty_set: HashSet<ElementId>, } - 3.2 Анализ зависимостей при загрузке шаблона:
- Сканировать
$varв свойствах → регистрироватьElementId → var - Сканировать
!rhei:{expr}→ извлекать имена переменных через Rhai parser @if condition→ зависимости от переменных в condition@each source→ зависимости от переменных в source- Результат: каждый элемент знает, от каких переменных зависит
- Сканировать
- 3.3 Изменить
GlintApp::update():fn update(&mut self, msg: Message) { let affected = self.tracker.on_variable_changed(name, new_val); self.vdom_roots = Interpreter::evaluate_vdom_incr( &self.doc.roots, &affected, &self.tracker, ... ); } - 3.4
evaluate_vdom_incr():- Для элементов не в
affected— возвращаем кэшированный VDOM - Для элементов в
affected— пересчитываем и каскадно помечаем детей @if— если условие не изменилось — не пересчитываем ветку@each— если source не изменился — не пересчитываем
- Для элементов не в
- 3.5 Инвалидация стилей:
- При изменении переменной, меняющей
classилиidэлемента → сброс кэша стилей - Иначе стили не пересчитываем
- При изменении переменной, меняющей
- 3.6 Кэш VDOM:
HashMap<ElementId, Arc<VNode>>— не клонируем, разделяем памятьArc::make_mut()при изменении (copy-on-write)
- 3.7 Написать тест:
- Сценарий: 1000 элементов, 1 зависимость → после изменения пересчитывается 1 элемент
- Проверить, что dirty_set корректен
Изменяемые файлы: новый interpreter/reactive.rs, interpreter/mod.rs, interpreter/types.rs,
app.rs, main.rs
Фаза 4: Индексированный матчинг стилей (Style Index)
Цель: заменить O(N×M) на O(K) где K — число релевантных правил для элемента (1-5, не 500).
Текущая проблема: style.rs:487-505 — каждый элемент проверяется против ВСЕХ правил.
100 элементов × 500 правил = 50 000 проверок на событие.
- 4.1 Построить индекс стилей при загрузке
Document:pub struct StyleIndex { by_tag: HashMap<InternedStr, Vec<RuleId>>, by_class: HashMap<InternedStr, Vec<RuleId>>, by_id: HashMap<InternedStr, Vec<RuleId>>, by_tag_class: HashMap<(InternedStr, InternedStr), Vec<RuleId>>, by_tag_id: HashMap<(InternedStr, InternedStr), Vec<RuleId>>, complex_rules: Vec<(ComplexSelector, RuleId)>, rules: Vec<StyleRule>, } - 4.2
matching_rules()→query_index():- Для элемента
Button.primary#submit:by_tag["Button"]→ [1, 5, 12]by_class["primary"]→ [3, 5, 7]by_id["submit"]→ [5]- Пересечение → [5]
- Проверить 1 сложное правило
- Итого: 3 проверки вместо 500
- Для элемента
- 4.3 Кэш совпадений:
HashMap<ElementId, (Vec<RuleId>, u64)>— инвалидируется по эпохе- epoch увеличивается при перезагрузке стилей
- Псевдоклассы (
:hover,:active) — кэш с ключом(ElementId, PseudoClass)
- 4.4
ComputedStyle::compute():- Убрать сортировку по specificity на каждый вызов
- Правила уже отсортированы в индексе
lookup()— проход по нескольким правилам вместо прохода поmatched_sheets
- 4.5 Миграция
matching_pseudo_rules():- Использовать индекс + кэш
- Вызывается из
renderer.rsдля hover/active — тысячи раз в секунду - Кэш на
(ElementId, pseudo)с инвалидацией
Изменяемые файлы: interpreter/style.rs, interpreter/mod.rs, interpreter/types.rs,
renderer.rs, main.rs
Фаза 5: Аренная аллокация и VNode (Flat VDOM)
Цель: устранить глубокое клонирование Element и reduce аллокаций на каждый кадр.
Текущая проблема: Element содержит Vec<Element> — рекурсивное клонирование.
child.clone() в @if (mod.rs:347) копирует поддеревья целиком.
- 5.1 Создать плоское представление VDOM:
struct VNode<'a> { id: NodeId, type_name: InternedStr, properties: Range<PropertyIdx>, children_range: Range<NodeIdx>, computed_style: ComputedStyle, } struct FlatVDom<'a> { nodes: Vec<VNode<'a>>, properties: Vec<(InternedStr, Value)>, arena: Bump, } - 5.2 Добавить
bumpaloили написатьBumpAlloc:- Все строковые данные живут в арене
- Очистка арены одним махом между кадрами
- 5.3
evaluate_vdom()→ возвращаетFlatVDom<'arena>вместоVec<Element>:- Вместо
push_prop("text", val.clone())— allocate в арене - Вместо
Element::new(el.type_name)— выделить VNode вVec<VNode>
- Вместо
- 5.4 Structural sharing:
- Ветки
@eachс одинаковыми телами разделяютVNodeчерезArc<VNode> Arc::make_mut()при изменении
- Ветки
- 5.5
render_element()→ работает с&VNode:get_prop()— lookup в плоском массиве свойствchildren— итерация поchildren_range
- 5.6 Удалить
#[derive(Clone)]изElement(или оставить для совместимости):- В горячем пути clone не используется
- 5.7 Тест: проверить, что аллокаций на кадр стало < 10 (было ~1000+)
Изменяемые файлы: interpreter/types.rs, interpreter/mod.rs, renderer.rs,
interpreter/style.rs, app.rs
Фаза 6: Кэширование Rhai AST
Цель: убрать компиляцию Rhai скриптов при каждом выполнении.
Текущая проблема: rhei.rs:112 — engine.compile(script) при каждом execute_action().
Скрипты в @on:click { ... } компилируются каждый клик.
- 6.1 Собрать все Rhai блоки при загрузке
Document:- Из
rhei_scripts(init-скрипты) - Из
__on:*свойств (обработчики событий) - Из
!rhei:выражений (компилируем, но не выполняем)
- Из
- 6.2
RheiContext:pub struct RheiContext { engine: Engine, init_ast: AST, action_cache: HashMap<InternedStr, AST>, expr_cache: HashMap<InternedStr, AST>, } - 6.3
execute_action():self.action_cache.get(script)вместоengine.compile(script)- Если нет — компилируем и кэшируем
- 6.4 Очистка кэша:
- Только при перезагрузке документа
- Или
LruCacheесли скриптов слишком много
- 6.5 Тест: выполнить 1000 раз один и тот же action — время не должно расти
Изменяемые файлы: interpreter/rhei.rs, interpreter/mod.rs, main.rs
Фаза 7: Параллелизм (Rayon)
Цель: распараллелить style matching и независимые ветки VDOM.
Текущая проблема: всё выполняется последовательно, хотя style matching для разных элементов — embarrassingly parallel.
- 7.1 Добавить
rayonвCargo.toml(feature gate:parallel) - 7.2 Параллельный style matching:
matching_rules_batch(elements: &[Element]) -> Vec<Vec<&HashMap>>elements.par_iter().map(...)
- 7.3 Параллельный
@each:- Итерации
@eachнезависимы items.par_iter().flat_map(|item| evaluate_vdom(body, ...))- Feature gate: только для больших списков (> N элементов)
- Итерации
- 7.4 Параллельный
ComputedStyle::compute_batch():- Векторизованный compute для нескольких элементов
- 7.5 Тест:
@eachс 1000 итераций → ускорение ~4x на 8 ядрах
Изменяемые файлы: Cargo.toml, interpreter/style.rs, interpreter/mod.rs
Фаза 8: Мемоизация ComputedStyle::compute()
Цель: не пересчитывать стили для элементов, чьи свойства не изменились.
Текущая проблема: каждый кадр ComputedStyle::compute() парсит все ~40 полей
через lookup().
- 8.1 Ввести хэш
(inline_properties_hash, stylesheet_epoch, element_id) → ComputedStyle - 8.2 Кэш:
HashMap<u64, ComputedStyle>+ LRU eviction - 8.3 Инвалидация:
stylesheet_epoch— счётчик, увеличивается при изменении стилейinline_properties_hash— хэш от значений свойств элемента
- 8.4 Проверить hit rate на реальном UI: > 95%+
Изменяемые файлы: interpreter/style.rs
Фаза 9: Инкрементальный рендеринг (keyed widgets)
Цель: дать Iced'у возможность эффективно диффить виджеты, а не пересоздавать их.
Текущая проблема: view() создаёт полностью новые Iced-виджеты каждый кадр.
- 9.1 Ввести стабильные ID для каждого элемента VDOM
- 9.2
render_element():- Присваивать
iced::id()на основеElementId - Iced использует ID для сохранения состояния виджетов между кадрами
- Присваивать
- 9.3 Убирать из
view()только изменившиеся элементы:- Если
VNode::hash == prev_hash— вернуть кэшированныйiced::Element - Кэш:
HashMap<ElementId, Arc<iced::Element>>
- Если
Изменяемые файлы: renderer.rs, interpreter/types.rs
Фаза 10: Мониторинг и автоматические бенчмарки
Цель: не допустить регрессий в будущем.
- 10.1 Добавить
cargo benchв CI - 10.2 Пороговые проверки:
evaluate_vdomна desktop.glbc < 5msmatching_rules(100 правил) < 10μsComputedStyle::compute< 1μs
- 10.3 Логирование производительности:
--perfфлаг дляglint run- Печатать:
VDOM: 1.2ms | Style: 0.3ms | Render: 2.1ms | Total: 3.6ms
- 10.4 Alert если total > 16ms (frame budget для 60 FPS)
Изменяемые файлы: Cargo.toml, новый src/perf.rs, cli.rs, .github/workflows/ci.yml
Сводная таблица влияния
| Фаза | Описание | Ускорение | Плоский график |
|---|---|---|---|
| 0 | Бенчмарки | — | — |
| 1 | Value enum | 2-3× | Нет |
| 2 | String interning | 1.5-2× | Нет |
| 3 | Reactive tracker | 10-50× | Да (ключевое) |
| 4 | Style index | 5-10× (style) | Да |
| 5 | Arena + Flat VDOM | 2-3× | Частично |
| 6 | Rhai AST cache | 2-5× (click) | Да |
| 7 | Parallelism | 2-4× | Нет |
| 8 | Style memoization | 2-3× | Да |
| 9 | Keyed widgets | 1.5-2× (render) | Частично |
| 10 | CI benchmarks | — (контроль) | — |
Суммарно: до 100× на горячих путях.
Рекомендованный порядок имплементации
- Фаза 0 — профилирование (без этого нельзя)
- Фаза 1 + 2 — Value enum + String interning (фундамент)
- Фаза 3 — Reactive tracker (сердце архитектуры)
- Фаза 4 — Style index
- Фаза 5 + 8 — Arena + Memoization
- Фаза 6 — Rhai cache
- Фаза 7 — Parallelism
- Фаза 9 — Keyed widgets
- Фаза 10 — CI benchmarks