La implementación de lenguajes de programación con características avanzadas como call-with-current-continuation presenta desafíos fundamentales en la gestión de continuaciones. Dos enfoques principales son la máquina virtual basada en bytecode con gestión de pila explícita o la transformación a Continuation-Passing Style (CPS). El CPS, aunque introduce una sobrecarga inicial significativa debido a la proliferación de clausuras, ofrece una representación intermedia (IR) canónica que facilita análisis y optimizaciones profundas, alineándose con el objetivo de maximizar el rendimiento. Este artículo explora la implementación de un compilador JIT basado en CPS para Scheme, destacando cómo la aplicación sistemática de optimizaciones de compilador es crucial para superar la penalización de rendimiento inherente a la transformación CPS y alcanzar la eficiencia de implementaciones maduras.

La decisión de adoptar CPS se basa en su capacidad para hacer explícitas las continuaciones, simplificando la implementación de call/cc y proporcionando un terreno fértil para optimizaciones. Sin embargo, la conversión directa a CPS a menudo genera un código verboso y de bajo rendimiento, como se demostró en la regresión inicial de 40ms a 350ms para la función Fibonacci. Esto subraya que la mera adopción de un IR avanzado no garantiza el rendimiento; es la calidad de las fases de optimización subsiguientes lo que determina el éxito de la estrategia de compilación.

Arquitectura del Sistema

El sistema scheme-rs evoluciona de un intérprete de Abstract Syntax Tree (AST) a un compilador JIT. El pipeline de compilación ahora incluye las siguientes etapas: el AST de Scheme se convierte a una representación intermedia en Continuation-Passing Style (CPS). Esta representación CPS se traduce posteriormente a LLVM Static Single Assignment (SSA) form, que es luego compilado Just-In-Time (JIT) a código máquina ejecutable. La integración con LLVM permite aprovechar su extenso conjunto de optimizaciones de backend.

Las optimizaciones clave implementadas incluyen: 1) Beta-reducción: una forma de inlining de funciones que elimina clausuras triviales generadas por la conversión CPS. Se aplica una heurística simple: si una función no es recursiva y se usa exactamente una vez en su expresión de continuación, su cuerpo se sustituye directamente en el sitio de llamada. Esto reduce significativamente el overhead de llamadas a funciones y la creación de clausuras. 2) Especialización de operadores primitivos: los operadores aritméticos y de comparación (+, -, *, /, =, <, <=, >, >=) se identifican y se compilan para invocar funciones de tiempo de ejecución dedicadas en lugar de llamadas a funciones Scheme o Rust genéricas. Esto evita la sobrecarga de la gestión de continuaciones para operaciones básicas. 3) Representación de tipos de valor: el tipo de valor original, una enumeración grande con Arc<Mutex<T>> para mutabilidad interior, se reemplaza por una representación de puntero etiquetado (tagged pointer). Esta técnica utiliza bits de baja orden de un puntero para almacenar un tipo de dato (tag), permitiendo que tipos pequeños se almacenen directamente en el puntero y reduciendo la indirección y el consumo de memoria. Se utiliza un tag de 4 bits, proporcionando 16 posibles tipos de valor, con un tipo 'Other' para valores menos frecuentes almacenados en una estructura OtherData alineada a 16 bytes. La API de Value se hace opaca para permitir futuras modificaciones de la representación interna sin romper la compatibilidad.

Pipeline de Compilación JIT con CPS

  1. 1 AST de Scheme Código fuente de Scheme parseado en un Árbol de Sintaxis Abstracta.
  2. 2 Conversión a CPS Transformación del AST a Continuation-Passing Style, haciendo explícitas las ...
  3. 3 Beta-Reducción Optimización de inlining para eliminar clausuras triviales generadas por CPS.
  4. 4 Especialización de PrimOps Identificación y compilación de operadores primitivos a funciones de runtime ...
  5. 5 Generación LLVM SSA Traducción del código CPS optimizado a la forma SSA de LLVM.
  6. 6 Compilación JIT LLVM compila el código SSA a código máquina ejecutable en tiempo de ejecución.
