diff --git a/changelog.d/8201-scalar-param-guard-leaf.md b/changelog.d/8201-scalar-param-guard-leaf.md new file mode 100644 index 0000000000..e332ad5618 --- /dev/null +++ b/changelog.d/8201-scalar-param-guard-leaf.md @@ -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. diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index d0a85761dc..27bd299233 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -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, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 33496cc2a7..0fe85b17cc 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -2634,6 +2634,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> 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, diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs index 1b7a69533d..09793ce49c 100644 --- a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs +++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs @@ -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("); diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index 61ca15095d..683d220526 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -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 { + use super::typed_abi::TypedParamRep; + let word = |at: usize| -> Option { + 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\""); @@ -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(); diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index ea71e81c02..e6807f3e4e 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -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}" );