RFC-028:JIT компилятор — многоуровневый движок выполнения в VM
Справочные материалы:
Резюме
В данном документе предлагается внедрить Cranelift JIT компилятор в VM бэкенд YaoXiang, модернизировав VM с чистого интерпретатора до многоуровневого движка выполнения: холодный код выполняется интерпретатором, горячие функции компилируются Cranelift в нативный код. JIT путь совместно использует с LLVM AOT путем из RFC-018 IR нормализационные passes, Cranelift обеспечивает быструю JIT компиляцию, LLVM отвечает за глубокую AOT оптимизацию — каждый выполняет свою задачу.
Основная позиционирование: JIT обслуживает VM, а не заменяет VM.
Мотивация
Зачем нужен JIT?
Текущий VM бэкенд — это чистый интерпретатор, скорость выполнения которого в 10-100 раз ниже нативного кода. При разработке часто запускаются тесты, скрипты, локальная отладка — в этих сценариях не нужна предельная AOT оптимизация, но требуется заметно более высокая скорость выполнения по сравнению с интерпретатором.
Почему не использовать только LLVM AOT?
LLVM AOT компиляция занимает много времени (секунды), что не подходит для итеративной разработки. При разработке нужно ощущение «изменил — запустил»: изменил строку кода → перезапустил → почти мгновенно видишь результат. 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
├── Уровень интерпретатора
│ ├── Выполняет байткод инструкции
│ ├── Собирает данные о нагреве (счётчик вызовов + счётчик обратных рёбер цикла)
│ └── При достижении порога → отправляет задачу компиляции
│
├── Уровень JIT компиляции (Cranelift Backend)
│ ├── Очередь компиляции (фоновый поток, не блокирует интерпретатор)
│ ├── IR → нормализация → Cranelift IR → нативный код
│ └── Повторное использование IR нормализационного pass из RFC-018 §4.0 (стек→SSA)
│
├── Кэш кода
│ ├── Таблица функций: ID функции → {интерпретаторная точка входа, JIT точка входа (опционально)}
│ ├── Атомарная замена точки входа скомпилированной функции
│ └── Группировка по модулям (резерв для горячей перезагрузки)
│
└── Анализ нагрева
├── Счётчик вызовов на функцию + счётчик обратных рёбер циклов
├── Периодическое затухание (чтобы избежать запуска бессмысленной JIT компиляции при одноразовом прогреве)
└── Четыре уровня нагрева: Cold → Warm → Hot → CompiledИнтеграция с существующей архитектурой
Исходный код → Фронтенд (общий) → IR → ┬→ Байткод codegen → VM интерпретатор → [горячие функции] → Cranelift JIT
│
└→ LLVM AOT codegen → .o → Линковка → exe (продакшн)JIT и AOT совместно используют IR нормализационный pass (middle/passes/ir_normalize.rs), нижний codegen меняется с LLVM на Cranelift.
Поток выполнения
Вызов функции
→ fn_entry.code_ptr.load()
→ ┬─ Интерпретаторный stub (холодное состояние): построчное интерпретирование байткода
└─ 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, нормализационные passes), стандартной библиотеки и crate 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 нормализационный pass (стек → регистры/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 | |
| Запись enum | { i64, [max_payload] } | Тег + union |
?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 { ... } | Вызов runtime task_spawn + task_wait_all |
Полная таблица перевода в основном тексте RFC. Ключевой принцип: набор инструкций Cranelift покрывает все операции YaoXiang IR, семантических пробелов нет.
3.4 Сосуществование двух нормализаций
VM интерпретатору нужна стековая семантика (Push/Pop/Dup/Swap), а Cranelift JIT и LLVM AOT нужны регистры/SSA. IR нормализационный pass выполняет одно преобразование (RFC-018 §4.0), JIT и AOT совместно используют его, не меняя само представление IR. Каждый бэкенд потребляет один и тот же IR по-своему.
4. Таблица точек входа функций и атомарная замена
4.1 FunctionEntry
struct FunctionEntry {
/// Атомарно заменяемая точка выполнения
code_ptr: AtomicPtr<u8>,
/// Неизменяемые метаданные
bytecode: &'static [u8], // Запасной интерпретатор
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)
→ ┬─ Адрес интерпретаторного stub → Выполнение через интерпретатор, построчная интерпретация байткода
└─ Адрес JIT кода → Прямой переход на нативный кодОдно разыменование указателя. Обработка косвенных переходов современными CPU branch predictor: первое предсказание неверное, далее всё верно. Накладные расходы примерно 1 cycle.
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()
}Без паузы интерпретатора, без ожидания safe point, без обхода точек вызова. Одна атомарная операция завершает переключение.
5. Кэш кода
5.1 Структура
CodeCache:
modules:
"main.yao":
functions:
"compute" → FunctionEntry (state: Compiled)
"process" → FunctionEntry (state: Cold)
"init" → FunctionEntry (state: Compiled)
native_pages: [ исполняемые страницы памяти через mmap ]
"lib.yao":
functions:
"helper" → FunctionEntry (state: Compiled)
native_pages: [ исполняемые страницы памяти через mmap ]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 нормализационный pass (RFC-018 §4.0), не изобретаем велосипед повторно
- Неразрушительность: Чисто добавочная функциональность. VM неизменна, интерпретатор неизменен, просто добавляется более быстрый горячий путь
- Без зависимости от LLVM: VM не引入 LLVM, сохраняется лёгкость
- Естественная поддержка нескольких платформ: Cranelift нативно поддерживает x86_64 и ARM64, охватывает все целевые платформы
- Резерв для горячей перезагрузки: Кэш кода группировка по модулям + косвенный переход точек входа функций закладывают структурную основу для будущей горячей перезагрузки
Недостатки
- Новая зависимость от Cranelift: Внедрение нового внешнего crate, требуется знакомство с его API
- Сложность отладки: Стеки вызовов сгенерированного JIT кода должны быть совместимы со стеками вызовов интерпретатора, маппинг отладочной информации требует дополнительной обработки
- Задержка холодного старта: В первые секунды после запуска программы нет JIT ускорения, требуется накопление нагрева
- Платформенный ABI: mmap и соглашения о вызовах для разных платформ (Linux/macOS/Windows) требуют отдельной адаптации
Согласованность с связанными RFC
| RFC | Согласованность |
|---|---|
| RFC-018 LLVM AOT | ✅ Совместное использование IR нормализационного pass, JIT и AOT — параллельные бэкенды |
| RFC-024 spawn块 параллелизм | ✅ Блоки spawn компилируются в вызовы runtime функций |
| RFC-008 Архитектура Runtime | ✅ Все три уровня runtime (Embedded/Standard/Full) поддерживают JIT |
Альтернативные решения
| Решение | Почему не выбрано |
|---|---|
| Только LLVM AOT, без JIT | При разработке нужно перекомпилировать всю программу, теряется быстрая итерация |
| LLVM ORC JIT | Высокая задержка компиляции (10-100 мс), большая зависимость LLVM, не подходит для встраивания в VM |
| Собственный лёгкий JIT (dynasm) | Высокая стоимость поддержки рукописного бэкенда, Cranelift более зрелый |
| Template JIT | Нулевая оптимизация, низкое качество кода, напрасная трата времени на JIT компиляцию |
| Whole-program JIT (без интерпретатора) | Медленный холодный старт, простые скрипты не стоят компиляции |
Зависимости
- RFC-018(LLVM AOT)→ Совместное использование IR нормализационного pass
- RFC-024(Параллелизм блоков spawn)→ JIT компиляция блоков spawn
- RFC-008(Архитектура Runtime)→ Поддержка JIT тремя уровнями runtime
- Cranelift crate → JIT бэкенд
Справочные материалы
- Cranelift IR Documentation
- 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/ | Становится официальным проектным документом |