CapaTecnologíaJustificación
compute Continuation-Passing Style (CPS) Representación intermedia (IR) para hacer explícitas las continuaciones y facilitar optimizaciones de compilador, especialmente para `call/cc`. vs Máquina virtual basada en bytecode
compute LLVM SSA Backend de compilación JIT para generar código máquina optimizado a partir del IR CPS. vs IR más ligero
data-processing Tagged Pointers Representación eficiente de tipos de valor heterogéneos en tiempo de ejecución, reduciendo la indirección y el consumo de memoria. vs Enum con smart pointers (Arc<Mutex<T>>) Tag de 4 bits para 16 tipos de valor, alineación a 16 bytes para 'OtherData'.

Trade-offs

Ganancias
  • ▲▲ Rendimiento de ejecución
  • Control sobre la representación de tipos de valor
  • Facilidad para implementar `call/cc`
Costes
  • Complejidad del compilador
  • ▲▲ Rendimiento inicial tras conversión a CPS
  • Posible fragmentación de memoria (por tagged pointers con tags grandes)
impl Cps {
    pub(super) fn reduce(self) -> Self {
        // Perform beta reduction twice. This seems like the sweet spot for now
        self.beta_reduction(&mut HashMap::default())
            .beta_reduction(&mut HashMap::default())
    }

    /// Beta-reduction optimization step. This function replaces applications to
    /// functions with the body of the function with arguments substituted.
    ///
    /// Our initial heuristic is rather simple: if a function is non-recursive and
    /// is applied to exactly once in its continuation expression, its body is
    /// substituted for the application.
    ///
    /// The uses analysis cache is absolutely demolished and dangerous to use by
    /// the end of this function.
    fn beta_reduction(self, uses_cache: &mut HashMap<Local, HashMap<Local, usize>>) -> Self {
        match self {
            Cps::PrimOp(prim_op, values, result, cexp) => Cps::PrimOp(
                prim_op, values, result, Box::new(cexp.beta_reduction(uses_cache)),
            ),
            Cps::If(cond, success, failure) => Cps::If(
                cond,
                Box::new(success.beta_reduction(uses_cache)),
                Box::new(failure.beta_reduction(uses_cache)),
            ),
            Cps::Closure {
                args, body, val, cexp, debug,
            } => {
                let body = body.beta_reduction(uses_cache);
                let mut cexp = cexp.beta_reduction(uses_cache);
                let is_recursive = body.uses(uses_cache).contains_key(&val);
                let uses = cexp.uses(uses_cache).get(&val).copied().unwrap_or(0);

                // TODO: When we get more list primops, allow for variadic substitutions
                if !args.variadic && !is_recursive && uses == 1 {
                    let reduced = cexp.reduce_function(val, &args, &body, uses_cache);
                    if reduced {
                        uses_cache.remove(&val);
                        return cexp;
                    }
                }

                Cps::Closure {
                    args,
                    body: Box::new(body),
                    val,
                    cexp: Box::new(cexp),
                    debug,
                }
            }
            cexp => cexp,
        }
    }

