Skip to content

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 JITLLVM 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, проходы нормализации), стандартной библиотеки и крейта Cranelift
  • backends/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) два атомарных счётчика:

rust
// FunctionEntry 的热度字段(完整定义见 §4.1)
invocation_count: AtomicU32,   // 函数被调用次数
backedge_count: AtomicU32,     // 循环回边跳转次数
state: AtomicU8,              // Cold | Warm | Hot | Compiled

2.3 Механизм затухания ​

Каждые 5 секунд все счётчики сдвигаются вправо на 1 бит (умножение на 0.5). Это предотвращает запуск JIT-компиляции для кода, который во время старта выполняется часто, но лишь однажды (например, инициализационный обход).

rust
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Описание
Inti64
Int32i32
Floatf64
Float32f32
Booli8Cranelift не имеет i1, используется i8
Chari32Unicode-кодпойнт
String{ i64, i64 }Указатель + длина
VoidПустой кортеж
&T—Нулевой размер, исчезает после компиляции
&mut T—Нулевой размер, исчезает после компиляции
ref T{ i64, i64 }Указатель счётчика ссылок + указатель данных
*Ti64Голый указатель
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 ​

rust
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-операция:

rust
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 Управление исполняемой памятью ​

rust
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 для одной функции; операции уровня модуля откладываются на горячую перезагрузку.

rust
/// 代码缓存扩展接口(预留,不实现)
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 по каждой функции — последнее при наличии циклических зависимостей между функциями привело бы к несогласованному состоянию.

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

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

  1. Нулевая воспринимаемая задержка компиляции: Cranelift 1-5 мс/функция, компиляция в фоновом потоке, интерпретатор не приостанавливается
  2. Совместная инфраструктура: JIT и AOT совместно используют проход IR-нормализации (RFC-018 §4.0), дублирования нет
  3. Без разрушительных изменений: чисто инкрементальная функциональность. VM не меняется, интерпретатор не меняется, просто добавляется более быстрый горячий путь
  4. Без зависимости от LLVM: VM не вводит LLVM, остаётся лёгкой
  5. Естественная поддержка нескольких платформ: Cranelift изначально поддерживает x86_64 и ARM64, покрывая все целевые платформы
  6. Резерв для горячей перезагрузки: кэш кода группируется по модулям + косвенный переход через вход функции, закладывает структурную основу для будущей горячей перезагрузки

Недостатки ​

  1. Новая зависимость Cranelift: вводится новый внешний крейт, требуется освоить его API
  2. Сложность отладки: кадры стека JIT-кода должны быть совместимы с кадрами стека интерпретатора, требуется дополнительная обработка отображения отладочной информации
  3. Задержка прогрева при холодном старте: первые несколько секунд после запуска программы нет ускорения JIT, требуется накопление горячих точек
  4. 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

Список литературы ​


Жизненный цикл и местонахождение ​

СостояниеРасположениеОписание
Черновикdocs/src/design/rfc/draft/Черновик автора, ожидает подачи на рассмотрение
На рассмотренииdocs/src/design/rfc/review/Открыто для обсуждения и обратной связи сообщества
Принятоdocs/src/design/rfc/accepted/Становится официальным проектным документом