RFC-028: JIT-компилятор — многоуровневый исполнительный движок в VM
Ссылки:
Аннотация
В данном документе предлагается ввести JIT-компилятор Cranelift в бэкенд VM языка YaoXiang, обновив VM с чистого интерпретатора до многоуровневого исполнительного движка: холодный код выполняется через интерпретацию, горячие функции компилируются Cranelift в нативный код. Путь JIT и путь LLVM AOT из RFC-018 совместно используют проходы IR-нормализации: Cranelift отвечает за быструю JIT-компиляцию, LLVM — за глубокую AOT-оптимизацию; каждый инструмент выполняет свою задачу.
Ключевое позиционирование: JIT обслуживает VM, а не заменяет её.
Мотивация
Зачем нужен JIT?
Текущий бэкенд VM представляет собой чистый интерпретатор, скорость выполнения которого в 10-100 раз ниже нативного кода. При разработке часто запускаются тесты, скрипты, локальная отладка — в этих сценариях не требуется предельная AOT-оптимизация, но требуется заметно более высокая скорость выполнения по сравнению с интерпретатором.
Почему не только LLVM AOT?
AOT-компиляция LLVM занимает много времени (порядка секунд), что не подходит для итераций разработки. Разработке необходим опыт «изменил и сразу запустил»: изменил строку кода → перезапустил → почти мгновенно увидел результат. Cranelift JIT-компилирует одну функцию за 1-5 мс — пользователь не ощущает задержки компиляции.
Почему Cranelift, а не LLVM ORC JIT?
| Измерение | Cranelift JIT | LLVM ORC JIT |
|---|---|---|
| Скорость компиляции | 1-5 мс/функция | 10-100 мс/функция |
| Объём зависимостей | Небольшой | Большой (требуется полный LLVM) |
| Качество кода | 70-80% от LLVM -O2 | Очень высокое |
| Сценарий применения | Разработка и отладка, быстрые итерации | Не подходит (см. компромиссы в статье) |
Cranelift компилирует быстро, качество кода при этом достаточное. LLVM остаётся для офлайн-глубокой AOT-оптимизации. Один инструмент хорошо делает одно дело.
Предложение
Базовая архитектура
Исполнительный движок VM
├── Слой интерпретатора
│ ├── Выполнение инструкций байт-кода
│ ├── Сбор данных о горячих точках (invocation count + loop backedge count)
│ └── Достижение порога → отправка задачи компиляции
│
├── Слой JIT-компиляции (Cranelift Backend)
│ ├── Очередь компиляции (фоновый поток, не блокирует интерпретатор)
│ ├── IR → Нормализация → Cranelift IR → Нативный код
│ └── Повторное использование прохода IR-нормализации из RFC-018 §4.0 (стек→SSA)
│
├── Кэш кода
│ ├── Таблица функций: ID функции → {вход в интерпретатор, вход в JIT (опционально)}
│ ├── Атомарная замена входа функции после компиляции
│ └── Группировка по модулям (резерв интерфейса горячей перезагрузки)
│
└── Анализ горячих точек
├── Счётчик вызовов каждой функции + счётчик обратных рёбер цикла
├── Периодическое затухание (во избежание однократного срабатывания прогрева)
└── Три уровня горячих точек: Cold → Warm → Hot → CompiledСопряжение с существующей архитектурой
Исходный код → Фронтенд (общий) → IR → ┬→ codegen байт-кода → Интерпретатор VM → [горячие функции] → Cranelift JIT
│
└→ codegen LLVM AOT → .o → Линковка → exe (продакшн)JIT и AOT совместно используют проход IR-нормализации (middle/passes/ir_normalize.rs); низкоуровневый codegen заменяется с LLVM на Cranelift.
Процесс выполнения
Вызов функции
→ fn_entry.code_ptr.load()
→ ┬─ заглушка интерпретатора (холодное состояние): побайтовая интерпретация
└─ нативный код JIT (горячее состояние): прямое выполнение машинного кода
→ возвратДетальное проектирование
1. Структура каталогов
src/
├── backends/
│ ├── interpreter/ # существующий — интерпретатор VM
│ │ └── executor/
│ │ ├── engine.rs # изменён — точка входа переключена с прямой интерпретации на диспетчеризацию через FunctionEntry
│ │ └── ...
│ │
│ ├── jit/ # новый — слой JIT-компиляции
│ │ ├── mod.rs # точка входа модуля JIT, инициализация контекста Cranelift
│ │ ├── profiler.rs # счётчики горячих точек + затухание + пороговые решения
│ │ ├── entry.rs # FunctionEntry + управление AtomicPtr
│ │ ├── cache.rs # кэш кода (управление исполняемыми страницами mmap)
│ │ ├── compiler.rs # IR → Cranelift IR → нативный код
│ │ ├── types.rs # отображение типов YaoXiang → Cranelift
│ │ └── abi.rs # соглашение о вызовах (System V / Microsoft x64)
│ │
│ ├── llvm/ # в планах — LLVM AOT (RFC-018)
│ ├── common/ # существующий
│ └── runtime/ # существующий
│
└── middle/
└── passes/
└── ir_normalize.rs # новый — общая IR-нормализация (стек→SSA)
# совместно используется JIT и LLVM AOTКлючевые ограничения:
backends/jit/зависит только отmiddle/(определения IR, проходы нормализации), стандартной библиотеки и крейта Craneliftbackends/jit/не зависит отbackends/llvm/, оба являются равноправными бэкендамиbackends/jit/не зависит отbackends/interpreter/, взаимодействие через интерфейсFunctionEntry
2. Анализ горячих точек и многоуровневое срабатывание
2.1 Конечный автомат горячих точек
Cold ──(invocation > 50 или backedge > 500)──→ Warm
Warm ──(invocation > 200)────────────────────→ Hot
Hot ──(отправка в очередь компиляции, завершение компиляции)──→ CompiledПороги являются настраиваемыми параметрами, выше приведены значения по умолчанию. См. фактические диапазоны порогов LuaJIT, JVM C1, V8 Sparkplug (50-1000).
2.2 Счётчики
Каждая функция поддерживает в FunctionEntry (подробности в §4.1) два атомарных счётчика:
// FunctionEntry 的热度字段(完整定义见 §4.1)
invocation_count: AtomicU32, // 函数被调用次数
backedge_count: AtomicU32, // 循环回边跳转次数
state: AtomicU8, // Cold | Warm | Hot | Compiled2.3 Механизм затухания
Каждые 5 секунд все счётчики сдвигаются вправо на 1 бит (умножение на 0.5). Это предотвращает запуск JIT-компиляции для кода, который во время старта выполняется часто, но лишь однажды (например, инициализационный обход).
fn decay(entry: &FunctionEntry) {
entry.invocation_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(v >> 1));
entry.backedge_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(v >> 1));
}Используются побитовые операции — нулевые накладные расходы на деление.
2.4 Очередь компиляции
Поток интерпретатора Фоновый поток JIT
│ │
├─ Достигнута горячая точка Hot │
├─ Отправка запроса на компиляцию ───→ │
│ (не блокирует интерпретатор) ├─ Извлечение IR функции
│ ├─ IR-нормализация (стек→SSA)
│ ├─ Компиляция Cranelift
│ ├─ Запись в кэш кода
│ └─ Атомарное обновление указателя входа функции
│ Следующий вызов этой функции ←───── │
│ сразу переходит на нативный код │Во время компиляции функция по-прежнему выполняется через интерпретатор. После завершения компиляции следующий вызов атомарно переключается на JIT-код.
3. Конвейер компиляции IR → Cranelift
3.1 Конвейер
YaoXiang IR (стековая форма)
→ Проход IR-нормализации (стек → регистры/SSA) ← повторное использование из RFC-018 §4.0
→ Построение Cranelift IR
→ Оптимизации Cranelift + генерация машинного кода
→ Запись в кэш кода3.2 Типы YaoXiang → типы Cranelift
| Тип YaoXiang | Тип Cranelift | Описание |
|---|---|---|
Int | i64 | |
Int32 | i32 | |
Float | f64 | |
Float32 | f32 | |
Bool | i8 | Cranelift не имеет i1, используется i8 |
Char | i32 | Unicode-кодпойнт |
String | { i64, i64 } | Указатель + длина |
Void | Пустой кортеж | |
&T | — | Нулевой размер, исчезает после компиляции |
&mut T | — | Нулевой размер, исчезает после компиляции |
ref T | { i64, i64 } | Указатель счётчика ссылок + указатель данных |
*T | i64 | Голый указатель |
List(T) | { i64, i64, i64 } | Указатель данных + длина + ёмкость |
| Структура | Cranelift struct | |
| Запись-перечисление | { i64, [max_payload] } | Метка + объединение |
?T | { i8, T } | Метка наличия значения + данные |
Сравнение с таблицей типов LLVM из RFC-018 §3: Cranelift не различает типы указателей, не имеет
i1, в целом проще.
3.3 Трансляция ключевых инструкций
| IR-инструкция | Cranelift IR |
|---|---|
Add { dst, lhs, rhs } | iadd (целое) / fadd (с плавающей точкой) |
Sub { dst, lhs, rhs } | isub / fsub |
Mul { dst, lhs, rhs } | imul / fmul |
Div { dst, lhs, rhs } | sdiv / udiv / fdiv |
Eq { dst, lhs, rhs } | icmp eq / fcmp eq |
Jmp(label) | jump |
JmpIf(cond, label) | brnz |
Ret(Some(v)) | return |
Call { dst, func, args } | call |
Load { dst, src } | load |
Store { dst, src } | store |
Spawn { ... } | вызовы task_spawn и task_wait_all среды выполнения |
Полная таблица трансляции приведена в основном тексте RFC. Ключевой принцип: набор инструкций Cranelift покрывает все операции IR YaoXiang, семантических пробелов нет.
3.4 Сосуществование двух видов нормализации
Интерпретатору VM требуется стековая семантика (Push/Pop/Dup/Swap), Cranelift JIT и LLVM AOT требуют регистров/SSA. Проход IR-нормализации выполняет однократное преобразование (RFC-018 §4.0), JIT и AOT совместно его используют, не изменяя представление самого IR. Каждый бэкенд потребляет один и тот же IR в соответствии со своими потребностями.
4. Таблица входов функций и атомарная замена
4.1 FunctionEntry
struct FunctionEntry {
/// 原子可替换的执行目标
code_ptr: AtomicPtr<u8>,
/// 不变元数据
bytecode: &'static [u8], // 解释器 fallback
ir: &'static FunctionIR, // JIT 编译的输入
/// 运行时统计
invocation_count: AtomicU32,
backedge_count: AtomicU32,
state: AtomicU8, // Cold | Warm | Hot | Compiled
}4.2 Диспетчеризация входа
Вызывающий код
→ fn_entry.code_ptr.load(Ordering::Acquire)
→ ┬─ адрес заглушки интерпретатора → выполнение интерпретатора, побайтовая интерпретация
└─ адрес JIT-кода → прямой переход к нативному кодуОднократное разыменование указателя. Обработка косвенного перехода предсказателем переходов современного CPU: первое неверное предсказание, затем всегда верно. Накладные расходы — около 1 такта.
4.3 Атомарное переключение
После завершения компиляции — одна CAS-операция:
fn install_jit_code(entry: &FunctionEntry, jit_code: *mut u8) -> bool {
entry.code_ptr.compare_exchange(
INTERPRETER_STUB, // 期望:仍指向解释器
jit_code, // 替换为:JIT 代码
Ordering::AcqRel,
Ordering::Acquire,
).is_ok()
}Без приостановки интерпретатора, без ожидания точек безопасности, без обхода точек вызова. Одна атомарная операция завершает переключение.
5. Кэш кода
5.1 Структура
CodeCache:
modules:
"main.yao":
functions:
"compute" → FunctionEntry (state: Compiled)
"process" → FunctionEntry (state: Cold)
"init" → FunctionEntry (state: Compiled)
native_pages: [ mmap'd executable memory pages ]
"lib.yao":
functions:
"helper" → FunctionEntry (state: Compiled)
native_pages: [ mmap'd executable memory pages ]5.2 Управление исполняемой памятью
struct NativePage {
ptr: *mut u8,
size: usize,
used: AtomicUsize, // 已用字节数
remaining: usize, // 剩余容量
}
impl CodeCache {
fn allocate(&self, code_size: usize) -> *mut u8;
fn deallocate(&self, ptr: *mut u8, code_size: usize); // 仅在模块失效时调用
}Каждый модуль получает непрерывные исполняемые страницы mmap; все JIT-функции модуля размещаются на одной странице. При инвалидации модуля страница освобождается целиком, не нужно освобождать каждую функцию по отдельности.
6. Зарезервированные точки расширения для горячей перезагрузки
Следующие интерфейсы компилируются, но до реализации горячей перезагрузки не вызываются. Принцип проектирования интерфейсов: при реализации JIT достаточно insert и compare_exchange для одной функции; операции уровня модуля откладываются на горячую перезагрузку.
/// 代码缓存扩展接口(预留,不实现)
trait CodeCacheExt {
/// 失效整个模块的所有 JIT 代码,回退到解释器
fn invalidate_module(&self, module_path: &str);
/// 根据源码位置范围失效特定函数
fn invalidate_range(&self, file: &str, start: u32, end: u32);
/// 原子替换整个模块的函数表
fn swap_module(&self, module_path: &str, new_functions: HashMap<String, FunctionEntry>);
}
/// 编译队列扩展接口(预留,不实现)
trait CompileQueueExt {
/// 优先级插队(热重载编译高于普通 JIT 编译)
fn submit_priority(&self, task: CompileTask);
}Зачем группировать по модулям? Самому JIT нужны только функции. Организация по модулям существует исключительно для обслуживания горячей перезагрузки: после перекомпиляции модуля можно атомарно заменить весь набор функций модуля, а не делать CAS по каждой функции — последнее при наличии циклических зависимостей между функциями привело бы к несогласованному состоянию.
Компромиссы
Преимущества
- Нулевая воспринимаемая задержка компиляции: Cranelift 1-5 мс/функция, компиляция в фоновом потоке, интерпретатор не приостанавливается
- Совместная инфраструктура: JIT и AOT совместно используют проход IR-нормализации (RFC-018 §4.0), дублирования нет
- Без разрушительных изменений: чисто инкрементальная функциональность. VM не меняется, интерпретатор не меняется, просто добавляется более быстрый горячий путь
- Без зависимости от LLVM: VM не вводит LLVM, остаётся лёгкой
- Естественная поддержка нескольких платформ: Cranelift изначально поддерживает x86_64 и ARM64, покрывая все целевые платформы
- Резерв для горячей перезагрузки: кэш кода группируется по модулям + косвенный переход через вход функции, закладывает структурную основу для будущей горячей перезагрузки
Недостатки
- Новая зависимость Cranelift: вводится новый внешний крейт, требуется освоить его API
- Сложность отладки: кадры стека JIT-кода должны быть совместимы с кадрами стека интерпретатора, требуется дополнительная обработка отображения отладочной информации
- Задержка прогрева при холодном старте: первые несколько секунд после запуска программы нет ускорения JIT, требуется накопление горячих точек
- ABI платформ: разные платформы (Linux/macOS/Windows) требуют отдельной адаптации mmap и соглашений о вызовах
Согласованность со связанными RFC
| RFC | Согласованность |
|---|---|
| RFC-018 LLVM AOT | ✅ Общий проход IR-нормализации, JIT и AOT — равноправные бэкенды |
| RFC-024 spawn-блоки параллелизма | ✅ spawn-блоки компилируются в вызовы функций среды выполнения |
| RFC-008 архитектура среды выполнения | ✅ Все три уровня среды выполнения (Embedded/Standard/Full) поддерживают JIT |
Альтернативы
| Вариант | Почему не выбран |
|---|---|
| Только LLVM AOT, без JIT | При разработке требуется перекомпиляция всей программы, теряется опыт быстрых итераций |
| LLVM ORC JIT | Высокая задержка компиляции (10-100 мс), большая зависимость от LLVM, не подходит для встраивания в VM |
| Самодельный лёгкий JIT (dynasm) | Высокая стоимость поддержки рукописного бэкенда, не такой зрелый, как Cranelift |
| Шаблонный JIT | Нулевая оптимизация, плохое качество кода, напрасная трата времени JIT-компиляции |
| Полнопрограммный JIT (без интерпретатора) | Медленный холодный старт, простые скрипты не стоит компилировать |
Зависимости
- RFC-018 (LLVM AOT) → общий проход IR-нормализации
- RFC-024 (spawn-блоки параллелизма) → JIT-компиляция spawn-блоков
- RFC-008 (архитектура среды выполнения) → поддержка JIT на трёх уровнях среды выполнения
- Крейт Cranelift → бэкенд JIT
Список литературы
- Документация Cranelift IR
- RFC-018: Проект LLVM AOT-компилятора
- RFC-024: Модель параллелизма на основе spawn-блоков
- RFC-008: Разделение модели параллелизма Runtime и планировщика
- Hölzle, U. (1994). Adaptive Optimization for Self: Reconciling High Performance with Exploratory Programming. Stanford.
Жизненный цикл и местонахождение
| Состояние | Расположение | Описание |
|---|---|---|
| Черновик | docs/src/design/rfc/draft/ | Черновик автора, ожидает подачи на рассмотрение |
| На рассмотрении | docs/src/design/rfc/review/ | Открыто для обсуждения и обратной связи сообщества |
| Принято | docs/src/design/rfc/accepted/ | Становится официальным проектным документом |
