Skip to content

RFC-029a: Кэш модулей и инкрементальная перекомпиляция ​

Резюме ​

Реализуя зарезервированную строку 029a из RFC-029 «Планирование под-RFC»: поверх уже стабилизированного оркестратора определить для компилятора многоуровневую семантику кэширования и инкрементальной перекомпиляции — L1 (внутрипроцессный кэш результатов анализа), L2 (внутрисессионный модульный кэш), L3 (межпроцессный дисковый кэш) — с единым определением ключей, правилами инвалидации и обязательными хуками метрик, чтобы свести воедино четыре ныне разрозненных точки кэширования (аудит #251 P0-6).

Мотивация ​

Основания в хост-системе и границах ​

RFC-029 (принят) явно выносит данную тему за свои рамки и резервирует для неё слот:

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

Под-RFCВозможностьПредусловие
029aКэш модулей и инкрементальная перекомпиляцияСтабильность оркестратора

Предусловие «стабильность оркестратора» выполнено: оркестратор выкачен вместе с RFC-029 и проверен поставками #232/#243/#244/#245.

Текущие проблемы ​

Разрозненные точки кэширования не сообщаются между собой:

СуществующееУровеньФактический потребительПроблема
VALIDATE_CACHE (validate.rs)ВнутрипроцессныйТолько formatterCLI check/run и оркестратор его не используют
Кэш запросов Z3BackendСессия проверкиКонвейер доказательствНет правил инвалидации и бюджетирования (RFC-009a — одна фраза)
DocumentCache (RFC-017)Сессия LSPLSPПолностью изолирован от CLI-стороны компиляции
~/.yaoxiang/cache (RFC-014)ДискЗагрузка пакетовНе связан с компиляцией
Кэш кода (RFC-028)RuntimeJITНе слой времени компиляции

Многовходовое повторение полного конвейера (эмпирически 2026-09-07):

  • В цикле check_files_with_diagnostics для каждого файла создаётся новый Compiler: при проверке 19 файлов уровня библиотек в одном процессе встроенная цепочка std повторно typecheck'ится 19 раз;
  • Файлы тестов runtime-error проходят через два подпроцесса check и run, полный конвейер выполняется дважды;
  • Быстрый путь проверки заимствований fast_path_check при каждой операции записи независимо выполняет полный BFS по графу, без кэширования результата (исходное наблюдение аудита #251 P0-6; в RFC-009a лишь одна фраза: «результат однократного BFS может кэшироваться для повторного использования в рамках одного токена», без проработки механизма).

Анатомия стоимости (метод базовой линии --version): спавн процесса + инициализация CLI ~50 мс; голый check файла ~60 мс (компиляция фронтенда ≈ 10 мс); цепочка std.test ~80 мс (встроенная цепочка ≈ +20 мс). Тестовый цикл из 170 файлов по измерениям 9,8 с ≈ 58 мс/файл, ~85% — стоимость жизненного цикла процесса. Это определяет позиционирование выгоды данного RFC: устранить повторение по множественным входам и повторную компиляцию цепочки std; стоимость спавна процесса вне зоны действия кэша (изоляция подпроцессов — проектное решение RFC-036 §6, см. раздел «Вне целей»).

Предложение ​

1. Трёхуровневая модель кэша ​

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

УровеньЖизненный циклХозяинТипичные записи
L1Одна сессия проверкиvalidate_source / конвейер доказательствРезультаты валидации (диагностика + AST), множества unsafe заимствований, SMT-запросы
L2Одна оркестрация (возможно повторное использование внутри процесса)CompileSessionRegistry, результаты валидации по модулям
L3Между запускамиДиск cache/compile/Байткод модулей

Критерий стратификации: кем потребляется запись, с чем она инвалидируется. Запись L1 потребляется только одной проверкой; запись L2 — множеством входов в одной оркестрации; запись L3 — между процессами. Между уровнями записи не разделяются — один и тот же семантический продукт может одновременно существовать на нескольких уровнях, каждый уровень независимо отслеживает попадания и считает.

2. Ключ кэша: хеш содержимого как идентичность ​

rust
struct CacheKey {
    content_hash: u64,          // FNV-1a, существующая реализация в validate.rs
    compiler_version: Option<Version>,   // только L3
    config_fingerprint: Option<u64>,     // только L3: хеш битов конфигурации, влияющих на семантику
}
  • Ключ L1/L2 = content_hash: исходный код — это идентичность, то же содержимое гарантированно даёт попадание, независимо от пути;
  • Ключ L3 = тройка целиком: несовпадение версии или отпечатка пальца — промах, полный ключ перекомпилируется;
  • Неполный ключ — отказ записи: запись L3 без версии или без отпечатка не сохраняется на диск — лучше промах, чем устаревший продукт; тот же принцип конструктивного отказа, что и отказ от компиляции при отсутствии перевода в i18n диагностики.

3. L1: validate_source — единственная точка входа фронтенда ​

Все компоненты, которым нужна пара «исходный код → (диагностика, AST)», обязаны проходить через validate_source, создание собственного фронтенд-конвейера недопустимо. В текущем виде он уже существует и несёт процессный кэш, но его потребляет только formatter; данный RFC собирает три обходных пути:

ПотребительТекущее состояниеПосле сбора
check_files_with_diagnosticsCompiler::new() на каждый файлЧерез validate_source — запрос/обратное заполнение
Пофайловый typecheck оркестратораСобственная пересборка RegistryЧерез validate_source — запрос/обратное заполнение
Диагностика LSPНезависимая система DocumentCacheЧерез validate_source (третья волна)

Кэширование результатов анализа заимствований — вторая линия L1. Определим сессию проверки функций как единицу жизненного цикла кэша заимствований:

rust
struct FunctionCheckSession {
    /// Множество unsafe, полученное полным BFS по графу — создаётся при входе в функцию, отбрасывается при выходе
    unsafe_set: HashSet<TokenId>,
    /// Кэш SMT-запросов (линейные арифметические запросы в рамках бюджета RFC-027)
    smt_cache: HashMap<u64, SMTResult>,
}

Множественные операции записи в одной функции разделяют один BFS (реализация проектной фразы RFC-009a); кэш SMT-запросов собирается в тот же объект сессии и уничтожается вместе с ней. Сессия не переживает функцию — неизменность кода внутри функции гарантируется самим процессом проверки типов.

4. L2: модульный кэш внутри сессии ​

Сессия компиляции — сущность первого класса, более не пересоздаётся неявно каждый раз:

rust
pub struct CompileSession {
    /// Ключ = (путь модуля, content_hash); путь различает разные файлы с одинаковым содержимым
    modules: HashMap<(PathBuf, u64), Arc<ModuleEntry>>,
}

struct ModuleEntry {
    validated: Arc<ValidateResult>,
}
  • Встроенные источники std (yx_sources include_str!) имеют постоянное содержимое, их хеш вычисляется в процессе один раз (статическая таблица LazyLock) — N импортирующих файлов разделяют одну компиляцию;
  • Второй вызов compile_project в том же процессе (например, check + run в тестовом цикле, yx_runner с множеством файлов) запрашивает модули в CompileSession — неизменённое содержимое означает повторное использование модуля целиком;
  • Сессия умирает вместе с процессом, без записи на диск, без межпроцессных обязательств.

5. L3: дисковый кэш байткода ​

text
~/.yaoxiang/cache/compile/<content_hash>-<version>-<fp>.yxbc
  • Продукт = существующий магический формат BytecodeFile (тот же путь пробы байткода для run используется как есть), новый формат сериализации не изобретается;
  • Загрузка = чтение файла + проверка согласованности тройки в имени файла с содержимым; любое несоответствие обрабатывается как промах с удалением повреждённой записи;
  • Первая партия кэширует только модули std: встроенные источники стабильны, версия зафиксирована, процент попаданий постоянен; кэширование модулей проекта откладывается до решения открытых вопросов.

6. Правила инвалидации ​

ПравилоТриггерДействие
R1Изменение содержимого источникаАктивная инвалидация не выполняется — ключ содержит хеш содержимого, старые записи естественно недостижимы
R2Изменение зависимого модуля (L2)Пометить грязными все нисходящие по графу use, перекомпилировать только грязные модули и их потомки
R3Изменение версии компилятора (L3)Полный промах (ключ содержит версию, миграция между версиями не выполняется)
R4Неполный ключ (L3)Отказ записи

Распространение инвалидации существует только в пределах графа зависимостей L2; L3 не отслеживает межмодульные зависимости — версия служит защитным барьером, семантика никогда не зависит от «всё ещё валидного» старого продукта.

7. Метрики: ненаблюдаемый кэш не допускается в основную ветку ​

rust
pub struct CacheStats {
    pub hits: u64,
    pub misses: u64,
    pub cached_bytes: u64,
}

В summary вывода check --json и test --json добавляется поле cache:

json
{
  "cache": {
    "l1": { "hits": 18, "misses": 1 },
    "l2": { "hits": 0, "misses": 19 },
    "l3": { "hits": 57, "misses": 3, "cached_bytes": 245760 }
  }
}

Любой PR с добавлением кэш-записей в основную ветку обязан сопровождаться свидетельством попаданий по этому полю, чтобы предотвратить тихое разложение «кэш есть, но никто не попадает» (согласование наблюдаемости с #289).

Детальный проект ​

Влияние на систему типов ​

Отсутствует. Данный RFC не вводит изменений в типы языка, синтаксис или семантику; типы кэш-записей полностью переиспользуют существующие ValidateResult / ModuleRegistry / BytecodeFile.

Поведение во время выполнения ​

  • Семантика исполнения run не меняется; попадание в L3 эквивалентно «загрузить байткод с диска» по существующему пути BytecodeFile::probe/load, байт-в-байт идентично;
  • При полном промахе кэша поведение полностью совпадает с сегодняшним (кэш должен быть прозрачным — это критерий приёмки, а не пожелание);
  • Наблюдаемых изменений только два: снижение времени; добавление поля cache в вывод --json для check/test (чистое добавление, старые потребители не затронуты).

Изменения в компиляторе ​

КомпонентИзменение
src/frontend/validate.rsСтановится единственной точкой входа фронтенда (сбор потребителей, см. предложение §3)
src/util/diagnostic/mod.rsВ check_files_with_diagnostics убрать Compiler::new() на каждый файл
src/frontend/module/orchestrator.rscompile_project принимает CompileSession (L2)
src/frontend/core/typecheck/proof/FunctionCheckSession (L1: кэш заимствований/SMT)
src/frontend/pipeline/compilation_cache.rsНовый — чтение/запись L3 и CacheStats (заглушки тестов уже подготовлены)
src/frontend/pipeline/incremental_scheduler.rsНовый — определение грязных файлов по графу use (заглушки тестов уже подготовлены)
src/util/cache.rsDocumentCache переводится в потребители L1 (третья волна, поглощение RFC-017)
src/main.rs / src/util/test_runner.rsJSON-отчёт дополнен полем cache

Обратная совместимость ​

  • ✅ Нулевые изменения на уровне языка, нулевое разрушение CLI, JSON-поле — чистое добавление;
  • ✅ Слой кэша в целом деградируем (очистка возвращает поведение без кэша), без бремени миграции;
  • Записи L3 на диске защищены версией, после обновления версии старые записи естественно дают промах, инструмент очистки не требуется (семантика yaoxiang cache clean расширяется существующей командой RFC-014 на сегмент compile/).

Компромиссы ​

Преимущества ​

  • Прямое устранение трёх эмпирически подтверждённых потерь: повторение полного конвейера по множественным входам в одном процессе, N-кратная повторная компиляция встроенной цепочки std (19 файлов × 20 мс → 1 × 20 мс), повторные BFS O(V²) в больших функциях при проверке заимствований;
  • Унификация кэш-стратегии: определение ключей, правила инвалидации, метрики — единый источник по всему репозиторию, пять разрозненных точек собираются в единую семантику;
  • Обязательность метрик делает видимой как выгоду кэша, так и его разложение.

Недостатки ​

  • Процессный кэш L1 вводит разделяемое состояние: в режиме --parallel становится горячей точкой блокировки. Смягчение: записи Arc неизменяемые, ключ u64, гранулярность блокировки = одно ведро;
  • L3 вводит поверхность риска «загрузки устаревшего продукта». Смягчение: двойная защита версии + отпечатка, лучше промах чем ошибка, плюс собственная магическая проверка байткода;
  • L2 превращает оркестратор из безсостоятельного в имеющий состояние, регрессионное покрытие требует полного прохождения сценариев #232/#243-245.

Альтернативы ​

АльтернативаПочему не выбрана
Только дисковый кэш L3Эмпирически 85% стоимости — жизненный цикл процесса, L3 затрагивает только ~20 мс/файл цепочки std; не решает проблему повторения по множественным входам
Компилятор-резидентный сервис (daemon)Наивысший потолок выгоды (съедает и стоимость спавна), но вводит управление жизненным циклом сервиса, конфликтует с моделью изоляции подпроцессов RFC-036; оставляется на будущий отдельный RFC
Независимая эволюция каждой точки, без унификацииЭто и есть текущее состояние — три разных набора ключей/инвалидации, что и есть указанный в #293 пробел
Тестовый цикл через внутрипроцессный runnerПринадлежит плоскости решения модели изоляции RFC-036, данный RFC не выходит за её пределы

Стратегия реализации ​

Зависимости ​

  • Предусловия: оркестратор RFC-029 (стабилизирован), конвейер доказательств RFC-009a (хост кэша BFS);
  • Параллельно: обнаружение трассировки use в #247 — точность графа зависимостей для инкрементальной перекомпиляции зависит от него, до его реализации грязное определение R2 консервативно берёт полный объём;
  • Потребители: #290/#292 — реализация проверки заимствований.

Волны и риски ​

  1. Первая волна (L1): сбор трёх потребителей в validate_source + FunctionCheckSession + минимальная версия CacheStats. Риск низкий — чистый внутренний рефакторинг, поведение при полном промахе не меняется;
  2. Вторая волна (L2): CompileSession + однократная компиляция встроенного std. Риск средний — оркестратор становится stateful, требуется полная регрессия #232/#243-245;
  3. Третья волна (L3 + инкремент): дисковый кэш (std в приоритете) + incremental_scheduler + миграция DocumentCache. Риск высокий — корректность межпроцессного продукта полностью зависит от защиты версией.

Открытые вопросы ​

  • [ ] Включать ли в L3-продукт сериализацию типового окружения (определяет, могут ли потребители класса check попадать в L3, или только run)
  • [ ] Список семантических битов config_fingerprint: какие параметры конфигурации изменяют семантику продукта компиляции
  • [ ] Миграция DocumentCache — идти в третьей волне или отдельным небольшим шагом раньше
  • [ ] Метрика атрибуции попаданий: по числу вызовов или по числу уникальных ключей (влияет на интерпретируемость метрик)

Вне целей ​

  • Инкрементальный парсинг на уровне функций (существующее решение RFC-017: полный парсинг файла занимает лишь несколько миллисекунд, не стоит инкрементализации);
  • JIT / кэш времени выполнения (юрисдикция RFC-028);
  • Слом изоляции подпроцессов тестового цикла (проектное решение RFC-036 §6; схема резидентного сервиса — в альтернативах, отдельный RFC);
  • Распространение инвалидации между пакетами (вне рамок RFC-029);
  • Оптимизация стоимости спавна процесса (не вопрос кэширования).

Приложение Б: Записи проектных решений ​

РешениеОпределениеДатаЗаписавший
ХостЗарезервированная строка 029a из RFC-029, не создавать нового RFC верхнего уровня2026-09-07Чэньсюй
Модель стратификацииL1 внутрипроцессный / L2 сессионный модульный / L3 дисковый2026-09-07Чэньсюй
Позиционирование выгодыГлавное поле боя = повторное использование по множественным входам и устранение цепочки std2026-09-07Чэньсюй
Не ломать изоляцию подпроцессовL3 — единственный межпроцессный канал выгоды в модели подпроцессов2026-09-07Чэньсюй
L3 не выполняет межмодульное распространение инвалидацииПолная инвалидация ключа защитой версии2026-09-07Чэньсюй
Обязательность метрикБез метрик не допускается в основную ветку2026-09-07Чэньсюй

Ссылки ​

  • #293 (issue данного RFC); #251 аудит P0-6 (обнаружение отсутствия кэша BFS заимствований)
  • RFC-029 §Основные принципы (объявление границ) и §Планирование под-RFC (строка 029a)
  • RFC-009a (конвейер доказательств заимствований; проектная фраза о кэше BFS)
  • RFC-017 §2 (LSP DocumentCache и решение об упрощении на уровне файлов)
  • RFC-014 §Глобальный кэш (директория на диске); RFC-028 §Кэш кода (разграничение слоя времени выполнения)