Долгое время быстрая JIT-компиляция считалась чем-то вроде чёрной магии. Чтобы написать быстрый JIT-компилятор, нужно было уметь писать ассемблер. Показательный факт: сегодня нет ни одной production-ready базы данных с собственным JIT-компилятором — все либо используют LLVM, либо генерируют код на C/C++. У обоих подходов есть общая проблема — высокое время компиляции, которое ограничивает область применения. Теперь, с использованием AI, написать JIT-компилятор с быстрой компиляцией, напрямую генерирующий ассемблер, стало значительно проще. Это же открывает возможность новым базам данных обойти старые. При разработке pgrust изначально казалось, что реализовать JIT-компилятор будет очень сложно. В итоге всё оказалось намного проще благодаря помощи AI, и это одна из причин, почему pgrust работает так быстро. JIT-компилятор в pgrust компилирует код примерно за 5 микросекунд, что позволяет JIT-компилировать каждый SQL-запрос, а не только часть из них. Дальше — пошаговый разбор того, как построить собственный быстрый JIT-компилятор, на примере простого движка регулярных выражений, использующего JIT-компиляцию.

Зачем нужна JIT-компиляция

JIT-компиляция — это генерация скомпилированного кода во время выполнения программы, «точно вовремя» (Just In Time). При правильной реализации это даёт заметный прирост производительности — часто в 2-5 раз, а иногда и больше. Основной сценарий применения JIT-компиляции — ситуации, когда информация, полученная во время выполнения, кардинально меняет поведение программы. Это особенно характерно для интерпретаторов языков программирования: код для выполнения они получают именно во время работы. JIT-компиляторы полезны и за пределами языков программирования — например, при разборе данных. Иногда схема разбираемых данных неизвестна до момента выполнения, и здесь JIT тоже может помочь.

Для начала реализуем игрушечный движок регулярных выражений. Для простоты будет поддерживаться всего две возможности: литеральные строки и повторение (то есть регекс-звёздочка *). Парсер тоже опустим — регулярное выражение будет представлено уже разобранными структурами Rust. Это значит, что получится обрабатывать строки вроде:

  • apples
  • b(an)*

но не будет поддержки альтернации, lookbehind и подобного.

В коде всё довольно просто. Будет три типа узлов: узел литеральной строки, узел повторения и узел конкатенации — комбинация двух узлов. Выглядит это так:

enum Node {
    Literal(&'static str),
    Concatenation(Box<Node>, Box<Node>),
    Repetition(Box<Node>),
}

fn literal(text: &'static str) -> Node {
    Node::Literal(text)
}

fn concatenation(left: Node, right: Node) -> Node {
    Node::Concatenation(Box::new(left), Box::new(right))
}

fn repetition(body: Node) -> Node {
    Node::Repetition(Box::new(body))
}

Написать интерпретатор для этого движка регулярных выражений тоже несложно:

fn match_node(node: &Node, input: &[u8], pos: usize, next: &dyn Fn(usize) -> bool) -> bool {
    match node {
        Node::Literal(text) => {
            let literal = text.as_bytes();
            input[pos..].starts_with(literal) && next(pos + literal.len())
        }

        Node::Concatenation(left, right) => {
            match_node(left, input, pos, &|left_end| {
                match_node(right, input, left_end, next)
            })
        }

        Node::Repetition(body) => {
            match_node(body, input, pos, &|body_end| {
                match_node(node, input, body_end, next)
            }) || next(pos)
        }
    }
}

fn interp_match(regex: &Node, input: &str) -> bool {
    let bytes = input.as_bytes();
    match_node(regex, bytes, 0, &|pos| pos == bytes.len())
}

Этот движок регулярных выражений получился весьма простым — меньше 20 строк кода. Но как обстоят дела с производительностью? Для сравнения возьмём код, написанный вручную специально под конкретный regex. В качестве примера используем регулярное выражение b(an)*. Написанный вручную код выглядит так:

fn handwritten_b_an_star(input: &str) -> bool {
    let bytes = input.as_bytes();
    let mut pos = 0;

    if pos == bytes.len() || bytes[pos] != b'b' {
        return false;
    }
    pos += 1;

    while pos < bytes.len() {
        if bytes[pos] != b'a' {
            return false;
        }
        pos += 1;
        if pos == bytes.len() || bytes[pos] != b'n' {
            return false;
        }
        pos += 1;
    }
    true
}

(Этот код можно было бы оптимизировать и сделать значительно быстрее, но для наших целей он служит хорошей точкой сравнения.)

При бенчмарке нескольких примеров ручная версия оказалась в 10-20 раз быстрее интерпретатора. Явно есть куда расти.

Дальше рассмотрим, как с помощью JIT-компиляции получить универсальный движок регулярных выражений, который работает так же быстро, как ручная реализация.

Как выполнить JIT-компиляцию

JIT-компиляция кода состоит из двух шагов. Сначала генерируется ассемблерный код для нужной операции. Затем этот код упаковывается в функцию, которую можно вызывать из программы как обычную.

Для генерации ассемблера используется вариант подхода copy-and-patch. Идея в том, что для разных операций, которые нужно JIT-компилировать, заранее готовятся шаблоны ассемблерного кода — их называют «трафаретами» (stencils). Когда нужно JIT-скомпилировать операцию, берётся соответствующий трафарет и вносятся небольшие правки в зависимости от специфики операции — очень похоже на заполнение настоящего трафарета. Соединяя несколько таких заполненных трафаретов, можно во время выполнения собрать программу, по производительности близкую к написанной вручную.

Путь такой: сначала рассматривается ARM64-код, который нужно сгенерировать для b(an)*. Затем повторяющиеся последовательности инструкций превращаются в переиспользуемые трафареты, пишется эмиттер, который заполняет и комбинирует эти трафареты на основе AST регулярного выражения, и, наконец, сгенерированные инструкции копируются в исполняемую память, чтобы Rust мог вызывать их как обычную функцию.

Проще всего разобраться в этом, начав с готового сгенерированного кода и двигаясь назад к самому JIT-компилятору. Снова используем регулярное выражение «b(an)*». Ключевые решения по дизайну:

