Initial type inference impl

This commit is contained in:
2026-09-08 18:52:47 +00:00
parent be53d3a5e5
commit 51bcac6225
5 changed files with 615 additions and 286 deletions
+167 -264
View File
@@ -1,303 +1,191 @@
//! demutate: object builds and in-place mutations become calls to a function //! demutate: every static-key field write becomes a call to a function that
//! that returns a fresh object (demutate_algo.txt). //! RETURNS a fresh object.
//! //!
//! This file is the detector. It finds the runs and works out what each one //! There is no grouping and no analysis of what surrounds a write. Each one
//! reads from outside itself; lifting a run into its own function, and putting //! stands alone, because its two arguments are the call's two arguments and
//! the call in its place, is the next step. //! the enclosing code already holds both:
//! //!
//! A run is a maximal contiguous stretch of static-key field writes to ONE //! define_field @F [v, o] -> [n] n = <define F>(o, v)
//! object, together with the value computations those writes consume: //! put_field @F [v, o] n = <put F>(o, v) ; store n back
//! //!
//! creation object ; ( <value> ; define_field @F )+ //! `object` is left as it is. It already makes a fresh object, so it needs no
//! mutation ( get_loc L ; <value> ; put_field @F )+ (same L) //! function; infer reads it as `Type::Empty`, the base every shape builds on.
//!
use std::range::Range; //! 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::{ use crate::{
parse::{ImmOperand, opcodes::OpCode}, infer::{CodeKind, InferFn, Type},
parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode},
regify::{RegFn, RegInstr}, regify::{RegFn, RegInstr},
}; };
pub fn find_object_runs(reg_fn: &RegFn) -> Vec<ObjectRun> { pub fn demutate(reg_fns: Vec<RegFn>) -> Vec<InferFn> {
let barriers = find_barriers(&reg_fn.code); // 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(&reg_fn.code, &barriers, 0, Look::Anything) let mut fns = reg_fns
.into_iter()
.map(|f| rewrite_fn(f, first_lifted, &mut lifted))
.collect::<Vec<InferFn>>();
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 /// One scan of the code. Everything that is not a static-key write is moved
/// mutation writes its object over and over, so matching anything would find /// across untouched; a write becomes a call, and a `put_field` a call and a
/// the run's own second write as a run of its own. /// store.
#[derive(Clone, Copy, PartialEq)] fn rewrite_fn(mut reg_fn: RegFn, first_lifted: usize, lifted: &mut Vec<CodeKind>) -> InferFn {
enum Look { let code = std::mem::take(&mut reg_fn.code);
Anything, let mut new = Vec::with_capacity(code.len());
BuildsOnly,
}
/// The runs in `ins`, each holding whatever runs are nested inside it. for instr in code {
fn collect_runs( let Some(field) = static_key(&instr) else {
mut ins: &[RegInstr], new.push(instr); // Not a field write, so it passes through
mut barriers: &[bool], continue;
mut offset: usize,
look: Look,
) -> Vec<ObjectRun> {
let mut found = vec![];
while !ins.is_empty() {
let run = match_run(ins, barriers, look); // Try to match a run
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
let range = Range {
start: offset,
end: offset + consumed,
}; };
// Walk the run's own instructions to find the builds nested in let define = instr.op == OpCode::DefineField;
// 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,
);
found.push(ObjectRun { // Where the object lives says where its replacement goes. The
range, // instruction that made it is already in `new`.
kind, let object = instr.args[1];
params: find_params(run_instr, offset, nested), let slot = (!define).then(|| frame_slot(&new, object)).flatten();
let callee =
intern_const(&mut reg_fn.cpool, first_lifted + intern_fn(lifted, instr.op, field));
let dst = match slot {
// define_field hands the object back, so the call keeps its dst
_ if define => instr.dsts[0],
// 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,
// 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(),
};
new.push(call(callee, dst, instr.args));
if let Some(slot) = slot.filter(|s| !matches!(s, Source::Loc(_))) {
new.push(store(slot, dst));
}
}
InferFn {
types: vec![Type::Unknown; reg_fn.num_vals()],
name: reg_fn.name,
cpool: reg_fn.cpool,
code: CodeKind::Code(new),
sigs: 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<AtomRef> {
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,
}
}
/// 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<CodeKind>, 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
})
}
/// 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<JsValue>, 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
}); });
offset += consumed; at as u32
ins = rest;
barriers = &barriers[consumed..];
}
// 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..];
}
}
}
found
} }
#[derive(Debug)] /// The frame slot an object lives in, from whichever instruction defined it.
pub struct ObjectRun { fn frame_slot(code: &[RegInstr], object: usize) -> Option<Source> {
pub range: Range<usize>, code.iter()
pub kind: RunKind, .find(|i| i.dsts.contains(&object))
/// What the run cannot make for itself, in first-use order. Each one .and_then(frame_load)
/// becomes a parameter of the lifted function.
pub params: Vec<Param>,
} }
/// A value the lifted function is handed rather than making. /// A frame slot, and the two instructions that read and write it.
#[derive(Debug)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Param { enum Source {
/// 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),
}
/// 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),
}
/// 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 {
Arg(u16), Arg(u16),
Loc(u16), Loc(u16),
VarRef(u16), VarRef(u16),
} }
/// Where a run may not reach across: a jump, and anything a jump lands on. A // ── emitted instructions ───────────────────────────────────────────────────
/// run is one straight line of code, so that both ends of a branch are cut off.
fn find_barriers(code: &[RegInstr]) -> Vec<bool> {
let mut barriers = vec![false; code.len()];
for (at, i) in code.iter().enumerate() { /// A call to a function fixed at rewrite time. The callee is named by the
if let ImmOperand::Label { target } = i.imm { /// immediate rather than pushed, so the call fits where the write stood.
barriers[at] = true; fn call(callee: u32, dst: usize, args: Vec<usize>) -> RegInstr {
barriers[target as usize] = true; RegInstr::new(OpCode::Call, ImmOperand::Const(callee), vec![dst], args)
}
}
barriers
} }
/// What the run has to be handed, in first-use order. A frame slot appears fn store(slot: Source, value: usize) -> RegInstr {
/// once however many times it is loaded, which folds a mutation's repeated let (op, imm) = match slot {
/// loads of its object into the one parameter. Source::Arg(k) => (OpCode::PutArg, ImmOperand::Arg(k)),
fn find_params(run: &[RegInstr], offset: usize, nested: Vec<ObjectRun>) -> Vec<Param> { Source::Loc(k) => (OpCode::PutLoc, ImmOperand::Loc(k)),
let mut params = vec![]; Source::VarRef(k) => (OpCode::PutVarRef, ImmOperand::VarRef(k)),
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 ; ( <value> ; define_field @F )+
// mutation ( get_loc L ; <value> ; put_field @F )+ (same L)
//
// <value> 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);
}; };
if first.op != OpCode::Object { RegInstr::new(op, imm, vec![], vec![value])
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..])
} }
fn match_mutation_run<'a>( fn lifted_name(code: &CodeKind) -> String {
ins: &'a [RegInstr], match code {
barriers: &[bool], CodeKind::DefineField(a) => format!("<define {a:?}>"),
) -> Result<(&'a [RegInstr], RunKind), &'a [RegInstr]> { CodeKind::PutField(a) => format!("<put {a:?}>"),
let Some(loc) = ins.first().and_then(frame_load) else { CodeKind::Code(_) => unreachable!("a lifted function has no body"),
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;
}
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 /// The frame slot an instruction reads. Only plain one-value loads count;
/// it all joins the run -- which is why a barrier in between ends the search. /// `get_loc0_loc1` pushes two.
fn next_use(ins: &[RegInstr], barriers: &[bool], v: usize) -> Option<usize> {
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<usize> {
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.
fn frame_load(i: &RegInstr) -> Option<Source> { fn frame_load(i: &RegInstr) -> Option<Source> {
let slot = |imm| match imm { let slot = |imm| match imm {
ImmOperand::Loc(k) | ImmOperand::Arg(k) | ImmOperand::VarRef(k) => k, ImmOperand::Loc(k) | ImmOperand::Arg(k) | ImmOperand::VarRef(k) => k,
@@ -315,7 +203,22 @@ fn frame_load(i: &RegInstr) -> Option<Source> {
OpCode::GetLoc1 => Some(Source::Loc(1)), OpCode::GetLoc1 => Some(Source::Loc(1)),
OpCode::GetLoc2 => Some(Source::Loc(2)), OpCode::GetLoc2 => Some(Source::Loc(2)),
OpCode::GetLoc3 => Some(Source::Loc(3)), 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::GetVarRef0 => Some(Source::VarRef(0)),
OpCode::GetVarRef1 => Some(Source::VarRef(1)), OpCode::GetVarRef1 => Some(Source::VarRef(1)),
+310
View File
@@ -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<Type>)),
Any,
}
#[derive(Debug)]
pub enum CodeKind {
Code(Vec<RegInstr>),
DefineField(AtomRef),
PutField(AtomRef),
}
#[derive(Debug)]
pub struct Signature {
pub args: Vec<Type>,
pub ret: Type,
}
#[derive(Debug)]
pub struct InferFn {
pub name: String,
pub cpool: Vec<JsValue>,
pub code: CodeKind,
pub sigs: Vec<Signature>,
/// 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<Type>,
}
/// 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<InferFn>) -> 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
}
+16 -5
View File
@@ -2,6 +2,7 @@ mod declass;
mod declosure; mod declosure;
mod demutate; mod demutate;
mod flatten; mod flatten;
mod infer;
mod parse; mod parse;
mod regify; mod regify;
@@ -9,7 +10,9 @@ use std::process::Command;
use declass::declass; use declass::declass;
use declosure::declosure; use declosure::declosure;
use demutate::demutate;
use flatten::flatten; use flatten::flatten;
use infer::infer;
use parse::QuickJSBinary; use parse::QuickJSBinary;
use regify::regify; use regify::regify;
@@ -44,15 +47,23 @@ fn main() {
let declassed = declass(reg_fns); let declassed = declass(reg_fns);
println!("{declassed:#?}"); println!("{declassed:#?}");
println!("=== demutate runs ===");
for f in &declassed {
println!("{}: {:#?}", f.name, demutate::find_object_runs(f));
}
println!("=== after declosure ==="); println!("=== after declosure ===");
let declosured = declosure(declassed); let declosured = declosure(declassed);
println!("{declosured:#?}"); 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!("{atoms:#?}");
/*println!("=== after deprototype ==="); /*println!("=== after deprototype ===");
+51
View File
@@ -85,11 +85,58 @@ impl RegFn {
reg_instr.args.push(v); reg_instr.args.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 { for _ in 0..n_push {
let v = fresh_val(); let v = fresh_val();
stack.push(v); stack.push(v);
reg_instr.dsts.push(v); reg_instr.dsts.push(v);
} }
}
}
output.push(reg_instr); output.push(reg_instr);
} }
@@ -109,6 +156,10 @@ impl RegFn {
self.next_val += 1; self.next_val += 1;
v v
} }
pub fn num_vals(&self) -> usize {
self.next_val
}
} }
pub fn regify(flat_fns: Vec<FlatFn>) -> Vec<RegFn> { pub fn regify(flat_fns: Vec<FlatFn>) -> Vec<RegFn> {
+76 -22
View File
@@ -34,16 +34,23 @@ DefineField both become DefineField on the fresh copy the function owns.
PARAMETERS -- the rule that keeps this general PARAMETERS -- the rule that keeps this general
==================================================================== ====================================================================
A value the run USES but does not itself PRODUCE becomes a parameter, in A run is handed anything it does not make for itself. Everything else moves
first-use order. Everything produced inside the run (Object, constants, into the function unchanged. There are two kinds of parameter:
arithmetic that is wholly inside) moves into the function unchanged.
the object being mutated is used-but-not-produced -> it is a parameter. FRAME. GetArg, GetLoc, GetVarRef and their numbered forms are frame
a constant field value (Push1) is produced inside -> it moves in. reads. Each becomes a parameter, and the load stays at the call site.
a field value from the enclosing frame (GetArg p) -> a parameter. 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 Everything else -- constants, arithmetic, field reads off a parameter -- is
`a` lifts to a 1-parameter function; `a.x = p + 1` lifts to a 2-parameter one. 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) DETECTION (one linear walk)
==================================================================== ====================================================================
start a run at an Object, or at the first static-key write to an object. A run cannot be matched opcode by opcode, the way a class build can,
extend it while the next instruction is one of: because <value> is any expression at all. FOLLOW THE OBJECT instead:
Object (a nested object is its own run)
a value computation feeding a write in this run start a run at an Object (creation), or at a load of a slot that a
DefineField, imm == Atom(F) (same object) static-key write then writes to (mutation).
PutField, imm == Atom(F) (same object) skip forward to the next instruction that USES the object.
a (re)load of the same 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: END the run at any of:
the object is READ for anything but a field write -- passed to a call, the object is READ for anything but a field write -- passed to a call,
used in an expression, branched on. used in an expression, branched on.
a static-key write to a DIFFERENT object. 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 "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 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. 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 WORKED EXAMPLE (test), real instructions
@@ -176,13 +219,24 @@ new FnById(Nm), name "<mutate>", arg_count 1:
FIELD VALUE FORMS -- all handled the same way FIELD VALUE FORMS -- all handled the same way
==================================================================== ====================================================================
constant a.x = 1 Push1 moves into the function One rule covers them all: a frame read (GetArg, GetLoc, GetVarRef) becomes a
argument { x: p } GetArg becomes a parameter parameter and its load stays at the call site; everything else moves in.
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 a.x = 1 constant
nested object { x: { y: 1 } } the inner Object is its own run, lifted Push1 is produced inside the run, so it moves in.
first; its result is a value the outer
run uses -> a parameter of the outer { 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.
==================================================================== ====================================================================