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) | Внутрипроцессный | Только formatter | CLI check/run и оркестратор его не используют |
| Кэш запросов Z3Backend | Сессия проверки | Конвейер доказательств | Нет правил инвалидации и бюджетирования (RFC-009a — одна фраза) |
DocumentCache (RFC-017) | Сессия LSP | LSP | Полностью изолирован от CLI-стороны компиляции |
~/.yaoxiang/cache (RFC-014) | Диск | Загрузка пакетов | Не связан с компиляцией |
| Кэш кода (RFC-028) | Runtime | JIT | Не слой времени компиляции |
Многовходовое повторение полного конвейера (эмпирически 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 | Одна оркестрация (возможно повторное использование внутри процесса) | CompileSession | Registry, результаты валидации по модулям |
| L3 | Между запусками | Диск cache/compile/ | Байткод модулей |
Критерий стратификации: кем потребляется запись, с чем она инвалидируется. Запись L1 потребляется только одной проверкой; запись L2 — множеством входов в одной оркестрации; запись L3 — между процессами. Между уровнями записи не разделяются — один и тот же семантический продукт может одновременно существовать на нескольких уровнях, каждый уровень независимо отслеживает попадания и считает.
2. Ключ кэша: хеш содержимого как идентичность
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_diagnostics | Compiler::new() на каждый файл | Через validate_source — запрос/обратное заполнение |
| Пофайловый typecheck оркестратора | Собственная пересборка Registry | Через validate_source — запрос/обратное заполнение |
| Диагностика LSP | Независимая система DocumentCache | Через validate_source (третья волна) |
Кэширование результатов анализа заимствований — вторая линия L1. Определим сессию проверки функций как единицу жизненного цикла кэша заимствований:
struct FunctionCheckSession {
/// Множество unsafe, полученное полным BFS по графу — создаётся при входе в функцию, отбрасывается при выходе
unsafe_set: HashSet<TokenId>,
/// Кэш SMT-запросов (линейные арифметические запросы в рамках бюджета RFC-027)
smt_cache: HashMap<u64, SMTResult>,
}Множественные операции записи в одной функции разделяют один BFS (реализация проектной фразы RFC-009a); кэш SMT-запросов собирается в тот же объект сессии и уничтожается вместе с ней. Сессия не переживает функцию — неизменность кода внутри функции гарантируется самим процессом проверки типов.
4. L2: модульный кэш внутри сессии
Сессия компиляции — сущность первого класса, более не пересоздаётся неявно каждый раз:
pub struct CompileSession {
/// Ключ = (путь модуля, content_hash); путь различает разные файлы с одинаковым содержимым
modules: HashMap<(PathBuf, u64), Arc<ModuleEntry>>,
}
struct ModuleEntry {
validated: Arc<ValidateResult>,
}- Встроенные источники std (
yx_sourcesinclude_str!) имеют постоянное содержимое, их хеш вычисляется в процессе один раз (статическая таблицаLazyLock) — N импортирующих файлов разделяют одну компиляцию; - Второй вызов
compile_projectв том же процессе (например, check + run в тестовом цикле, yx_runner с множеством файлов) запрашивает модули вCompileSession— неизменённое содержимое означает повторное использование модуля целиком; - Сессия умирает вместе с процессом, без записи на диск, без межпроцессных обязательств.
5. L3: дисковый кэш байткода
~/.yaoxiang/cache/compile/<content_hash>-<version>-<fp>.yxbc- Продукт = существующий магический формат
BytecodeFile(тот же путь пробы байткода дляrunиспользуется как есть), новый формат сериализации не изобретается; - Загрузка = чтение файла + проверка согласованности тройки в имени файла с содержимым; любое несоответствие обрабатывается как промах с удалением повреждённой записи;
- Первая партия кэширует только модули std: встроенные источники стабильны, версия зафиксирована, процент попаданий постоянен; кэширование модулей проекта откладывается до решения открытых вопросов.
6. Правила инвалидации
| Правило | Триггер | Действие |
|---|---|---|
| R1 | Изменение содержимого источника | Активная инвалидация не выполняется — ключ содержит хеш содержимого, старые записи естественно недостижимы |
| R2 | Изменение зависимого модуля (L2) | Пометить грязными все нисходящие по графу use, перекомпилировать только грязные модули и их потомки |
| R3 | Изменение версии компилятора (L3) | Полный промах (ключ содержит версию, миграция между версиями не выполняется) |
| R4 | Неполный ключ (L3) | Отказ записи |
Распространение инвалидации существует только в пределах графа зависимостей L2; L3 не отслеживает межмодульные зависимости — версия служит защитным барьером, семантика никогда не зависит от «всё ещё валидного» старого продукта.
7. Метрики: ненаблюдаемый кэш не допускается в основную ветку
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub cached_bytes: u64,
}В summary вывода check --json и test --json добавляется поле cache:
{
"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.rs | compile_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.rs | DocumentCache переводится в потребители L1 (третья волна, поглощение RFC-017) |
src/main.rs / src/util/test_runner.rs | JSON-отчёт дополнен полем 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 — реализация проверки заимствований.
Волны и риски
- Первая волна (L1): сбор трёх потребителей в
validate_source+FunctionCheckSession+ минимальная версияCacheStats. Риск низкий — чистый внутренний рефакторинг, поведение при полном промахе не меняется; - Вторая волна (L2):
CompileSession+ однократная компиляция встроенного std. Риск средний — оркестратор становится stateful, требуется полная регрессия #232/#243-245; - Третья волна (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 | Чэньсюй |
| Позиционирование выгоды | Главное поле боя = повторное использование по множественным входам и устранение цепочки std | 2026-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 §Кэш кода (разграничение слоя времени выполнения)