  • Для бэктрекинга используется стек. Стек хранит состояние, к которому нужно вернуться при попадании в тупик регулярного выражения
  • Строка, с которой идёт сопоставление, заканчивается нулевым байтом. Это значит, что любое сравнение символов автоматически провалится при достижении конца строки — и не придётся нигде сравнивать длину

Для состояния программы используются следующие регистры:

  • x0 — текущая позиция в строке и возвращаемое значение
  • x1 — вершина стека для бэктрекинга
  • x2 — дно стека для бэктрекинга (нужно, чтобы определить, пуст ли стек)
  • x9 — используется как временная переменная

Во входные параметры программы передаются:

  • x0 — указатель на начало строки
  • x1 — указатель на область памяти под стек

Сгенерированный ARM64-код

Теперь, разобравшись с этим, пройдёмся по сгенерированному ассемблеру блок за блоком. Пример приведён конкретно для macOS на ARM64. Сначала идёт пролог, инициализирующий программу. Он просто устанавливает вершину и дно стека равными переданному значению:

0:  aa0103e2   mov   x2, x1

Дальше — код, проверяющий символ b. Если встречается символ, отличный от b, происходит переход к блоку обработки отката. В противном случае позиция в строке продвигается вперёд:

; CHAR 'b'
  4:  39400009   ldrb  w9, [x0]                  ; load current input byte
  8:  7101893f   cmp   w9, #0x62                 ; is it 'b'?
  c:  54000281   b.ne  0x5c                      ; no -> fallback block
 10:  91000400   add   x0, x0, #1                ; yes -> advance input

Далее — повторение (an)*. Для повторения нужен бэктрекинг. Если откат происходит здесь, это значит переход сразу к концу цикла. Поэтому в стек нужно сохранить и адрес инструкции после цикла, и текущую позицию в строке:

