Проєктування інтерпретатора Lisp на Rust без переповнень стеку

Глибоке занурення в ітеративне ядро risp: enum Value, написаний вручну ітеративний Drop та обчислювач CEK з явним стеком, які читають, друкують, порівнюють, звільняють і обчислюють структуру глибиною в мільйон без переповнення стеку Rust.

Джерело: github.com/kissishka/risp

Інтерпретатор Lisp — це здебільшого рекурсія по деревах, а рекурсія по наданих користувачем деревах — це переповнення стеку, яке тільки й чекає нагоди. Проєктне правило, що стоїть за risp, було безапеляційним: жоден вхід ніколи не повинен аварійно завершувати роботу хоста. Ні список на мільйон елементів, ні рекурсія глибиною в мільйон, ні глибоко вкладений літерал. Це єдине правило сформувало все ядро, і виявляється, що захищати його доводиться у п’яти різних місцях.

Один enum — це і програма, і дані

risp гомоіконічний за побудовою: єдиний enum Value є і розібраним AST, і значенням під час виконання.

/// The one enum that is both AST and runtime value.
#[derive(Clone, Debug)]
pub enum Value {
    Nil,
    Bool(bool),
    Int(i64),
    Float(f64),
    Str(Rc<str>),
    Symbol(Rc<str>),
    /// A cons cell: the building block of lists AND of program forms.
    Pair(Rc<Pair>),
    Builtin(Rc<Builtin>),
    Closure(Rc<Closure>),
    /// A macro: like a closure, but invoked on its UNEVALUATED arguments.
    Macro(Rc<Closure>),
    Compiled(Rc<ClosureObj>),
}

pub struct Pair {
    pub car: Value,
    pub cdr: Value,
}

Коли читач розбирає (+ 1 2), він видає Value::Pair, чий carValue::Symbol("+"), а чий cdr — це ланцюжок із інших пар; обчислювач оперує саме цією структурою без окремого типу AST. Кожен вантаж у купі лежить за Rc, тож Value: Clone завжди має складність O(1): клонування Pair збільшує один лічильник посилань, воно не копіює список. А оскільки Pair — це просто два слоти Value, car/cdr/cons є операціями над полями зі складністю O(1). Зверніть увагу, що Macro повторно використовує Rc<Closure>: макрос структурно є замиканням із іншим тегом enum, і цей тег — це і є вся макросистема.

Прихована небезпека: звільнення довгого списку

Ось пастка, у яку потрапляють усі. Cons-комірки за Rc безкоштовно отримують рекурсивний деструктор: звільнення Pair звільняє її cdr, що звільняє cdr наступної Pair, і так далі. Звільніть список завдовжки 500 000 елементів, і ось вам рекурсія на 500 000 кадрів у стеку викликів Rust: миттєве переповнення. risp замінює похідний деструктор написаним вручну ітеративним:

impl Drop for Pair {
    fn drop(&mut self) {
        // Seed the shared iterative teardown with this cell's two slots so a long
        // `cdr` spine or deep `car` nest is reclaimed without recursing.
        dismantle(vec![
            Teardown::Val(std::mem::replace(&mut self.car, Value::Nil)),
            Teardown::Val(std::mem::replace(&mut self.cdr, Value::Nil)),
        ]);
    }
}

Хитрість у mem::replace: замість того, щоб дати полям звільнятися на місці (що призвело б до рекурсії), він переміщує їх назовні, залишаючи позаду нешкідливі Nil, і передає їх у цикл:

pub(crate) fn dismantle(mut stack: Vec<Teardown>) {
    while let Some(item) = stack.pop() {
        match item {
            Teardown::Val(Value::Pair(rc)) => {
                if let Ok(mut p) = Rc::try_unwrap(rc) {
                    stack.push(Teardown::Val(std::mem::replace(&mut p.car, Value::Nil)));
                    stack.push(Teardown::Val(std::mem::replace(&mut p.cdr, Value::Nil)));
                }
            }
            Teardown::Val(Value::Closure(rc)) | Teardown::Val(Value::Macro(rc)) => {
                if let Ok(c) = Rc::try_unwrap(rc) {
                    let Closure { env, .. } = c;
                    stack.push(Teardown::Env(env));   // closures capture scopes too
                }
            }
            Teardown::Val(_) => {}
            Teardown::Env(e) => {
                if let Ok(cell) = Rc::try_unwrap(e) {
                    let mut env = cell.into_inner();
                    let (parent, vals) = env.take_children();
                    if let Some(p) = parent { stack.push(Teardown::Env(p)); }
                    stack.extend(vals.into_iter().map(Teardown::Val));
                }
            }
        }
    }
}