    fn reduce_function(
        &mut self,
        func: Local,
        args: &ClosureArgs,
        func_body: &Cps,
        uses_cache: &mut HashMap<Local, HashMap<Local, usize>>,
    ) -> bool {
        let new = match self {
            Cps::PrimOp(_, _, _, cexp) => {
                return cexp.reduce_function(func, args, func_body, uses_cache)
            }
            Cps::If(_, succ, fail) => {
                return succ.reduce_function(func, args, func_body, uses_cache)
                    || fail.reduce_function(func, args, func_body, uses_cache)
            }
            Cps::Closure { val, body, cexp, .. } => {
                let reduced = body.reduce_function(func, args, func_body, uses_cache)
                    || cexp.reduce_function(func, args, func_body, uses_cache);
                if reduced {
                    uses_cache.remove(val);
                }
                return reduced;
            }
            Cps::App(Value::Var(Var::Local(operator)), applied, _) if *operator == func => {
                let substitutions: HashMap<_, _> = args
                    .to_vec()
                    .into_iter()
                    .zip(applied.iter().cloned())
                    .collect();
                let mut body = func_body.clone();
                body.substitute(&substitutions);
                body
            }
            Cps::App(_, _, _) | Cps::Forward(_, _) | Cps::Halt(_) => return false,
        };
        *self = new;
        true
    }
}
Implementación de la heurística de beta-reducción: si una clausura no es recursiva y se usa una sola vez en su continuación, se inlina su cuerpo.
impl Expression {
    pub fn to_primop(&self) -> Option<PrimOp> {
        use crate::{
            num::{
                add_builtin_wrapper,
                div_builtin_wrapper,
                equal_builtin_wrapper,
                greater_builtin_wrapper,
                greater_equal_builtin_wrapper,
                lesser_builtin_wrapper,
                lesser_equal_builtin_wrapper,
                mul_builtin_wrapper,
                sub_builtin_wrapper,
            },
            proc::{Closure, FuncPtr::Bridge},
        };

        if let Expression::Var(Var::Global(global)) = self {
            let val = global.value_ref().read().clone();
            let val: Gc<Closure> = val.try_into().ok()?;
            let val_read = val.read();
            match val_read.func {
                Bridge(ptr) if ptr == add_builtin_wrapper => Some(PrimOp::Add),
                Bridge(ptr) if ptr == sub_builtin_wrapper => Some(PrimOp::Sub),
                Bridge(ptr) if ptr == mul_builtin_wrapper => Some(PrimOp::Mul),
                Bridge(ptr) if ptr == div_builtin_wrapper => Some(PrimOp::Div),
                Bridge(ptr) if ptr == equal_builtin_wrapper => Some(PrimOp::Equal),
                Bridge(ptr) if ptr == greater_builtin_wrapper => Some(PrimOp::Greater),
                Bridge(ptr) if ptr == greater_equal_builtin_wrapper => Some(PrimOp::GreaterEqual),
                Bridge(ptr) if ptr == lesser_builtin_wrapper => Some(PrimOp::Lesser),
                Bridge(ptr) if ptr == lesser_equal_builtin_wrapper => Some(PrimOp::LesserEqual),
                _ => None,
            }
        } else {
            None
        }
    }
}
Función para identificar si una expresión corresponde a un operador primitivo conocido, basado en la dirección de memoria de su función de runtime.
#[repr(u64)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum ValueType {
    Undefined = 0,
    Null = 1,
    Boolean = 2,
    Character = 3,
    Number = 4,
    String = 5,
    Symbol = 6,
    Vector = 7,
    ByteVector = 8,
    Syntax = 9,
    Closure = 10,
    Record = 11,
    Condition = 12,
    Pair = 13,
    HashTable = 14,
    Other = 15,
}

#[derive(Clone, Trace)]
#[repr(align(16))]
pub enum OtherData {
    CapturedEnv(CapturedEnv),
    Transformer(Transformer),
    Future(Future),
    RecordType(RecordType),
    UserData(Arc<dyn std::any::Any>),
}
Definición de los tipos de valor y la estructura `OtherData` para la representación de punteros etiquetados, incluyendo alineación de memoria.

Fundamentos Teóricos

La transformación a Continuation-Passing Style (CPS) es un concepto fundamental en la teoría de compiladores y el cálculo lambda, formalizado por autores como Steele y Sussman en los años 70. El CPS convierte explícitamente el control de flujo implícito de las llamadas a funciones en argumentos explícitos de continuación, lo que simplifica la implementación de características como call/cc y facilita optimizaciones de bajo nivel. La beta-reducción, la optimización más efectiva en este caso, es una regla de reescritura del cálculo lambda que describe la aplicación de funciones, esencialmente el inlining. Su importancia en la eliminación de clausuras redundantes en el código CPS fue destacada por Andrew Appel en su trabajo sobre compiladores funcionales, donde la optimización de la representación intermedia es clave. La representación de punteros etiquetados (tagged pointers) es una técnica clásica en la implementación de lenguajes dinámicos, especialmente en Lisp y Scheme, para representar tipos de datos heterogéneos de manera eficiente en memoria y con bajo overhead de runtime. Esta técnica se remonta a las primeras implementaciones de Lisp en los años 60 y 70, donde la optimización del uso de la memoria y el rendimiento de las operaciones de tipo eran críticas.