 14:  d2800989   movz  x9, #0x004c               ; build resume address
 18:  f2a00009   movk  x9, #0x0000, lsl #16      ;   = 0x1_0000_004c
 1c:  f2c00029   movk  x9, #0x0001, lsl #32      ;   (the loop exit)
 20:  f2e00009   movk  x9, #0x0000, lsl #48      ;
 24:  a8810029   stp   x9, x0, [x1], #16         ; push (exit, pos) onto stack

После этого можно выполнять тело повторения. Оно проверяет символы «a» и «n» и, если они найдены, возвращается к началу повторения, но уже с новой позицией в строке.

; CHAR 'a'
 28:  39400009   ldrb  w9, [x0]
 2c:  7101853f   cmp   w9, #0x61                 ; 'a'?
 30:  54000161   b.ne  0x5c                      ; no -> fallback block
 34:  91000400   add   x0, x0, #1

; CHAR 'n'
 38:  39400009   ldrb  w9, [x0]
 3c:  7101b93f   cmp   w9, #0x6e                 ; 'n'?
 40:  540000e1   b.ne  0x5c                      ; no -> fallback block
 44:  91000400   add   x0, x0, #1

; JMP
 48:  17fffff3   b     0x14                      ; back to top of loop

Теперь цикл пройден. Именно сюда попадёт выполнение после отката. По завершении повторения достигается конец регулярного выражения. Остаётся проверить, достигнут ли конец строки. Если да — возвращается 1 (успех). Если нет — регулярное выражение не совпало, и нужно выполнить логику отката (fallback):

 4c:  39400009   ldrb  w9, [x0]
 50:  35000069   cbnz  w9, 0x5c                  ; not at NUL -> fallback block
 54:  d2800020   mov   x0, #1                    ; success
 58:  d65f03c0   ret

И наконец, логика отката. Она проверяет, пуст ли стек. Если да — возвращается 0. Если нет — со стека снимаются адрес возврата и позиция в строке, после чего выполняется переход на этот адрес.

