diff --git a/compiler/src/demutate.rs b/compiler/src/demutate.rs index 2d522ba..d4afef6 100644 --- a/compiler/src/demutate.rs +++ b/compiler/src/demutate.rs @@ -1,303 +1,191 @@ -//! demutate: object builds and in-place mutations become calls to a function -//! that returns a fresh object (demutate_algo.txt). +//! demutate: every static-key field write becomes a call to a function that +//! RETURNS a fresh object. //! -//! This file is the detector. It finds the runs and works out what each one -//! reads from outside itself; lifting a run into its own function, and putting -//! the call in its place, is the next step. +//! There is no grouping and no analysis of what surrounds a write. Each one +//! stands alone, because its two arguments are the call's two arguments and +//! the enclosing code already holds both: //! -//! A run is a maximal contiguous stretch of static-key field writes to ONE -//! object, together with the value computations those writes consume: +//! define_field @F [v, o] -> [n] n = (o, v) +//! put_field @F [v, o] n = (o, v) ; store n back //! -//! creation object ; ( ; define_field @F )+ -//! mutation ( get_loc L ; ; put_field @F )+ (same L) - -use std::range::Range; +//! `object` is left as it is. It already makes a fresh object, so it needs no +//! function; infer reads it as `Type::Empty`, the base every shape builds on. +//! +//! The lifted functions have no body. `CodeKind::DefineField` / `PutField` +//! name the operation and the field, and infer gives each a type rule, so ONE +//! function serves every write of that field in the program. Their ids run on +//! after the real functions. use crate::{ - parse::{ImmOperand, opcodes::OpCode}, + infer::{CodeKind, InferFn, Type}, + parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode}, regify::{RegFn, RegInstr}, }; -pub fn find_object_runs(reg_fn: &RegFn) -> Vec { - let barriers = find_barriers(®_fn.code); +pub fn demutate(reg_fns: Vec) -> Vec { + // The lifted functions take the ids after the real ones, so a write can + // name the one it needs before the list of them is finished + let first_lifted = reg_fns.len(); + let mut lifted = vec![]; - collect_runs(®_fn.code, &barriers, 0, Look::Anything) + let mut fns = reg_fns + .into_iter() + .map(|f| rewrite_fn(f, first_lifted, &mut lifted)) + .collect::>(); + + fns.extend(lifted.into_iter().map(|code| InferFn { + name: lifted_name(&code), + cpool: vec![], + code, + sigs: vec![], + types: vec![], + })); + + fns } -/// What to match at this level. Inside a run only nested BUILDS count: a -/// mutation writes its object over and over, so matching anything would find -/// the run's own second write as a run of its own. -#[derive(Clone, Copy, PartialEq)] -enum Look { - Anything, - BuildsOnly, -} +/// One scan of the code. Everything that is not a static-key write is moved +/// across untouched; a write becomes a call, and a `put_field` a call and a +/// store. +fn rewrite_fn(mut reg_fn: RegFn, first_lifted: usize, lifted: &mut Vec) -> InferFn { + let code = std::mem::take(&mut reg_fn.code); + let mut new = Vec::with_capacity(code.len()); -/// The runs in `ins`, each holding whatever runs are nested inside it. -fn collect_runs( - mut ins: &[RegInstr], - mut barriers: &[bool], - mut offset: usize, - look: Look, -) -> Vec { - let mut found = vec![]; + for instr in code { + let Some(field) = static_key(&instr) else { + new.push(instr); // Not a field write, so it passes through + continue; + }; - while !ins.is_empty() { - let run = match_run(ins, barriers, look); // Try to match a run + let define = instr.op == OpCode::DefineField; - match run { - // Found a run, store it and advance `ins` - Ok((rest, kind)) => { - let consumed = ins.len() - rest.len(); // How many instrs were consumed - let run_instr = &ins[..consumed]; // The run's instrs + // Where the object lives says where its replacement goes. The + // instruction that made it is already in `new`. + let object = instr.args[1]; + let slot = (!define).then(|| frame_slot(&new, object)).flatten(); - let range = Range { - start: offset, - end: offset + consumed, - }; + let callee = + intern_const(&mut reg_fn.cpool, first_lifted + intern_fn(lifted, instr.op, field)); - // Walk the run's own instructions to find the builds nested in - // it. Skip instr 0: it starts this run, and would match it a - // second time. - let nested = collect_runs( - &run_instr[1..], - &barriers[1..consumed], - offset + 1, - Look::BuildsOnly, - ); + let dst = match slot { + // define_field hands the object back, so the call keeps its dst + _ if define => instr.dsts[0], - found.push(ObjectRun { - range, - kind, - params: find_params(run_instr, offset, nested), - }); + // regify does not rename a local, so the local IS the object's + // value: defining it again is the store, and costs no instruction + Some(Source::Loc(_)) => object, - offset += consumed; - ins = rest; - barriers = &barriers[consumed..]; - } + // Anything else needs a value of its own to be written back from, + // and with no slot at all the write is lost -- `f().x = 1` does that + _ => reg_fn.fresh_val(), + }; - // Did not find a run, advance past what was tried and go again - Err(rest) => { - let consumed = ins.len() - rest.len(); // Handle partial consumption - let consumed = consumed.max(1); - offset += consumed; - ins = &ins[consumed..]; - barriers = &barriers[consumed..]; - } + new.push(call(callee, dst, instr.args)); + + if let Some(slot) = slot.filter(|s| !matches!(s, Source::Loc(_))) { + new.push(store(slot, dst)); } } - found + InferFn { + types: vec![Type::Unknown; reg_fn.num_vals()], + name: reg_fn.name, + cpool: reg_fn.cpool, + code: CodeKind::Code(new), + sigs: vec![], + } } -#[derive(Debug)] -pub struct ObjectRun { - pub range: Range, - pub kind: RunKind, - /// What the run cannot make for itself, in first-use order. Each one - /// becomes a parameter of the lifted function. - pub params: Vec, +/// The field a write names, when the name is fixed at compile time. A computed +/// key is `define_array_el` / `put_array_el`, and is left alone. +fn static_key(i: &RegInstr) -> Option { + let is_write = matches!(i.op, OpCode::DefineField | OpCode::PutField) && i.args.len() == 2; + + match i.imm { + ImmOperand::Atom(a) if is_write => Some(a), + _ => None, + } } -/// A value the lifted function is handed rather than making. -#[derive(Debug)] -pub enum Param { - /// Read out of the enclosing frame. The load stays at the call site. - Frame(Source), - /// A build nested inside this run. It is a run in its own right, and it - /// lives here because being a parameter is the whole of its part in the - /// outer run: lift it first, then pass its call result in. - Run(ObjectRun), +/// The index of the function for this write, adding it if it is the first of +/// its kind. One function serves every write of one field by one opcode. +fn intern_fn(lifted: &mut Vec, op: OpCode, field: AtomRef) -> usize { + let wanted = match op { + OpCode::DefineField => CodeKind::DefineField(field), + _ => CodeKind::PutField(field), + }; + + let same = |c: &CodeKind| match (c, &wanted) { + (CodeKind::DefineField(a), CodeKind::DefineField(b)) => a == b, + (CodeKind::PutField(a), CodeKind::PutField(b)) => a == b, + _ => false, + }; + + lifted.iter().position(same).unwrap_or_else(|| { + lifted.push(wanted); + lifted.len() - 1 + }) } -/// Where the run's object comes from. It is the only difference between the -/// two forms: a creation makes the object, a mutation is handed one. -#[derive(Debug)] -pub enum RunKind { - /// The run makes the object itself, with `object`. - Create, - /// The run loads the object from the frame before each write. It is - /// parameter 0 of the lifted function, and the result is stored back here. - Mutate(Source), +/// The cpool slot holding function `id`, adding it if it is not there yet. A +/// call names its function the way every other reference to one does. +fn intern_const(cpool: &mut Vec, id: usize) -> u32 { + let at = cpool + .iter() + .position(|c| matches!(c, JsValue::FnById(f) if *f == id)) + .unwrap_or_else(|| { + cpool.push(JsValue::FnById(id)); + cpool.len() - 1 + }); + + at as u32 } -/// A frame slot a run reads. Two loads of one slot are the same object, whatever -/// value ids regify gave them, so sameness is judged here and not on the value. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Source { +/// The frame slot an object lives in, from whichever instruction defined it. +fn frame_slot(code: &[RegInstr], object: usize) -> Option { + code.iter() + .find(|i| i.dsts.contains(&object)) + .and_then(frame_load) +} + +/// A frame slot, and the two instructions that read and write it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Source { Arg(u16), Loc(u16), VarRef(u16), } -/// Where a run may not reach across: a jump, and anything a jump lands on. A -/// run is one straight line of code, so that both ends of a branch are cut off. -fn find_barriers(code: &[RegInstr]) -> Vec { - let mut barriers = vec![false; code.len()]; +// ── emitted instructions ─────────────────────────────────────────────────── - for (at, i) in code.iter().enumerate() { - if let ImmOperand::Label { target } = i.imm { - barriers[at] = true; - barriers[target as usize] = true; - } - } - - barriers +/// A call to a function fixed at rewrite time. The callee is named by the +/// immediate rather than pushed, so the call fits where the write stood. +fn call(callee: u32, dst: usize, args: Vec) -> RegInstr { + RegInstr::new(OpCode::Call, ImmOperand::Const(callee), vec![dst], args) } -/// What the run has to be handed, in first-use order. A frame slot appears -/// once however many times it is loaded, which folds a mutation's repeated -/// loads of its object into the one parameter. -fn find_params(run: &[RegInstr], offset: usize, nested: Vec) -> Vec { - let mut params = vec![]; - let mut nested = nested.into_iter().peekable(); - let mut at = 0; - - while at < run.len() { - // A nested build is its own run, so step over it whole and take the run - // itself instead of the instructions that make it. They come in - // instruction order, so the next one is always the one to look at. - if nested.peek().is_some_and(|n| n.range.start == offset + at) { - let child = nested.next().unwrap(); - at += child.range.end - child.range.start; - params.push(Param::Run(child)); - continue; - } - - if let Some(src) = frame_load(&run[at]) { - if !params.iter().any(|p| matches!(p, Param::Frame(s) if *s == src)) { - params.push(Param::Frame(src)); - } - } - - at += 1; - } - - params -} - -// ─────────────────────────────── Run Grammar ──────────────────────────────── -// creation object ; ( ; define_field @F )+ -// mutation ( get_loc L ; ; put_field @F )+ (same L) -// -// is any expression, so a run cannot be matched opcode by opcode the -// way declass matches a class build. The object is FOLLOWED instead: skip to -// the next instruction that uses it, and carry on only while that use is a -// static-key write. Whatever was passed over on the way is the value -// computation, and it belongs to the run. -// -// match fns return Ok(advanced_instrs) or Err(original_instrs) - -fn match_run<'a>( - ins: &'a [RegInstr], - barriers: &[bool], - look: Look, -) -> Result<(&'a [RegInstr], RunKind), &'a [RegInstr]> { - match match_creation_run(ins, barriers) { - Ok(rest) => Ok((rest, RunKind::Create)), - Err(_) if look == Look::BuildsOnly => Err(ins), - Err(_) => match_mutation_run(ins, barriers), - } -} - -fn match_creation_run<'a>( - ins: &'a [RegInstr], - barriers: &[bool], -) -> Result<&'a [RegInstr], &'a [RegInstr]> { - let Some(first) = ins.first() else { - return Err(ins); +fn store(slot: Source, value: usize) -> RegInstr { + let (op, imm) = match slot { + Source::Arg(k) => (OpCode::PutArg, ImmOperand::Arg(k)), + Source::Loc(k) => (OpCode::PutLoc, ImmOperand::Loc(k)), + Source::VarRef(k) => (OpCode::PutVarRef, ImmOperand::VarRef(k)), }; - if first.op != OpCode::Object { - return Err(ins); - } - - // define_field hands the object back as a NEW value each time, so the run - // holds together as a chain: follow it from one write to the next - let mut obj = first.dsts[0]; - let mut end = 1; - let mut writes = 0; - - while let Some(at) = next_use(&ins[end..], &barriers[end..], obj) { - let Some(next_obj) = chained_write(&ins[end + at], obj) else { - break; // The object is read for something other than a field write - }; - - obj = next_obj; - end += at + 1; - writes += 1; - } - - if writes == 0 { - return Err(ins); // A bare `object` with no writes is not a run - } - - Ok(&ins[end..]) + RegInstr::new(op, imm, vec![], vec![value]) } -fn match_mutation_run<'a>( - ins: &'a [RegInstr], - barriers: &[bool], -) -> Result<(&'a [RegInstr], RunKind), &'a [RegInstr]> { - let Some(loc) = ins.first().and_then(frame_load) else { - return Err(ins); - }; - - let mut end = 0; - let mut writes = 0; - - // put_field hands nothing back, so each write re-loads the object. The run - // carries on while the load is of the SAME slot - while ins.get(end).and_then(frame_load) == Some(loc) { - if end > 0 && barriers[end] { - break; // The next write is on the far side of a branch - } - - let obj = ins[end].dsts[0]; - - let Some(at) = next_use(&ins[end + 1..], &barriers[end + 1..], obj) else { - break; // The load goes nowhere - }; - - if !is_write(&ins[end + 1 + at], OpCode::PutField, obj) { - break; // The object is read for something other than a field write - } - - end += at + 2; - writes += 1; +fn lifted_name(code: &CodeKind) -> String { + match code { + CodeKind::DefineField(a) => format!(""), + CodeKind::PutField(a) => format!(""), + CodeKind::Code(_) => unreachable!("a lifted function has no body"), } - - if writes == 0 { - return Err(ins); - } - - Ok((&ins[end..], RunKind::Mutate(loc))) } -// ───────────────────────────── Matcher Primitives ─────────────────────────── +// ── operand helpers ──────────────────────────────────────────────────────── -/// Where `v` is next read. Everything before it is the value computation, so -/// it all joins the run -- which is why a barrier in between ends the search. -fn next_use(ins: &[RegInstr], barriers: &[bool], v: usize) -> Option { - let at = ins.iter().position(|i| i.args.contains(&v))?; - barriers[..=at].iter().all(|b| !b).then_some(at) -} - -/// The object as `define_field` hands it back, if this is a static-key write -/// to `obj`. -fn chained_write(i: &RegInstr, obj: usize) -> Option { - is_write(i, OpCode::DefineField, obj).then(|| i.dsts[0]) -} - -/// A static-key field write to `obj`: `op @F [value, obj]`. A computed key is -/// `define_array_el` / `put_array_el` instead, and does not match. -fn is_write(i: &RegInstr, op: OpCode, obj: usize) -> bool { - i.op == op && matches!(i.imm, ImmOperand::Atom(_)) && i.args.len() == 2 && i.args[1] == obj -} - -/// The frame slot an instruction loads. Only plain one-value loads count; -/// `get_loc0_loc1` pushes two and is left to cut the run. +/// The frame slot an instruction reads. Only plain one-value loads count; +/// `get_loc0_loc1` pushes two. fn frame_load(i: &RegInstr) -> Option { let slot = |imm| match imm { ImmOperand::Loc(k) | ImmOperand::Arg(k) | ImmOperand::VarRef(k) => k, @@ -315,7 +203,22 @@ fn frame_load(i: &RegInstr) -> Option { OpCode::GetLoc1 => Some(Source::Loc(1)), OpCode::GetLoc2 => Some(Source::Loc(2)), OpCode::GetLoc3 => Some(Source::Loc(3)), - OpCode::GetLoc | OpCode::GetLoc8 | OpCode::GetLocCheck => Some(Source::Loc(slot(i.imm))), + // Every local op names the same value, so any of them identifies it + OpCode::GetLoc + | OpCode::GetLoc8 + | OpCode::GetLocCheck + | OpCode::PutLoc + | OpCode::PutLoc8 + | OpCode::PutLocCheck + | OpCode::PutLocCheckInit + | OpCode::SetLoc + | OpCode::SetLoc8 + | OpCode::SetLocUninitialized => Some(Source::Loc(slot(i.imm))), + + OpCode::PutLoc0 | OpCode::SetLoc0 => Some(Source::Loc(0)), + OpCode::PutLoc1 | OpCode::SetLoc1 => Some(Source::Loc(1)), + OpCode::PutLoc2 | OpCode::SetLoc2 => Some(Source::Loc(2)), + OpCode::PutLoc3 | OpCode::SetLoc3 => Some(Source::Loc(3)), OpCode::GetVarRef0 => Some(Source::VarRef(0)), OpCode::GetVarRef1 => Some(Source::VarRef(1)), diff --git a/compiler/src/infer.rs b/compiler/src/infer.rs new file mode 100644 index 0000000..d496234 --- /dev/null +++ b/compiler/src/infer.rs @@ -0,0 +1,310 @@ +use crate::{ + parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode}, + regify::RegInstr, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Type { + Unknown, + Undefined, + Bool, + Int, + Float, + Empty, + /// A named function, by flat fn id. + Fn(usize), + ReturnOf((usize, Vec)), + Any, +} + +#[derive(Debug)] +pub enum CodeKind { + Code(Vec), + DefineField(AtomRef), + PutField(AtomRef), +} + +#[derive(Debug)] +pub struct Signature { + pub args: Vec, + pub ret: Type, +} + +#[derive(Debug)] +pub struct InferFn { + pub name: String, + pub cpool: Vec, + pub code: CodeKind, + pub sigs: Vec, + /// One entry per value id, so a value's type is `types[v]`. regify keeps + /// the first `var_count` ids for the locals, so a local's type lives there + /// too and `put_loc` / `get_loc` just copy between the two. + pub types: Vec, +} + +/// The lattice, joining what a value already holds with what it is being +/// given. Unknown is the bottom and gives way to anything; Any is the top and +/// swallows everything; two types that disagree can only be described by Any. +pub fn join(a: &Type, b: &Type) -> Type { + match (a, b) { + (Type::Unknown, t) | (t, Type::Unknown) => t.clone(), + (Type::Any, _) | (_, Type::Any) => Type::Any, + (a, b) if a == b => a.clone(), + _ => Type::Any, + } +} + +/// Walk a function's code once, giving every value a type, and hand back what +/// the function returns. A call is followed down into its callee. +pub fn infer(fn_id: usize, fns: &mut Vec) -> Type { + // The caller left this function's argument types here before recursing. + let args = match fns[fn_id].sigs.first() { + Some(sig) => sig.args.clone(), + None => vec![], + }; + + // The two field writers have no body: their result IS the shape term that + // names them, built from the types they were handed. + let code = match &fns[fn_id].code { + CodeKind::DefineField(_) | CodeKind::PutField(_) => return Type::ReturnOf((fn_id, args)), + + // An empty body means this function's code is out on the stack, so we + // have come back round a cycle. Unknown is the bottom, and a later + // round raises it. + CodeKind::Code(c) if c.is_empty() => return Type::Unknown, + + CodeKind::Code(_) => { + let taken = std::mem::replace(&mut fns[fn_id].code, CodeKind::Code(vec![])); + let CodeKind::Code(code) = taken else { + unreachable!() + }; + code + } + }; + + if fns[fn_id].sigs.is_empty() { + fns[fn_id].sigs.push(Signature { + args: vec![], + ret: Type::Unknown, + }); + } + + // The types move out too, so a callee can be inferred through `fns` while + // this function is still being walked. Both go back at the end. + let mut types = std::mem::take(&mut fns[fn_id].types); + let mut ret = Type::Unknown; + + for instr in &code { + // regify does not rename locals, so a load just names the local's value + // again. It must not write to it, or it would wipe what is stored there. + let load = matches!( + instr.op, + OpCode::GetLoc0 + | OpCode::GetLoc1 + | OpCode::GetLoc2 + | OpCode::GetLoc3 + | OpCode::GetLoc0Loc1 + | OpCode::GetLoc + | OpCode::GetLoc8 + | OpCode::GetLocCheck + ); + + let ty = match instr.op { + // ── literals ──────────────────────────────────────────────── + OpCode::PushMinus1 + | OpCode::Push0 + | OpCode::Push1 + | OpCode::Push2 + | OpCode::Push3 + | OpCode::Push4 + | OpCode::Push5 + | OpCode::Push6 + | OpCode::Push7 + | OpCode::PushI8 + | OpCode::PushI16 + | OpCode::PushI32 => Type::Int, + + OpCode::PushTrue | OpCode::PushFalse => Type::Bool, + OpCode::Undefined => Type::Undefined, + OpCode::Object => Type::Empty, + + OpCode::PushConst | OpCode::PushConst8 | OpCode::Fclosure | OpCode::Fclosure8 => { + match instr.imm { + ImmOperand::Const(k) => match fns[fn_id].cpool.get(k as usize) { + Some(JsValue::Int(_)) => Type::Int, + Some(JsValue::Float(_)) => Type::Float, + Some(JsValue::BoolTrue) | Some(JsValue::BoolFalse) => Type::Bool, + Some(JsValue::Undefined) => Type::Undefined, + Some(&JsValue::FnById(id)) => Type::Fn(id), + _ => Type::Any, + }, + _ => Type::Any, + } + } + + // ── field reads ───────────────────────────────────────────── + // Walk the shape term from the outside in: the newest write of the + // field wins, which is what makes a later write shadow an earlier. + OpCode::GetField => { + let field = match instr.imm { + ImmOperand::Atom(a) => a, + _ => AtomRef::TaggedInt(u32::MAX), + }; + + let mut ty = types[instr.args[0]].clone(); + + loop { + let (id, args) = match ty { + Type::ReturnOf((id, args)) if args.len() == 2 => (id, args), + Type::Empty => break Type::Undefined, // No fields at all + _ => break Type::Any, + }; + + match &fns[id].code { + CodeKind::DefineField(a) | CodeKind::PutField(a) if *a == field => { + break args[1].clone(); + } + CodeKind::DefineField(_) | CodeKind::PutField(_) => ty = args[0].clone(), + CodeKind::Code(_) => break Type::Any, // Not a shape we built + } + } + } + + // ── arithmetic ────────────────────────────────────────────── + // A float anywhere makes the result a float; anything else can be + // a string concatenation or an object coercion, so it is Any. + OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div | OpCode::Mod | OpCode::Pow => { + match (&types[instr.args[0]], &types[instr.args[1]]) { + (Type::Int, Type::Int) => Type::Int, + (Type::Float, Type::Float) + | (Type::Int, Type::Float) + | (Type::Float, Type::Int) => Type::Float, + _ => Type::Any, + } + } + + OpCode::Neg | OpCode::Plus | OpCode::Inc | OpCode::Dec => match types[instr.args[0]] { + Type::Int => Type::Int, + Type::Float => Type::Float, + _ => Type::Any, + }, + + OpCode::Lt + | OpCode::Lte + | OpCode::Gt + | OpCode::Gte + | OpCode::Eq + | OpCode::Neq + | OpCode::StrictEq + | OpCode::StrictNeq + | OpCode::Not => Type::Bool, + + // ── calls ─────────────────────────────────────────────────── + // demutate names a lifted callee in the immediate. Anything else + // is a callee off the stack, which this does not resolve yet. + OpCode::Call | OpCode::Call0 | OpCode::Call1 | OpCode::Call2 | OpCode::Call3 => { + // demutate names its callee in the immediate; every other call + // takes it off the stack, where it is deepest and so comes last. + // Reversing the rest puts them back in written order. + let (callee, args) = match instr.imm { + ImmOperand::Const(k) => match fns[fn_id].cpool.get(k as usize) { + Some(&JsValue::FnById(id)) => (Some(id), &instr.args[..]), + _ => (None, &instr.args[..]), + }, + _ => match instr.args.last().map(|a| &types[*a]) { + Some(&Type::Fn(id)) => (Some(id), &instr.args[..instr.args.len() - 1]), + _ => (None, &instr.args[..]), + }, + }; + + match callee { + Some(callee) => { + let args = args.iter().rev().map(|a| types[*a].clone()).collect(); + fns[callee].sigs = vec![Signature { + args, + ret: Type::Unknown, + }]; + infer(callee, fns) + } + None => Type::Any, + } + } + + // ── arguments ─────────────────────────────────────────────── + OpCode::GetArg0 => args.first().cloned().unwrap_or(Type::Undefined), + OpCode::GetArg1 => args.get(1).cloned().unwrap_or(Type::Undefined), + OpCode::GetArg2 => args.get(2).cloned().unwrap_or(Type::Undefined), + OpCode::GetArg3 => args.get(3).cloned().unwrap_or(Type::Undefined), + OpCode::GetArg => match instr.imm { + ImmOperand::Arg(k) => args.get(k as usize).cloned().unwrap_or(Type::Undefined), + _ => Type::Any, + }, + + // ── value movers ──────────────────────────────────────────── + // These hand the value straight back, so the type goes through. + // dup pushes it twice, which the assignment below spreads over + // both destinations. + // A store into a local is one of these too: the local IS the + // destination value, so the type simply moves across. + OpCode::SetName + | OpCode::SetNameComputed + | OpCode::SetHomeObject + | OpCode::Dup + | OpCode::PutLoc0 + | OpCode::PutLoc1 + | OpCode::PutLoc2 + | OpCode::PutLoc3 + | OpCode::PutLoc + | OpCode::PutLoc8 + | OpCode::PutLocCheck + | OpCode::PutLocCheckInit + | OpCode::SetLoc0 + | OpCode::SetLoc1 + | OpCode::SetLoc2 + | OpCode::SetLoc3 + | OpCode::SetLoc + | OpCode::SetLoc8 => types[instr.args[0]].clone(), + + OpCode::SetLocUninitialized => Type::Unknown, + + // ── returns ───────────────────────────────────────────────── + OpCode::Return => { + ret = join(&ret, &types[instr.args[0]]); + Type::Unknown + } + OpCode::ReturnUndef => { + ret = join(&ret, &Type::Undefined); + Type::Unknown + } + + // ── everything else ───────────────────────────────────────── + _ => Type::Any, + }; + + // Assigning follows the algebra rather than overwriting. A local keeps + // one value id, so both arms of a branch write it, and the join is what + // stops the second simply winning. + // + // IMPRECISE, and it needs a CFG to fix: joining EVERY write treats a + // plain reassignment like a branch, so `let x = 1; x = 2.5` gives Any + // where Float is right. Only a merge point should join; an + // unconditional store should replace. A linear walk cannot tell the two + // apart. It also walks a loop body once, with the types from before the + // loop, instead of iterating it. + // + // Both need the jump targets, and `ImmOperand::Label` still holds a + // BYTE offset (parse.rs resolves it against pc), which means nothing + // once the code is a list of instructions. + if !load { + for &dst in &instr.dsts { + types[dst] = join(&types[dst], &ty); + } + } + } + + fns[fn_id].types = types; + fns[fn_id].code = CodeKind::Code(code); + fns[fn_id].sigs[0].ret = ret.clone(); + + ret +} diff --git a/compiler/src/main.rs b/compiler/src/main.rs index 5202d18..969eb63 100644 --- a/compiler/src/main.rs +++ b/compiler/src/main.rs @@ -2,6 +2,7 @@ mod declass; mod declosure; mod demutate; mod flatten; +mod infer; mod parse; mod regify; @@ -9,7 +10,9 @@ use std::process::Command; use declass::declass; use declosure::declosure; +use demutate::demutate; use flatten::flatten; +use infer::infer; use parse::QuickJSBinary; use regify::regify; @@ -44,15 +47,23 @@ fn main() { let declassed = declass(reg_fns); println!("{declassed:#?}"); - println!("=== demutate runs ==="); - for f in &declassed { - println!("{}: {:#?}", f.name, demutate::find_object_runs(f)); - } - println!("=== after declosure ==="); let declosured = declosure(declassed); println!("{declosured:#?}"); + println!("=== after demutate ==="); + let mut infer_fns = demutate(declosured); + println!("{infer_fns:#?}"); + + println!("=== after infer ==="); + if let Some(main_id) = infer_fns.iter().position(|f| f.name == "main") { + let ret = infer(main_id, &mut infer_fns); + println!("main returns {ret:?}"); + println!("{infer_fns:#?}"); + } else { + println!("no main"); + } + println!("{atoms:#?}"); /*println!("=== after deprototype ==="); diff --git a/compiler/src/regify.rs b/compiler/src/regify.rs index 6729b0b..c27b3ed 100644 --- a/compiler/src/regify.rs +++ b/compiler/src/regify.rs @@ -85,10 +85,57 @@ impl RegFn { reg_instr.args.push(v); } - for _ in 0..n_push { - let v = fresh_val(); - stack.push(v); - reg_instr.dsts.push(v); + // Locals are NOT renamed: local k is value k for the whole function, + // the id reserved above. A load pushes it, a store defines it again, + // and neither makes a new value -- so a local keeps one identity + // however many times it is read and written. + let local = match instr.op { + OpCode::GetLoc0 | OpCode::PutLoc0 | OpCode::SetLoc0 => Some(0), + OpCode::GetLoc1 | OpCode::PutLoc1 | OpCode::SetLoc1 => Some(1), + OpCode::GetLoc2 | OpCode::PutLoc2 | OpCode::SetLoc2 => Some(2), + OpCode::GetLoc3 | OpCode::PutLoc3 | OpCode::SetLoc3 => Some(3), + + OpCode::GetLoc + | OpCode::GetLoc8 + | OpCode::GetLocCheck + | OpCode::PutLoc + | OpCode::PutLoc8 + | OpCode::PutLocCheck + | OpCode::PutLocCheckInit + | OpCode::SetLoc + | OpCode::SetLoc8 + | OpCode::SetLocUninitialized => match reg_instr.imm { + ImmOperand::Loc(k) => Some(k as usize), + _ => None, + }, + + _ => None, + }; + + match local { + Some(k) => { + reg_instr.dsts.push(k); + + if n_push == 1 { + stack.push(k); + } + } + + // The one op that reads two locals at once + None if instr.op == OpCode::GetLoc0Loc1 => { + for k in [0, 1] { + stack.push(k); + reg_instr.dsts.push(k); + } + } + + None => { + for _ in 0..n_push { + let v = fresh_val(); + stack.push(v); + reg_instr.dsts.push(v); + } + } } output.push(reg_instr); @@ -109,6 +156,10 @@ impl RegFn { self.next_val += 1; v } + + pub fn num_vals(&self) -> usize { + self.next_val + } } pub fn regify(flat_fns: Vec) -> Vec { diff --git a/docs/demutate_algo.txt b/docs/demutate_algo.txt index 3145023..e2a06c1 100644 --- a/docs/demutate_algo.txt +++ b/docs/demutate_algo.txt @@ -34,16 +34,23 @@ DefineField both become DefineField on the fresh copy the function owns. PARAMETERS -- the rule that keeps this general ==================================================================== -A value the run USES but does not itself PRODUCE becomes a parameter, in -first-use order. Everything produced inside the run (Object, constants, -arithmetic that is wholly inside) moves into the function unchanged. +A run is handed anything it does not make for itself. Everything else moves +into the function unchanged. There are two kinds of parameter: - the object being mutated is used-but-not-produced -> it is a parameter. - a constant field value (Push1) is produced inside -> it moves in. - a field value from the enclosing frame (GetArg p) -> a parameter. + FRAME. GetArg, GetLoc, GetVarRef and their numbered forms are frame + reads. Each becomes a parameter, and the load stays at the call site. + RUN. A nested build inside this run. That run is lifted first, and its + call result is passed in. See NESTED BUILDS. -So a pure literal of constants lifts to a 0-parameter function; a mutation of -`a` lifts to a 1-parameter function; `a.x = p + 1` lifts to a 2-parameter one. +Everything else -- constants, arithmetic, field reads off a parameter -- is +made by the run and moves in. + +Parameters are taken in first-use order. A frame slot appears once however +many times it is loaded, so a mutation's repeated loads of its object fold +into the one parameter, and the object is always parameter 0. + +So a literal of constants lifts to a 0-parameter function; a mutation of `a` +lifts to a 1-parameter function; `a.x = p + 1` lifts to a 2-parameter one. ==================================================================== @@ -83,17 +90,32 @@ loads it with Fclosure8 Const(K). DETECTION (one linear walk) ==================================================================== - start a run at an Object, or at the first static-key write to an object. - extend it while the next instruction is one of: - Object (a nested object is its own run) - a value computation feeding a write in this run - DefineField, imm == Atom(F) (same object) - PutField, imm == Atom(F) (same object) - a (re)load of the same object + A run cannot be matched opcode by opcode, the way a class build can, + because is any expression at all. FOLLOW THE OBJECT instead: + + start a run at an Object (creation), or at a load of a slot that a + static-key write then writes to (mutation). + skip forward to the next instruction that USES the object. + carry on only while that use is a static-key write to it. + everything passed over on the way is the value computation, and it + joins the run. + + A creation and a mutation differ only in how the object carries from one + write to the next: + + DefineField hands the object back as a NEW value, so a creation run + is one value chain. + PutField hands nothing back, so a mutation re-loads the object before + every write. + END the run at any of: the object is READ for anything but a field write -- passed to a call, used in an expression, branched on. a static-key write to a DIFFERENT object. + a jump, or an instruction some jump lands on. A run is ONE straight + line of code, so both sides of a branch are cut off. Without this + an `if` in the middle of a sequence of writes joins the two sides + into one run. "Same object": a mutation re-loads the object before each write (each GetArg0 / GetLocCheck is a fresh value), so sameness is judged by the load @@ -103,6 +125,27 @@ DETECTION (one linear walk) Ignore computed-key writes (DefineArrayEl / PutArrayEl) -- see extensions. + NESTED BUILDS: a run's own instructions are walked AGAIN, to find the + object builds inside it. Every Object in the program ends up in exactly + one lifted function, so one function describes one object shape. + + { x: { y: 1 }, z: { w: 2 } } lifts to THREE functions, not one. + + An inner run is held INSIDE its parent's parameter list, as the Run + parameter it becomes. So lifting is depth-first: lift a run's Run + parameters, then the run itself, which needs their results. + + The inner walk looks for BUILDS ONLY. A mutation writes its object over + and over, so a walk that looked for anything would match the run's own + second write as a run of its own: + + a.x = 1 ; a.y = 2 one mutation run, NOT a run with one nested + + ORDER IN THE PASS: demutate runs BEFORE declosure. declosure builds its + closure objects out of Object and DefineField Atom(int(k)), which a run + cannot tell apart from a user object. Run it after, and every closure gets + lifted as if it were an object literal. + ==================================================================== WORKED EXAMPLE (test), real instructions @@ -176,13 +219,24 @@ new FnById(Nm), name "", arg_count 1: FIELD VALUE FORMS -- all handled the same way ==================================================================== - constant a.x = 1 Push1 moves into the function - argument { x: p } GetArg becomes a parameter - computed a.x = p + 1 p is a parameter; the Add moves in - field read a.x = b.y b is a parameter; the GetField moves in - nested object { x: { y: 1 } } the inner Object is its own run, lifted - first; its result is a value the outer - run uses -> a parameter of the outer +One rule covers them all: a frame read (GetArg, GetLoc, GetVarRef) becomes a +parameter and its load stays at the call site; everything else moves in. + + a.x = 1 constant + Push1 is produced inside the run, so it moves in. + + { x: p } argument + GetArg is a frame read, so p becomes a parameter. + + a.x = p + 1 computed + p is a parameter; the Add is produced inside, so it moves in. + + a.x = b.y field read + b is a parameter; the GetField is produced inside, so it moves in. + + { x: { y: 1 } } nested object + The inner Object is a run of its OWN. It is lifted first, and the + outer run takes its result as a parameter. See NESTED BUILDS. ====================================================================