Це класичний обхід графа з явним стеком. Ключовий хід: дочірні елементи вузла проштовхуються на робочий стек у купі, перш ніж звільниться сам вузол, тож його власний Drop завжди знаходить порожні слоти Nil і повертається за O(1). Жоден ланцюжок не може рекурсувати. Rc::try_unwrap — це те, що робить процес безпечним зі спільною структурою: він спускається в комірку лише тоді, коли цей власник останній, а ще спільна комірка залишається своєму останньому власнику, щоб той звільнив її пізніше. Enum Teardown об’єднує дві небезпеки в один робочий стек: довгі ланцюжки Value та глибокі ланцюжки областей видимості, захоплені замиканнями, тож звільнення замикання, яке закрилося над тисячею вкладених кадрів let, так само пласке. Є тест, який будує структури глибиною 200 000 у потоці зі стеком на 256 KiB і звільняє їх; рекурсивний Drop миттєво підірвав би це.

Обчислення — це цикл, а не рекурсія

Той самий принцип керує обчисленням. Обчислювач risp — це машина CEK з явним стеком: керувальний регістр з двома варіантами та стек кадрів продовження (continuation) у купі.

/// The machine's control register: an expression to evaluate, or a value
/// being handed back to the top continuation.
enum St {
    Eval(Value, Env),
    Ret(Value),
}

enum Frame {
    App  { pending: Vec<Value>, vals: Vec<Value>, env: Env },
    Seq  { rest: Vec<Value>, env: Env },
    If   { conseq: Value, alt: Option<Value>, env: Env },
    Let  { names: Vec<Rc<str>>, pending: Vec<Value>, vals: Vec<Value>, body: Vec<Value>, env: Env },
    And  { rest: Vec<Value>, env: Env },
    // ... Def, Set, Or, Cond, MacroExpand
}

Увесь обчислювач — це один цикл, який чергує два варіанти:

/// The single drive loop: alternately evaluate an expression or feed a value to
/// the top frame, until the frame stack empties.
fn run_loop(mut st: St, mut stack: Vec<Frame>) -> RispResult {
    loop {
        st = match st {
            St::Eval(expr, env) => step_eval(expr, env, &mut stack)?,
            St::Ret(val) => match stack.pop() {
                None => return Ok(val),
                Some(frame) => step_return(frame, val, &mut stack)?,
            },
        };
    }
}

Увесь стан обчислювача в будь-який момент — це (St, Vec<Frame>). Стек викликів Rust залишається завглибшки в три кадри (run_loopstep_eval/step_return), хоч би якою глибокою була програма.

Хвостові виклики у сталому просторі

Коли застосовується замикання, apply_value не рекурсує: він повертає тіло як наступний St::Eval:

fn apply_value(callable: Value, args: Vec<Value>, stack: &mut Vec<Frame>) -> Result<St, RispError> {
    match callable {
        Value::Builtin(b) => (b.func)(&args).map(St::Ret),
        Value::Closure(c) => {
            let frame = child(&c.env);
            bind_params(&c, &args, &frame)?;
            // Fast path for the common single-form body: tail-evaluate it.
            if c.body.len() == 1 {
                Ok(St::Eval(c.body[0].clone(), frame))
            } else {
                Ok(begin_sequence(c.body.to_vec(), frame, stack))
            }
        }
        other => Err(RispError::NotCallable(other.to_string())),
    }
}

run_loop просто перепризначає st і продовжує. Якщо останній вираз тіла — це ще один виклик, step_eval проштовхує один Frame::App, розв’язує його і викликає apply_value знову, який знову повертає St::Eval. Хвостоворекурсивна функція замінює поточне обчислення, а не нагромаджується поверх нього, тож Vec<Frame> у купі залишається завглибшки O(1) для будь-якої кількості ітерацій. Frame::Seq робить це автоматичним: остання форма будь-якого тіла входить із голим St::Eval і без нового кадру, тож вона завжди в належній хвостовій позиції.

Нехвостова вкладеність нарощує купу, а не стек

А як щодо справді нехвостової рекурсії, як-от (+ 1 (+ 1 (+ 1 …))) глибиною в мільйон? Обчислення зовнішнього + проштовхує Frame::App у Vec купи і переходить до внутрішнього виклику, який проштовхує наступний, і так далі. Мільйон вкладених викликів породжує мільйон записів Frame::App у векторі купи, який може вирости до гігабайтів, тоді як стек викликів Rust, обмежений кількома мегабайтами, не росте взагалі. Тож це повертає відповідь там, де CPython піднімає RecursionError на глибині ~1000:

(def sum-to (lambda (n acc) (if (= n 0) acc (sum-to (- n 1) (+ acc n)))))
(sum-to 1000000 0)   ; => 500000500000

Усе, що торкається даних користувача, є ітеративним

Щойно ви дотримуєтесь цього правила, воно має виконуватися всюди, інакше найслабший обхід стає аварією. Тож структурна рівність виконується на явному робочому стеку з множиною відвіданих вузлів, яка ще й згортає спільні DAG до лінійного часу:

