Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/8201-scalar-param-guard-leaf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`perf(codegen)`: the public spec wrapper now decides single-node **scalar** ordinary-parameter descriptors (`number`, `Int32`, `boolean`, `string`) with the existing typed-abi leaf guards instead of the interpretive `js_param_type_guard`, whose fixed per-call cost — descriptor parse plus a 768-byte `GuardState` init — measured as 33.8% / 19.6% / 17.0% / 16.3% of ALL instructions retired on the `tree` / `interp` / `tree_wide` / `iso_miss` corpus rows at `bfb0707be`, because every unproven direct call (including a Tier-B clone's own recursion, #8169) routes through the wrapper. Sound by predicate equality: each leaf guard computes bit-for-bit the same predicate as the validator's op (`OP_NUMBER` = `is_number || is_int32`, `OP_BOOLEAN` = `TAG_TRUE | TAG_FALSE`, `OP_STRING` = `is_any_string`, `OP_INT32` already delegates), so routing decisions are unchanged for every caller, honest or lying. Structural descriptors keep the validator; scalar ones stop emitting rodata blobs. Measured on the 19-program corpus (instructions retired, best-of-5, stdout byte-exact, peak RSS unchanged on every row): `tree` −31.8%, `tree_wide` −15.9%, `interp` −8.3%, `iso_miss` −6.9%, everything else within noise. Found while re-measuring #8079, whose actual ask — lifting the `guard_blocked` reference-aliasing rule — measured at exactly 0.0% on the eight regressed rows even with hypothetically free guards; the rule is correct as written.
29 changes: 19 additions & 10 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,16 +375,25 @@ fn emit_public_spec_function_trampoline(
});
}
if let Some(descriptor) = descriptor {
let raw = blk.call(
I32,
"js_param_type_guard",
&[
(DOUBLE, arg.as_str()),
(PTR, &format!("@{}", descriptor.descriptor_name)),
(I32, &descriptor.descriptor.len().to_string()),
],
);
let ok = blk.icmp_ne(I32, &raw, "0");
let ok = if let Some(rep) =
super::param_guard::scalar_descriptor_rep(&descriptor.descriptor)
{
// (#8079) Scalar proof: the typed-abi leaf guard decides
// the exact same predicate without the interpretive
// validator's per-call descriptor parse + state init.
emit_typed_arg_guard(blk, rep, arg)
} else {
let raw = blk.call(
I32,
"js_param_type_guard",
&[
(DOUBLE, arg.as_str()),
(PTR, &format!("@{}", descriptor.descriptor_name)),
(I32, &descriptor.descriptor.len().to_string()),
],
);
blk.icmp_ne(I32, &raw, "0")
};
guard = Some(match guard {
Some(prev) => blk.and(I1, &prev, &ok),
None => ok,
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2634,6 +2634,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
continue;
};
for guard in plan.guards.iter().flatten() {
// (#8079) Scalar descriptors are decided inline by a
// typed-abi leaf guard; no rodata blob is referenced.
if param_guard::scalar_descriptor_rep(&guard.descriptor).is_some() {
continue;
}
llmod.add_named_string_constant(
&guard.descriptor_name,
guard.descriptor.len() + 1,
Expand Down
16 changes: 14 additions & 2 deletions crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,20 @@ fn nonsuspending_async_function_needs_no_direct_call_site_for_its_guarded_clone(
let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles"))
.expect("LLVM IR is UTF-8");
let public = function_ir(&ir, "@perry_fn_ordinary_param_guard_async_ts__renderAsync(");
assert!(public.contains("call i32 @js_param_type_guard("));
assert_eq!(public.matches("call i32 @js_param_type_guard(").count(), 2);
// (#8079) Scalar descriptors are decided by the typed-abi leaf guards —
// same predicate, none of the interpretive validator's per-call cost.
// The interpretive validator must not appear for a string/number tuple.
assert!(!public.contains("call i32 @js_param_type_guard("));
assert_eq!(
public
.matches("call i32 @js_typed_string_arg_guard(")
.count(),
1
);
assert_eq!(
public.matches("call i32 @js_typed_f64_arg_guard(").count(),
1
);
assert!(public.contains("$spec_b_b("));
assert!(public.contains("$generic("));
let specialized = function_ir(&ir, "renderAsync$spec_b_b(");
Expand Down
85 changes: 85 additions & 0 deletions crates/perry-codegen/src/codegen/param_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,49 @@ pub(crate) fn body_contains_await(stmts: &[perry_hir::Stmt]) -> bool {
})
}

/// A single-node scalar descriptor whose predicate an existing typed-abi
/// leaf guard already decides EXACTLY (#8079: the interpretive
/// `js_param_type_guard` costs ~450 instructions per call — descriptor
/// parse plus a 768-byte `GuardState` init — which measured as 16-34% of
/// ALL retired instructions on the tree/tree_wide/interp/iso_miss corpus
/// rows, because every unproven call routes through the public wrapper):
///
/// * `OP_NUMBER` (1) = `js_typed_f64_arg_guard` = `is_number || is_int32`
/// * `OP_INT32` (2) — the validator literally calls `js_typed_i32_arg_guard`
/// * `OP_BOOLEAN` (3) = `js_typed_i1_arg_guard` = `TAG_TRUE | TAG_FALSE`
/// * `OP_STRING` (4) = `js_typed_string_arg_guard` = `is_any_string`
///
/// The predicate equivalence is what makes this sound: routing (clone vs
/// generic fallback) is bit-for-bit the decision the validator would have
/// made. Anything structural — unions, objects, arrays, literals — keeps
/// the descriptor call. Layout checked exhaustively so a future format
/// change fails back to the validator instead of misreading bytes.
pub(crate) fn scalar_descriptor_rep(descriptor: &[u8]) -> Option<super::typed_abi::TypedParamRep> {
use super::typed_abi::TypedParamRep;
let word = |at: usize| -> Option<u32> {
Some(u32::from_le_bytes(
descriptor.get(at..at + 4)?.try_into().ok()?,
))
};
// magic | root | node_count | offsets (node_count+1) | bodies
if descriptor.len() != 21
|| word(0)? != MAGIC
|| word(4)? != 0
|| word(8)? != 1
|| word(12)? != 20
|| word(16)? != 21
{
return None;
}
match descriptor[20] {
1 => Some(TypedParamRep::F64),
2 => Some(TypedParamRep::I32),
3 => Some(TypedParamRep::I1),
4 => Some(TypedParamRep::StringRef),
_ => None,
}
}

/// LLVM `c"..."` encoding for a binary descriptor plus its sentinel byte.
pub(crate) fn descriptor_llvm_literal(bytes: &[u8]) -> String {
let mut out = String::from("c\"");
Expand All @@ -574,6 +617,48 @@ pub(crate) fn descriptor_llvm_literal(bytes: &[u8]) -> String {
mod tests {
use super::*;

/// (#8079) The four ops the leaf guards decide classify; everything
/// structural, literal, truncated, or foreign stays with the validator.
/// Built through the real encoder so a layout change flips this red
/// instead of silently misclassifying.
#[test]
fn scalar_descriptor_rep_classifies_exactly_the_leaf_guard_ops() {
use super::super::typed_abi::TypedParamRep;
let build = |ty: &Type| {
descriptor_for_type(
ty,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap()
};
assert_eq!(
scalar_descriptor_rep(&build(&Type::Number)),
Some(TypedParamRep::F64)
);
assert_eq!(
scalar_descriptor_rep(&build(&Type::Int32)),
Some(TypedParamRep::I32)
);
assert_eq!(
scalar_descriptor_rep(&build(&Type::Boolean)),
Some(TypedParamRep::I1)
);
assert_eq!(
scalar_descriptor_rep(&build(&Type::String)),
Some(TypedParamRep::StringRef)
);
assert_eq!(
scalar_descriptor_rep(&build(&Type::Array(Box::new(Type::Number)))),
None
);
assert_eq!(scalar_descriptor_rep(&build(&Type::Null)), None);
assert_eq!(scalar_descriptor_rep(&build(&Type::Number)[..20]), None);
assert_eq!(scalar_descriptor_rep(b"PGT1"), None);
}

#[test]
fn recursive_alias_serializes_as_a_finite_graph() {
let mut props = HashMap::new();
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-codegen/tests/native_proof_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7551,8 +7551,13 @@ fn compiler_private_async_iter_result_annotated_numeric_payload_stays_generic()
"the guarded clone should consume its descriptor proof:\n{clone}"
);
let entry = function_ir_section(&ir, symbol);
// (#8079) A scalar (Number) descriptor is decided by the typed-abi leaf
// guard — same predicate as the validator's OP_NUMBER, no interpretive
// per-call cost. The invariant is unchanged: the raw-f64 clone is only
// reachable through the entry guard, with the generic body as fallback.
assert!(
entry.contains("call i32 @js_param_type_guard(")
entry.contains("call i32 @js_typed_f64_arg_guard(")
&& !entry.contains("call i32 @js_param_type_guard(")
&& entry.contains(&format!("@{symbol}$generic(")),
"the raw-f64 clone must be reachable only through the entry guard, with the generic body as fallback:\n{entry}"
);
Expand Down
Loading