 5c:  eb02003f   cmp   x1, x2                    ; any frames left?
 60:  54000060   b.eq  0x6c                      ; no -> give up
 64:  a9ff0029   ldp   x9, x0, [x1, #-16]!       ; pop (resume, pos)
 68:  d61f0120   br    x9                        ; jump there
 6c:  d2800000   mov   x0, #0                    ; no match
 70:  d65f03c0   ret

Построение трафаретов

Теперь, увидев скомпилированный код, легче понять принцип работы copy-and-patch компилятора. Есть общие наборы инструкций с небольшими различиями между собой. Для каждого такого блока функций можно написать функцию, генерирующую соответствующий код. Каждая функция принимает значения, которые нужно вставить в код. Например, одним из аргументов stencil_char будет символ регулярного выражения, с которым идёт сравнение — этот символ вставляется прямо в машинный код.

Пролог реализуется просто, поскольку это статичный блок кода:

const PROLOGUE_WORDS: usize = 1;

fn stencil_prologue() -> [u32; PROLOGUE_WORDS] {
    [0xAA0103E2] // mov x2, x1
}

Для сравнения символов нужно вставить сравниваемый символ и адрес перехода для логики отката:

const CHAR_WORDS: usize = 4;

fn stencil_char(byte: u8, stencil_pos: usize, fail_pos: usize) -> [u32; CHAR_WORDS] {
    [
        0x39400009,                                                 // ldrb w9, [x0]
        0x7100013F | ((byte as u32) << 10),                         // cmp  w9, #byte
        0x54000001 | cond_branch_offset(stencil_pos + 2, fail_pos), // b.ne fail
        0x91000400,                                                 // add  x0, x0, #1
    ]
}

Для повторения — начало цикла, помещающее данные в стек, и переход в конец:

const SPLIT_WORDS: usize = 5;

fn stencil_split(resume_addr: u64) -> [u32; SPLIT_WORDS] {
    [
        0xD2800009 | addr_bits(resume_addr, 0), // movz x9, #addr[0..16]
        0xF2A00009 | addr_bits(resume_addr, 1), // movk x9, #addr[16..32], lsl 16
        0xF2C00009 | addr_bits(resume_addr, 2), // movk x9, #addr[32..48], lsl 32
        0xF2E00009 | addr_bits(resume_addr, 3), // movk x9, #addr[48..64], lsl 48
        0xA8810029,                             // stp  x9, x0, [x1], #16
    ]
}

const JMP_WORDS: usize = 1;

fn stencil_jmp(stencil_pos: usize, target_pos: usize) -> [u32; JMP_WORDS] {
    [0x14000000 | branch_offset(stencil_pos, target_pos)] // b target
}

И, наконец, блоки match и fail — они получаются довольно чистыми:

const MATCH_WORDS: usize = 4;

fn stencil_match(stencil_pos: usize, fail_pos: usize) -> [u32; MATCH_WORDS] {
    [
        0x39400009,                                                 // ldrb w9, [x0]
        0x35000009 | cond_branch_offset(stencil_pos + 1, fail_pos), // cbnz w9, fail
        0xD2800020,                                                 // mov  x0, #1
        0xD65F03C0,                                                 // ret
    ]
}

const FAIL_WORDS: usize = 6;

fn stencil_fail() -> [u32; FAIL_WORDS] {
    [
        0xEB02003F, // cmp  x1, x2
        0x54000060, // b.eq +3 (to the mov below)
        0xA9FF0029, // ldp  x9, x0, [x1, #-16]!
        0xD61F0120, // br   x9
        0xD2800000, // mov  x0, #0
        0xD65F03C0, // ret
    ]
}

Для полноты картины — вспомогательные функции, которые помогают вставлять конкретные данные в инструкции:

// Compute the branch-offset field for a conditional branch (b.ne / cbnz):
// the instruction count from branch to target, stored in bits 5..24.
fn cond_branch_offset(branch_pos: usize, target_pos: usize) -> u32 {
    let instr_count = target_pos as i64 - branch_pos as i64; // may be negative
    (((instr_count as u64) & 0x7FFFF) << 5) as u32
}

// Compute the branch-offset field for an unconditional branch (b):
// same idea, but stored in bits 0..26.
fn branch_offset(branch_pos: usize, target_pos: usize) -> u32 {
    let instr_count = target_pos as i64 - branch_pos as i64; // may be negative
    ((instr_count as u64) & 0x3FF_FFFF) as u32
}

// Extract 16 bits of an absolute address, positioned for a movz/movk immediate.
fn addr_bits(addr: u64, part: usize) -> u32 {
    (((addr >> (16 * part)) & 0xFFFF) as u32) << 5
}

Генерация кода

Теперь код, управляющий этим процессом:

// Computes how many instructions a node compiles to.
fn node_words(node: &Node) -> usize {
    match node {
        Node::Literal(text) => text.len() * CHAR_WORDS,
        Node::Concatenation(left, right) => node_words(left) + node_words(right),
        Node::Repetition(body) => SPLIT_WORDS + node_words(body) + JMP_WORDS,
    }
}

struct Emitter {
    code: Vec<u32>,
    fail: usize, // word offset of the shared fail block
    base: u64,   // runtime address of code[0], for absolute-address holes
}

impl Emitter {
    // Returns the offset where the next instruction will be placed.
    fn pos(&self) -> usize {
        self.code.len()
    }

    // Appends a filled stencil to the code buffer.
    fn emit(&mut self, stencil: &[u32]) {
        self.code.extend_from_slice(stencil);
    }

    // Emits the code for one node, recursing into children.
    fn emit_node(&mut self, node: &Node) {
        match node {
            Node::Literal(text) => {
                for &byte in text.as_bytes() {
                    self.emit(&stencil_char(byte, self.pos(), self.fail));
                }
            }
            Node::Concatenation(left, right) => {
                self.emit_node(left);
                self.emit_node(right);
            }
            Node::Repetition(body) => {
                let split_at = self.pos();
                let exit = split_at + SPLIT_WORDS + node_words(body) + JMP_WORDS;
                self.emit(&stencil_split(self.base + exit as u64 * 4));
                self.emit_node(body);
                self.emit(&stencil_jmp(self.pos(), split_at));
            }
        }
    }
}

// Generates the complete program: prologue, the compiled AST, MATCH, fail block.
fn generate_code(regex: &Node, base: u64) -> Vec<u32> {
    let nwords = PROLOGUE_WORDS + node_words(regex) + MATCH_WORDS + FAIL_WORDS;
    let mut emitter = Emitter {
        code: Vec::with_capacity(nwords),
        fail: nwords - FAIL_WORDS,
        base,
    };
    emitter.emit(&stencil_prologue());
    emitter.emit_node(regex);
    let match_at = emitter.pos();
    emitter.emit(&stencil_match(match_at, emitter.fail));
    emitter.emit(&stencil_fail());
    assert_eq!(emitter.pos(), nwords);
    emitter.code
}

И это самая сложная часть! По личному опыту, именно при написании ассемблера AI помогает больше всего. Основной опыт с ассемблером до этого ограничивался прохождением CTF microcorruption — сам ассемблер писать не приходилось никогда. Без AI было бы очень сложно разобраться, какие именно инструкции нужны и как их модифицировать для получения нужного результата. С AI можно объяснить кодинг-агенту общую идею того, как должен работать JIT-компилятор, и он берёт на себя множество таких деталей.

Загрузка машинного кода

Чтобы завершить компилятор, нужно фактически загрузить код. Для этого используется mmap — выделяется блок памяти, доступный для чтения, записи и исполнения. Затем код копируется в эту память, а сам блок памяти превращается в функцию, которую можно вызывать:

const BSTACK_MAX: usize = 4096;

// These functions are included in the mac system library
unsafe extern "C" {
    fn pthread_jit_write_protect_np(enabled: libc::c_int);
    fn sys_icache_invalidate(start: *mut libc::c_void, len: libc::size_t);
}

type MatchFn = unsafe extern "C" fn(input: *const u8, bstack: *mut u64) -> u64;

struct Jit {
    buf: *mut u32,
    nbytes: usize,
    bstack: Vec<u64>,
}

impl Jit {
    fn compile(regex: &Node) -> Jit {
        let nwords = PROLOGUE_WORDS + node_words(regex) + MATCH_WORDS + FAIL_WORDS;
        let nbytes = nwords * 4;

        unsafe {
            let buf = libc::mmap(
                std::ptr::null_mut(),
                nbytes,
                libc::PROT_READ | libc::PROT_WRITE | libc::PROT_EXEC,
                libc::MAP_PRIVATE | libc::MAP_ANON | libc::MAP_JIT,
                -1,
                0,
            ) as *mut u32;
            assert!(buf as *mut libc::c_void != libc::MAP_FAILED, "mmap failed");

            let code = generate_code(regex, buf as u64);

            pthread_jit_write_protect_np(0); // make the region writable (Apple W^X)
            std::slice::from_raw_parts_mut(buf, code.len()).copy_from_slice(&code);
            pthread_jit_write_protect_np(1); // back to executable
            sys_icache_invalidate(buf as *mut libc::c_void, nbytes);

            Jit { buf, nbytes, bstack: vec![0; BSTACK_MAX * 2] }
        }
    }

    // Runs the generated code. Input must end with a NUL byte.
    fn is_match(&mut self, nul_terminated: &[u8]) -> bool {
        debug_assert_eq!(nul_terminated.last(), Some(&0));
        unsafe {
            let matcher: MatchFn = std::mem::transmute(self.buf);
            matcher(nul_terminated.as_ptr(), self.bstack.as_mut_ptr()) != 0
        }
    }
}

impl Drop for Jit {
    fn drop(&mut self) {
        unsafe {
            libc::munmap(self.buf as *mut libc::c_void, self.nbytes);
        }
    }
}

Результаты

После реализации всех вариантов можно сравнить их производительность:

Длина входной строки Интерпретатор JIT Написанный вручную Ускорение JIT Ускорение ручного кода
9 45 нс 3.8 нс 3.8 нс 11.7x 11.9x
33 103 нс 7.9 нс 10.5 нс 13.0x 9.8x
129 597 нс 30 нс 32 нс 19.7x 18.6x
513 1 955 нс 126 нс 120 нс 15.5x 16.2x
2 049 8 301 нс 470 нс 393 нс 17.7x 21.1x

JIT-версия и написанная вручную реализация показывают почти одинаковую производительность — иногда быстрее оказывается JIT, иногда ручной код.

В последнее время распространён мем о том, что AI не особо помогает, потому что «код никогда не был самой сложной частью». Это верно для некоторых областей, но в других написание кода как раз и было самым сложным. JIT-компиляторы — отличный тому пример. Для многих программ JIT-компилятор мог бы значительно ускорить работу. Редкость JIT-компиляторов говорит о том, что исторически их реализация была слишком сложной, чтобы игра стоила свеч. LLM снизили порог входа и заметно упростили написание JIT-компилятора. Именно эта идея лежит в основе pgrust. Базы данных исторически были одним из самых сложных видов программного обеспечения, и это их ограничивало. Теперь, с AI, можно быть куда амбициознее в выборе того, какое программное обеспечение создавать.