pub fn risp_equal(a: &Value, b: &Value) -> bool {
    let mut work: Vec<(Value, Value)> = vec![(a.clone(), b.clone())];
    let mut visited: HashSet<(usize, usize)> = HashSet::new();
    while let Some((p, q)) = work.pop() {
        match (&p, &q) {
            (Value::Pair(x), Value::Pair(y)) => {
                if Rc::ptr_eq(x, y) { continue; }
                let key = (Rc::as_ptr(x) as usize, Rc::as_ptr(y) as usize);
                if !visited.insert(key) { continue; }
                work.push((x.car.clone(), y.car.clone()));
                work.push((x.cdr.clone(), y.cdr.clone()));
            }
            _ => { if !risp_eq(&p, &q) { return false; } }
        }
    }
    true
}

Те саме стосується і друку: Display чергує на стеку кроки «відрендерити це підзначення» та «вивести цей літеральний токен», розкручуючи хребет списку в щільному внутрішньому циклі, тож плаский список на 500 000 елементів друкується без зростання стеку:

enum Step { Render(Value), Raw(&'static str) }
let mut stack = vec![Step::Render(self.clone())];
while let Some(step) = stack.pop() {
    match step {
        Step::Raw(s) => f.write_str(s)?,
        Step::Render(v) if matches!(v, Value::Pair(_)) => {
            // unwind the cdr-spine into `seq`, pushed in reverse so it pops in order
            // ... then: stack.extend(seq.into_iter().rev());
        }
        Step::Render(atom) => write_atom(f, &atom)?,
    }
}

Те саме стосується і матеріалізації списку аргументів виклику (list_vec, щільний цикл cur = p.cdr.clone()), і обходу ланцюжка областей видимості для змінної. Читання, друк, структурна рівність, звільнення пам’яті та обчислення — усе є ітеративним. Єдина звичайна рекурсія, що лишилася в усьому інтерпретаторі, обмежена вихідним текстом, а не даними під час виконання: глибина вкладеності шаблона quasiquote.

Єдина ціна: пошук змінної

Ітеративна безпека не безкоштовна. Середовище — це HashMap на кожну область видимості плюс вказівник на батьківську, а пошук обходить ланцюжок:

pub fn lookup(env: &Env, name: &str) -> Option<Value> {
    let mut cursor = env.clone();
    loop {
        let next = {
            let frame = cursor.borrow();
            if let Some(v) = frame.vars.get(name) { return Some(v.clone()); }
            frame.parent.clone()
        };
        match next {
            Some(parent) => cursor = parent,
            None => return None,
        }
    }
}

Ітеративний, тож глибокий ланцюжок областей видимості коштує ітерацій у купі, а не кадрів стеку, але кожен крок — це хешування рядка та зондування HashMap. Для деревообхідного інтерпретатора це гарний компроміс. Це також найбільший розрив у продуктивності між наївним інтерпретатором і швидким, а усунення цього розриву (розв’язання кожної змінної до індексу масиву під час компіляції) — це саме те, що робить байткод-VM risp, щоб лишити деревообхідний інтерпретатор (і CPython) позаду.

Урок узагальнюється поза межами Lisp. У будь-якому інтерпретаторі кожен рекурсивний обхід керованих користувачем даних — це прихована аварія, яка проявляється лише на тому вході, який ви не протестували. Перетворіть кожен на цикл, і питання «наскільки глибоким може бути вхід?» перестане бути тим, чого варто боятися. Ця дисципліна, не випадково, є також тим, що робить безпечним будівництво агресивних швидких шляхів unsafe та нативного JIT поверх ядра, якому можна довіряти.

Часті запитання

Як зупинити деревообхідний інтерпретатор (tree-walker) від переповнення стеку?
Перетворіть кожен рекурсивний обхід даних користувача на цикл із явним стеком. risp використовує ітеративний обчислювач CEK, написаний вручну ітеративний Drop, а також друк і перевірку рівності на основі циклів, тож стек викликів Rust залишається завглибшки в кілька кадрів, хоч би якою глибокою була вхідна структура.
Чому звільнення довгого зв'язаного списку переповнює стек у Rust?
Cons-комірки за Rc отримують рекурсивний деструктор: звільнення однієї Pair звільняє її cdr, що звільняє наступну, і так далі. Звільнення списку з 500 000 елементів рекурсує на глибину 500 000 кадрів. risp замінює похідний Drop на ітеративний teardown, який переміщує дочірні елементи на робочий стек у купі.
Що таке машина CEK?
Машина CEK обчислює вирази за допомогою явного керувального регістра та стека кадрів продовження (continuation) у купі замість рекурсії хоста. Обчислювач risp — це один цикл по (St, Vec<Frame>), тож хвостові виклики виконуються в сталому просторі, а нехвостова рекурсія глибиною в мільйон нарощує вектор у купі, а не стек викликів Rust.

← Усі дописи