Compute phis

This commit is contained in:
2026-09-09 13:37:43 +00:00
parent 51bcac6225
commit 57829def5c
5 changed files with 539 additions and 277 deletions
+279 -188
View File
@@ -1,6 +1,6 @@
use crate::{
parse::{AtomRef, ImmOperand, JsValue, opcodes::OpCode},
regify::RegInstr,
regify::{Block, RegInstr},
};
#[derive(Debug, Clone, PartialEq)]
@@ -40,6 +40,7 @@ pub struct InferFn {
/// 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>,
pub blocks: Vec<Block>,
}
/// The lattice, joining what a value already holds with what it is being
@@ -92,217 +93,307 @@ pub fn infer(fn_id: usize, fns: &mut Vec<InferFn>) -> Type {
// 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 blocks = std::mem::take(&mut fns[fn_id].blocks);
// The blocks, read back off the instructions. Passes have moved them, but
// none of them adds or removes a jump, so this is the partition regify
// found and `succ` still lines up with it. Anything inserted above the
// first boundary belongs to the first block.
let mut starts = (code.iter().enumerate())
.filter(|(_, i)| i.block_start)
.map(|(at, _)| at)
.collect::<Vec<usize>>();
if let Some(first) = starts.first_mut() {
*first = 0;
}
// One flag in, one block out. A pass may move a boundary or insert around
// it, but it must carry the flag when it REPLACES the instruction holding
// one -- that is what `RegInstr::starting` is for. Miss it and the blocks
// silently stop lining up with the edges regify recorded.
debug_assert_eq!(
starts.len(),
blocks.len(),
"{}: block starts lost or gained since regify",
fns[fn_id].name
);
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
);
// What each block is entered with, joined from the blocks that reach it.
let mut entry = vec![vec![Type::Unknown; types.len()]; starts.len()];
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,
for (b, &start) in starts.iter().enumerate() {
let end = starts.get(b + 1).copied().unwrap_or(code.len());
OpCode::PushTrue | OpCode::PushFalse => Type::Bool,
OpCode::Undefined => Type::Undefined,
OpCode::Object => Type::Empty,
// Fold in what the blocks reaching this one left behind. Two paths
// meeting is the ONLY place the algebra applies -- everything below
// runs in a straight line.
for (t, e) in types.iter_mut().zip(&entry[b]) {
*t = join(t, e);
}
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),
// A slot at a merge keeps the ids the first path in left, so every
// other path's value has to be folded into them. Without this the
// merge reads one arm and believes it.
for &(slot, incoming) in &blocks[b].phi {
types[slot] = join(&types[slot], &types[incoming]);
}
for instr in &code[start..end] {
// 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,
},
_ => 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]]) {
// ── 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)
OpCode::Neg | OpCode::Plus | OpCode::Inc | OpCode::Dec => {
match types[instr.args[0]] {
Type::Int => Type::Int,
Type::Float => Type::Float,
_ => Type::Any,
}
None => 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
| OpCode::TailCall => {
// 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[..]),
},
};
let result = 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,
};
// A tail call hands its result straight back, so it ends
// the function as much as a `return` does.
if instr.op == OpCode::TailCall {
ret = join(&ret, &result);
}
result
}
// A method call keeps its receiver under the callee, so the
// callee is not the last argument and the lookup above does not
// find it. Resolving that is not done yet.
OpCode::CallMethod | OpCode::TailCallMethod => {
if instr.op == OpCode::TailCallMethod {
ret = join(&ret, &Type::Any);
}
Type::Any
}
// ── globals ─────────────────────────────────────────────────
// A free name is a property of the global object, which nothing
// here models -- every built-in reaches this way too. Writing
// one (`put_var`) records nothing, because a global is not a
// value and has no id to record it against.
OpCode::GetVar | OpCode::GetVarUndef => 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 ─────────────────────────────────────────────────
// Every way out has to feed `ret`. Missing one leaves it at
// Unknown, the BOTTOM, which a caller folds away and believes
// -- where Any would have been distrusted.
OpCode::Return | OpCode::ReturnAsync => {
ret = join(&ret, &types[instr.args[0]]);
Type::Unknown
}
OpCode::ReturnUndef => {
ret = join(&ret, &Type::Undefined);
Type::Unknown
}
// ── everything else ─────────────────────────────────────────
_ => Type::Any,
};
// A write joins with what the value already holds, so
// reassigning a local a different type widens it to Any. Almost
// every reassignment keeps the type, and join(T, T) = T, so the
// common path costs nothing.
//
// Nothing branches inside a block, so plain `types[dst] = ty`
// would be sound here and would keep `let x = 1; x = 2.5` as
// Float. That is a one-line swap; the blocks do their own job
// either way, which is to join where paths MEET.
if !load {
for &dst in &instr.dsts {
types[dst] = join(&types[dst], &ty);
}
}
}
// ── 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);
// Hand the state on to every block this one reaches.
for &s in &blocks[b].succ {
for (e, t) in entry[s].iter_mut().zip(&types) {
*e = join(e, t);
}
}
}
fns[fn_id].types = types;
fns[fn_id].blocks = blocks;
fns[fn_id].code = CodeKind::Code(code);
fns[fn_id].sigs[0].ret = ret.clone();