Files
Glint-Runtime/ARCHITECTURE-IMPROVEMENT-PLAN.md

19 KiB
Raw Blame History

План улучшения архитектуры Glint Runtime

Цель

Обеспечить плоский график производительности: рендеринг 1000 строк байткода должен занимать ~0.04 секунды сегодня и столько же через годы, независимо от роста количества компонентов, стилей и страниц. Время обработки события должно быть пропорционально размеру изменения, а не размеру всей системы.


Фаза 0: Бенчмарки и профилирование

Цель: зафиксировать текущие метрики, чтобы объективно оценивать прогресс.

  • 0.1 Добавить Criterion в Cargo.toml
  • 0.2 Написать бенчмарки для:
    • evaluate_vdom() на desktop.glbc
    • matching_rules() — 10, 100, 1000 правил
    • ComputedStyle::compute() — пустой, 5, 20 свойств
    • resolve_string() — без $, с 1 $var, с 3 $var
    • RheiContext::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> для переменных. Каждый SliderChangedformat!(...), потом rhei.rs:139-142 — три парсинга подряд (i64, f64, bool).

  • 1.1 Определить Value enum в 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::variables
    • GlintApp::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 вместо ==
  • 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:
      1. by_tag["Button"] → [1, 5, 12]
      2. by_class["primary"] → [3, 5, 7]
      3. by_id["submit"] → [5]
      4. Пересечение → [5]
      5. Проверить 1 сложное правило
      6. Итого: 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:112engine.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 < 5ms
    • matching_rules (100 правил) < 10μs
    • ComputedStyle::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× на горячих путях.


Рекомендованный порядок имплементации

  1. Фаза 0 — профилирование (без этого нельзя)
  2. Фаза 1 + 2 — Value enum + String interning (фундамент)
  3. Фаза 3 — Reactive tracker (сердце архитектуры)
  4. Фаза 4 — Style index
  5. Фаза 5 + 8 — Arena + Memoization
  6. Фаза 6 — Rhai cache
  7. Фаза 7 — Parallelism
  8. Фаза 9 — Keyed widgets
  9. Фаза 10 — CI benchmarks