Skip to content

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 JITLLVM 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 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 нормализационный pass (стек → регистры/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
Запись 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

rust
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:

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()
}

Без паузы интерпретатора, без ожидания 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 Управление исполняемой памятью

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 нормализационный pass (RFC-018 §4.0), не изобретаем велосипед повторно
  3. Неразрушительность: Чисто добавочная функциональность. VM неизменна, интерпретатор неизменен, просто добавляется более быстрый горячий путь
  4. Без зависимости от LLVM: VM не引入 LLVM, сохраняется лёгкость
  5. Естественная поддержка нескольких платформ: Cranelift нативно поддерживает x86_64 и ARM64, охватывает все целевые платформы
  6. Резерв для горячей перезагрузки: Кэш кода группировка по модулям + косвенный переход точек входа функций закладывают структурную основу для будущей горячей перезагрузки

Недостатки

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

Справочные материалы


Жизненный цикл и судьба

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