Lysis VM
Tercera VM: instanciación estructural de plantillas con hash-consing y heap de spill. El ejemplo canónico de Bytecode-Oriented Compilation en Achronyme.
Por qué existe Lysis
Akron y Artik ejecutan ambos una vez por prueba. Lysis es diferente: separa compile/instantiate de la emisión en runtime. El walker emite un programa Lysis que, al correr, produce IR SSA — y el sink interning del ejecutor colapsa sub-árboles estructuralmente idénticos durante la emisión.
Concretamente: el expansor ávido legacy desenrollaba todos los loops e inlineaba todos los sub-templates por adelantado. Lysis difiere esa expansión a runtime y deduplica sub-grafos redundantes vía hash-consing. Los comentarios y tests actuales del árbol identifican circuitos con forma SHA-256(64) como el objetivo de escala motivador; las cifras exactas de tiempo y delta de constraints son salidas de benchmark, no invariantes arquitecturales.
Bytecode-Oriented Compilation (BOC)
Lysis es el ejemplo canónico de lo que llamamos Bytecode-Oriented Compilation (BOC) dentro de Achronyme: una arquitectura de compilador en la que el paso de compilación es él mismo un programa en bytecode, y la salida de la compilación es el efecto lateral de correr ese bytecode sobre una VM.
La forma:
Fuente → Bytecode → Correr sobre la VM → Salida de compilación
Dos distinciones que conviene hacer explícitas, porque BOC vive al lado de varios patrones con nombres parecidos:
- BOC no es “un compilador que emite bytecode”.
javacy CPython producen bytecode como artefacto final — ese bytecode es dato, consumido después por un runtime separado. En BOC el bytecode se consume como parte de la compilación misma: cada paso que la VM ejecuta empuja una entrada hacia la salida. En el caso de Lysis, esas entradas son nodos SSA IR que terminan convertidos en constraints R1CS. El bytecode es el motor de la compilación, no su producto. - BOC es staged compilation heterogénea. Los vecinos más cercanos en la literatura son multi-stage programming (MetaOCaml, Template Haskell), compile-time function execution (Zig comptime, D CTFE) y metacompilación (Forth). Todos ellos son típicamente homogéneos — el stage-2 produce código con la forma del stage-1, horneado en el mismo binario destino. BOC es heterogéneo: el bytecode de Lysis es un ISA distinto del de su salida, y la salida es un sistema de constraints, no un ejecutable.
La consecuencia práctica es que las pipelines BOC fallan y escalan como programas, no como estructuras de dato. El manejo de frame overflow, el heap de spill, la regla single-static-store del validador, la profundidad de llamada por defecto de 8192 — nada de eso existiría para un IR pasivo. Existen porque el meta-programa es un programa real sometido a límites de recurso reales, y el compile-time del compilador queda reificado como el runtime de esta VM. Cuando Lysis choca contra un límite, la solución rara vez es “cambiar la forma del dato”; suele ser “darle más capacidad al ejecutor” o “enseñarle un opcode nuevo al ejecutor”. Esa es la firma de BOC.
Dónde se ubica Lysis
El walker es el lifter del lado IR: consume ExtendedInstruction<F> (de ir-forge) y produce bytecode Lysis. El ejecutor es el lado VM: corre ese bytecode y emite SSA vía un sink.
Pipeline de lift (ir-forge::lysis_lift)
Archivo: ir-forge/src/lysis_lift/mod.rs. Cinco etapas:
| Etapa | Módulo | Entregable |
|---|---|---|
| Recorrido simbólico | ir-forge/src/lysis_lift/symbolic.rs | SymbolicTree con slots placeholder para derivativas de variables de loop |
| Diff estructural | ir-forge/src/lysis_lift/diff.rs | Diff con identidad de slot por ruta de AST |
| Binding-Time Analysis | ir-forge/src/lysis_lift/bta.rs | Clasificación de 3 puntos (Constant / Invariant / DataDependent) |
| Extracción de plantillas | ir-forge/src/lysis_lift/extract/ | Lambda lifting + tamaño de frame, layout de capturas |
| Walker (driver) | ir-forge/src/lysis_lift/walker/ | Orquesta el pase completo de lift |
Las cinco implementan evaluación diferida — las plantillas permanecen simbólicas hasta proof-time.
Encabezado del bytecode
0..4 magic "LYSI"
4..6 version (u16 LE) — actual: 2 (los streams v1 decodifican con heap_size_hint = 0)
6 field_family (u8) — BnLike256 | Goldilocks64
7 flags (u8) — bit 0: has_witness_calls
8..12 const_pool_len (u32 LE)
12..16 body_len (u32 LE)
16..20 heap_size_hint (u32 LE) — tamaño del heap de spill en entradas (sólo v2)
20..… const_pool (entradas FieldConst + bytecode blobs para llamadas a Artik)
….. body (stream de instrucciones)
El decodificador despacha sobre version y lee el encabezado v1 (16 bytes) cuando ve un stream v1, dejando heap_size_hint por defecto en 0.
Modelo de registros / frame
- Registros: 256 por frame, almacenados como
Vec<Option<FieldElement<F>>>(indefinidos hasta que se escriben). - Pila de frames:
Vec<Frame>conpc,body_start_idx,body_end_idx,template_id,loop_stack: Vec<LoopState>. - Estado de loop: cada
LoopUnrollempuja unLoopStaterastreando los límites de iteración; el pasoadvance_loops()del despachador incrementa y detecta completitud. - Profundidad de llamada por defecto: 8192 (subida desde el original 64 para acomodar árboles de plantillas profundos como el message schedule de SHA-256).
Heap de spill
El heap del frame es un Vec<FieldElement<F>> plano alocado por ejecución del programa, dimensionado por heap_size_hint. Contiene valores que sobreviven al frame que los escribió — capturas invariantes de loop, arrays materializados de loops rolled, buffers de witness-call — que no caben en la ventana de 256 registros del frame activo.
Cada slot del heap está gobernado por un invariante de single-static-store: un slot se escribe exactamente una vez por ejecución. El validador rechaza programas que emiten dos StoreHeap al mismo slot, y el ejecutor trapea ante un LoadHeap desde un slot no escrito. Esto hace que el heap se comporte como registros SSA extendidos a un espacio de direcciones más ancho, con las mismas garantías de dataflow que el resto de la VM asume.
Tabla de opcodes (37 opcodes)
Archivo: lysis/src/bytecode/opcode.rs.
| Código | Mnemónico | Operandos | Semántica |
|---|---|---|---|
| 0x01 | LoadCapture | dst, idx | R[dst] = captures[idx] |
| 0x02 | LoadConst | dst, idx | R[dst] = const_pool[idx] |
| 0x03 | LoadInput | dst, name_idx, vis | R[dst] = inputs[name_idx] (Public/Witness) |
| 0x04 | EnterScope | — | scope_depth += 1 |
| 0x05 | ExitScope | — | scope_depth −= 1 |
| 0x10 | Jump | offset | PC += offset |
| 0x11 | JumpIf | cond, offset | condicional |
| 0x12 | Return | — | pop frame |
| 0x13 | Halt | — | termina ejecución |
| 0x14 | Trap | code | aborta |
| 0x20 | LoopUnroll | iter_var, start, end, body_len | inline body N veces |
| 0x21 | LoopRolled | iter_var, start, end, template_id | llama plantilla repetidamente |
| 0x22 | LoopRange | iter_var, R(end), template_id | loop con cota dinámica |
| 0x30 | DefineTemplate | template_id, frame_size, n_params, body_offset, body_len | registra metadata de plantilla |
| 0x31 | InstantiateTemplate | template_id, capture_regs, output_regs | empuja frame + llama |
| 0x32 | TemplateOutput | output_idx, src | publica valor de salida |
| 0x40 | EmitConst | dst, src | emite Const(R[src]) |
| 0x41 | EmitAdd | dst, lhs, rhs | emite Add |
| 0x42 | EmitSub | dst, lhs, rhs | emite Sub |
| 0x43 | EmitMul | dst, lhs, rhs | emite Mul |
| 0x44 | EmitNeg | dst, src | emite Neg |
| 0x45 | EmitMux | dst, cond, t, f | emite Mux |
| 0x46 | EmitDecompose | dst_arr, src, n_bits | emite Decompose |
| 0x47 | EmitAssertEq | lhs, rhs | emite AssertEq |
| 0x48 | EmitRangeCheck | var, max_bits | emite RangeCheck |
| 0x49 | EmitWitnessCall | bytecode_idx, in_regs, out_regs | emite WitnessCall (Artik) |
| 0x4A | EmitPoseidonHash | dst, in_regs | emite PoseidonHash |
| 0x4B | EmitIsEq | dst, lhs, rhs | emite IsEq |
| 0x4C | EmitIsLt | dst, lhs, rhs | emite IsLt |
| 0x4D | EmitIntDiv | dst, lhs, rhs, max_bits | emite IntDiv |
| 0x4E | EmitIntMod | dst, lhs, rhs, max_bits | emite IntMod |
| 0x4F | EmitDiv | dst, lhs, rhs | emite Div de campo |
| 0x50 | StoreHeap | slot, src | escribe R[src] en un slot del heap (single-static-store) |
| 0x51 | LoadHeap | dst, slot | lee un slot del heap de vuelta a un registro |
| 0x52 | EmitWitnessCallHeap | bytecode_idx, in_descs, out_regs | WitnessCall cuyas entradas pueden vivir en slots del heap |
| 0x53 | EmitAssertEqMsg | lhs, rhs, msg_idx | AssertEq con un id de mensaje del const pool |
| 0x54 | EmitIsLtBounded | dst, lhs, rhs, max_bits | emite IsLt acotado |
Los opcodes Emit* no computan resultados — empujan entradas al sink.
Sinks
El despachador escribe los opcodes que emiten IR en un trait object IrSink. Existen dos implementaciones:
StubSink— no-op, usado por tests de esqueleto que ejercitan control flow sin materializar IR.InterningSink— hash-cons víaIndexMap<NodeKey, NodeId>. Default en producción. Materializa unVec<Instruction<F>>plano víasink.materialize().
Loop del despachador
Archivo: lysis/src/execute/mod.rs. Loop externo sobre frames + paso interno:
loop {
check_instruction_budget()?;
let instr = program.body[frame.pc];
match dispatch(instr, ...)? {
Step::Next => frame.pc += 1,
Step::JumpToIndex(idx) => frame.pc = idx,
Step::PushFrame(f) => stack.push(f),
Step::PopFrame => stack.pop(),
Step::Halt => break,
}
advance_loops()?;
}
Modelo de memoria
Sin GC. Los registros del frame viven en la struct del frame. El interner usa internamente un IndexMap<NodeKey, NodeId> prestado mutablemente durante la ejecución. El heap de spill es un único Vec<FieldElement<F>> propiedad del ejecutor durante una corrida del programa, reseteado entre ejecuciones.
Salida del lift → walker
Archivo: ir-forge/src/lysis_lift/walker/. Driver que consume Vec<ExtendedInstruction<F>>:
| Variante | Acción del walker |
|---|---|
Plain(ir::Instruction) | emite el opcode Emit* correspondiente |
LoopUnroll { iter_var, start, end, body } | emite opcode LoopUnroll |
TemplateCall {…} | emite InstantiateTemplate; las capturas de larga vida se spillean vía StoreHeap |
TemplateBody {…} | enlazado a un bloque DefineTemplate; los cuerpos de loop rolled viven aquí |
Cuando un body es demasiado ancho para mantener sus valores en la ventana de 256 registros — el body de la ronda de SHA-256 es el ejemplo canónico — el walker baja a unroll por iteración y rutea valores cargados por el loop a través de slots del heap, de modo que cada iteración arranca con un archivo de registros fresco.
Cobertura E2E
| Circuito | Pipeline | Resultado |
|---|---|---|
| SHA-256(64) | walker (unroll por iteración, ruteado por heap) → optimize → R1CS | equivalencia de digest y verificación completa de witness R1CS cubiertas por tests de regresión ignorados en circom/tests/e2e_sha256/witness.rs |
| Poseidon(2) | lift estructural → R1CS → Groth16 | ruta de generación de prueba cubierta por tests E2E de Groth16 y Solidity |
| EdDSAPoseidon | lift estructural + capturas en heap | compile + R1CS limpio |
| MiMCSponge(2,220,1) | lift estructural → R1CS | cubierto por E2E circomlib y harnesses de benchmark; los conteos exactos pertenecen a la salida del benchmark |
| Preservación de constraints adversarial | benchmark + tests cross-mode | tests de round-trip y preservación revisan R1CS byte-idéntico donde esa propiedad es requerida |
Preguntas abiertas
- Los números actuales de benchmark para SHA-256(64), MiMCSponge y deltas contra circom O2 deben regenerarse desde el harness de benchmarks antes de publicarse como conteos exactos o tiempos de pared.
Archivos fuente
| Componente | Archivo |
|---|---|
| Opcodes de bytecode | lysis/src/bytecode/opcode.rs |
| Codificación | lysis/src/bytecode/encoding.rs |
| Validador | lysis/src/bytecode/validate.rs |
| Pool de constantes | lysis/src/bytecode/const_pool.rs |
| Encabezado (v1/v2) | lysis/src/header.rs |
| Despachador | lysis/src/execute/mod.rs |
| Frame | lysis/src/execute/frame.rs |
| Dispatch de heap | lysis/src/execute/dispatch/heap.rs |
| Sink interning | lysis/src/execute/interning_sink.rs |
| Interner | lysis/src/intern/interner/ |
| Builder | lysis/src/builder.rs |
| Walker (lift) | ir-forge/src/lysis_lift/walker/ |
| Clasificador BTA | ir-forge/src/lysis_lift/bta.rs |
| Árbol simbólico | ir-forge/src/lysis_lift/symbolic.rs |
| Diff estructural | ir-forge/src/lysis_lift/diff.rs |
| Extracción de plantillas | ir-forge/src/lysis_lift/extract/ |
Ver ProveIR para la capa que alimenta a Lysis. Ver Akron VM y Artik Witness VM para las otras dos VMs.