diff --git a/changelog.d/9773-typed-feedback-profile-replay.md b/changelog.d/9773-typed-feedback-profile-replay.md new file mode 100644 index 0000000000..64290649e4 --- /dev/null +++ b/changelog.d/9773-typed-feedback-profile-replay.md @@ -0,0 +1 @@ +Add opt-in typed-feedback profile replay for guarded numeric array reads, with versioned capture catalogs, exact freshness checks, deterministic selection and rejection diagnostics, native-region verifier checks, and explain-lowering evidence. Profiles remain advisory and retain the runtime guard and boxed fallback. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ba4899a9ef..8322aeb0fd 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -3662,6 +3662,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> progress.phase(1, "lowering complete; finalizing generated IR"); crate::root_reload::apply_to_module(&mut llmod); + crate::typed_feedback_profile::finish_module(&mut llmod.native_rep_records); + let verify_native_regions = opts.verify_native_regions || std::env::var("PERRY_VERIFY_NATIVE_REGIONS").ok().as_deref() == Some("1"); if verify_native_regions { diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index a7fdc91289..ff889c781a 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -190,6 +190,20 @@ pub(super) fn lower_guarded_array_index_get( coerce_numeric_fallback: bool, receiver_slot: Option<&str>, ) -> Result { + let site_id = ctx.typed_feedback_site_id(ctx.ic_site_counter); + crate::typed_feedback_profile::register_site( + site_id, + &ctx.func.name, + "array_element", + "array[index]", + ); + let replay_fact = + crate::typed_feedback_profile::select_numeric_array(site_id, require_numeric_layout); + // Preserve the original consumer's coercion contract. A replay hint can + // select representation handling, but cannot turn a JS-value read into + // a numeric-context read. + let coerce_numeric_fallback = require_numeric_layout && coerce_numeric_fallback; + let require_numeric_layout = require_numeric_layout || replay_fact.is_some(); let contract = if require_numeric_layout { TypedFeedbackContract::numeric_array_get_index() } else { @@ -201,6 +215,10 @@ pub(super) fn lower_guarded_array_index_get( "array[index]", contract, ); + // Replay selects the existing numeric tier, including its full inline + // receiver/layout/bounds checks and cold runtime guard. The observation + // itself never admits a load or suppresses a check. + let inline_guard = !typed_feedback_emission_enabled(); let fast_idx = ctx.new_block(&format!("{}.fast", block_prefix)); let fallback_idx = ctx.new_block(&format!("{}.fallback", block_prefix)); // A non-negative ordinary-array index at or above `length` has no own @@ -210,7 +228,7 @@ pub(super) fn lower_guarded_array_index_get( // properties, that result is `undefined` without consulting the generic // polymorphic getter. Sparse-set membership tests hit exactly this arm for // absent ids, so keep it separate from the in-bounds raw-load block. - let inline_oob_idx = if !typed_feedback_emission_enabled() { + let inline_oob_idx = if inline_guard { Some(ctx.new_block(&format!("{}.guard.oob", block_prefix))) } else { None @@ -226,7 +244,7 @@ pub(super) fn lower_guarded_array_index_get( let mut inline_fast_handle: Option<(String, String)> = None; let mut runtime_fast_handle: Option<(String, String)> = None; - if !typed_feedback_emission_enabled() { + if inline_guard { // Normal builds do not collect feedback. Inline the plain-array // structural guard instead of paying an out-of-line call merely to // rediscover the same header facts before the direct slot load below. @@ -535,7 +553,10 @@ pub(super) fn lower_guarded_array_index_get( ], false, false, - Vec::new(), + replay_fact + .as_ref() + .map(|fact| vec![format!("typed_feedback_replay_fallback={}", fact.fact_id)]) + .unwrap_or_default(), ); } @@ -617,16 +638,27 @@ pub(super) fn lower_guarded_array_index_get( None, None, None, - vec![raw_f64_layout_fact( - None, - "consumed", - "numeric_array_index_get_guard", - None, - )], + { + let mut facts = vec![raw_f64_layout_fact( + None, + "consumed", + "numeric_array_index_get_guard", + None, + )]; + if let Some(fact) = &replay_fact { + facts.push(fact.clone()); + } + facts + }, Vec::new(), false, false, - Vec::new(), + replay_fact + .as_ref() + .map(|_| { + vec!["typed_feedback_replay_selected=fresh_numeric_array_observation".into()] + }) + .unwrap_or_default(), ); } diff --git a/crates/perry-codegen/src/expr/typed_feedback.rs b/crates/perry-codegen/src/expr/typed_feedback.rs index 78aff0e2c6..13b5f669a5 100644 --- a/crates/perry-codegen/src/expr/typed_feedback.rs +++ b/crates/perry-codegen/src/expr/typed_feedback.rs @@ -322,6 +322,7 @@ pub(crate) fn emit_typed_feedback_register_site( let local_site_id = ctx.ic_site_counter; ctx.ic_site_counter += 1; let site_id = ctx.typed_feedback_site_id(local_site_id); + crate::typed_feedback_profile::register_site(site_id, &ctx.func.name, kind.label(), operation); // Default build: skip the no-op registration call (and its byte globals) // but keep the site-id stable for the guard call. if !typed_feedback_emission_enabled() { diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 8186dcb47f..af644d78bb 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -68,6 +68,7 @@ pub(crate) mod type_analysis; pub(crate) mod type_analysis_class_fields; pub(crate) mod type_analysis_facts; pub(crate) mod type_analysis_net; +pub mod typed_feedback_profile; pub(crate) mod typed_shape; pub mod types; diff --git a/crates/perry-codegen/src/native_value/verify.rs b/crates/perry-codegen/src/native_value/verify.rs index 30679a3d09..7cf235543b 100644 --- a/crates/perry-codegen/src/native_value/verify.rs +++ b/crates/perry-codegen/src/native_value/verify.rs @@ -26,6 +26,7 @@ use raw_f64::{ pub(crate) fn verify_native_rep_records(records: &[NativeRepRecord]) -> Result<()> { let mut errors = Vec::new(); + crate::typed_feedback_profile::verify_records(records, &mut errors); for record in records { if let Some(expected_ty) = expected_llvm_type(&record.native_rep) { if record.llvm_ty != expected_ty { diff --git a/crates/perry-codegen/src/typed_feedback_profile.rs b/crates/perry-codegen/src/typed_feedback_profile.rs new file mode 100644 index 0000000000..1aba20c68c --- /dev/null +++ b/crates/perry-codegen/src/typed_feedback_profile.rs @@ -0,0 +1,478 @@ +//! Versioned, advisory typed-feedback replay. No observation is a runtime proof. +//! +//! The driver supplies source/compiler/configuration identity; this module matches +//! exact sites during lowering. State is scoped to one synchronous codegen call on +//! a rayon worker, and restored on every exit (including errors and unwinding). +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::native_value::{NativeFactUse, NativeRepRecord}; +use crate::{compile_module, CompileOptions}; + +pub fn effective_target(opts: &CompileOptions) -> String { + opts.target + .clone() + .unwrap_or_else(crate::codegen::helpers::default_target_triple) +} + +pub const SCHEMA_VERSION: u32 = 1; +pub const NUMERIC_ARRAY_ELEMENT: &str = "numeric_array_element"; +pub(crate) const NUMERIC_GUARD: &str = "numeric_array_index_get_guard"; +pub(crate) const ARRAY_FALLBACK: &str = "js_typed_feedback_array_index_get_fallback_boxed"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub schema_version: u32, + /// Exact compiler executable SHA-256, including same-version development builds. + pub compiler: String, + pub modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleIdentity { + pub module: String, + pub source_hash: String, + pub hir_hash: String, + pub lowering_hash: String, + pub target: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleProfile { + pub identity: ModuleIdentity, + pub sites: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Site { + pub site_id: u64, + pub function: String, + pub kind: String, + pub operation: String, + /// Only numeric_array_element is currently supported. Captured catalogs use + /// unobserved until joined with a runtime trace by the capture utility. + pub observation_kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Decision { + pub module: String, + pub site_id: Option, + pub function: String, + pub accepted: bool, + pub reason: String, +} + +pub struct Session { + compiler: String, + profile: Option, + captured: Mutex>, + decisions: Mutex>, +} + +impl Session { + pub fn new(compiler: String, profile: Option) -> Self { + Self { + compiler, + profile, + captured: Mutex::new(BTreeMap::new()), + decisions: Mutex::new(Vec::new()), + } + } + + /// Strict parsing for explicit input; unknown schema/observation versions are + /// well-formed but rejected later, with an explanation for every entry. + pub fn read_profile(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("cannot read --typed-feedback-profile {}", path.display()))?; + let diagnostic = || { + format!("invalid --typed-feedback-profile {}: expected a versioned replay profile; create one with scripts/typed-feedback-profile.py", path.display()) + }; + let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(diagnostic)?; + // A future schema may use a different body. Its version is sufficient + // to reject the whole profile without interpreting unknown fields. + if let Some(version) = value + .get("schema_version") + .and_then(serde_json::Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v != SCHEMA_VERSION) + { + return Ok(serde_json::from_value(value).unwrap_or_else(|_| Profile { + schema_version: version, + compiler: String::new(), + modules: Vec::new(), + })); + } + serde_json::from_value(value).with_context(diagnostic) + } + + pub fn compile_module( + &self, + hir: &perry_hir::Module, + opts: CompileOptions, + identity: ModuleIdentity, + ) -> Result> { + let state = ModuleState::new(&self.compiler, self.profile.as_ref(), identity); + let previous = ACTIVE.with(|active| active.replace(Some(state))); + let _scope = Scope(previous); + let result = compile_module(hir, opts); + let state = ACTIVE + .with(|active| active.borrow_mut().take()) + .expect("feedback scope"); + if result.is_ok() { + self.decisions.lock().unwrap().extend(state.decisions); + self.captured.lock().unwrap().insert( + state.identity.module.clone(), + ModuleProfile { + identity: state.identity, + sites: state.sites.into_values().collect(), + }, + ); + } + result + } + + /// Call after all modules finish, before explain-lowering reads artifacts. + pub fn finish(&self, catalog_path: Option<&Path>) -> Result> { + let captured = self.captured.lock().unwrap(); + if let Some(path) = catalog_path { + let catalog = Profile { + schema_version: SCHEMA_VERSION, + compiler: self.compiler.clone(), + modules: captured.values().cloned().collect(), + }; + std::fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&catalog)?), + ) + .with_context(|| { + format!( + "cannot write typed-feedback site catalog {}", + path.display() + ) + })?; + } + let mut decisions = self.decisions.lock().unwrap().clone(); + let mut unmatched = Vec::new(); + if let Some(profile) = &self.profile { + // Even an empty incompatible profile needs a profile-level diagnostic. + if let Some(reason) = profile_rejection(&self.compiler, profile) { + let decision = Decision { + module: "".into(), + site_id: None, + function: String::new(), + accepted: false, + reason: reason.into(), + }; + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + for module in &profile.modules { + if !captured.contains_key(&module.identity.module) { + let reason = + profile_rejection(&self.compiler, profile).unwrap_or("unknown_module"); + if module.sites.is_empty() { + let decision = module_rejection(&module.identity.module, reason); + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + for site in &module.sites { + let decision = rejected(&module.identity.module, site, reason); + unmatched.push(rejection_record(&decision)); + decisions.push(decision); + } + } + } + } + if !unmatched.is_empty() { + crate::native_value::write_native_rep_artifact_if_enabled( + "typed_feedback_profile", + &unmatched, + )?; + } + decisions.sort(); + Ok(decisions) + } +} + +fn profile_rejection(compiler: &str, profile: &Profile) -> Option<&'static str> { + if profile.schema_version != SCHEMA_VERSION { + Some("schema_mismatch") + } else if profile.compiler != compiler { + Some("compiler_mismatch") + } else { + None + } +} + +fn identity_rejection(expected: &ModuleIdentity, actual: &ModuleIdentity) -> Option<&'static str> { + if expected.source_hash != actual.source_hash { + Some("source_hash_mismatch") + } else if expected.target != actual.target { + Some("target_mismatch") + } else if expected.hir_hash != actual.hir_hash { + Some("hir_hash_mismatch") + } else if expected.lowering_hash != actual.lowering_hash { + Some("lowering_inputs_mismatch") + } else { + None + } +} + +struct ModuleState { + compiler: String, + identity: ModuleIdentity, + sites: BTreeMap, + pending: BTreeMap, + decisions: Vec, +} + +impl ModuleState { + fn new(compiler: &str, profile: Option<&Profile>, identity: ModuleIdentity) -> Self { + let mut state = Self { + compiler: compiler.into(), + identity, + sites: BTreeMap::new(), + pending: BTreeMap::new(), + decisions: Vec::new(), + }; + if let Some(profile) = profile { + let modules: Vec<_> = profile + .modules + .iter() + .filter(|m| m.identity.module == state.identity.module) + .collect(); + for module in &modules { + let reason = profile_rejection(compiler, profile) + .or_else(|| (modules.len() != 1).then_some("duplicate_module")) + .or_else(|| identity_rejection(&module.identity, &state.identity)); + if module.sites.is_empty() { + if let Some(reason) = reason { + state + .decisions + .push(module_rejection(&state.identity.module, reason)); + } + } + let mut seen = BTreeSet::new(); + let duplicates: BTreeSet<_> = module + .sites + .iter() + .filter_map(|s| (!seen.insert(s.site_id)).then_some(s.site_id)) + .collect(); + for site in &module.sites { + let reason = reason + .or_else(|| { + duplicates + .contains(&site.site_id) + .then_some("duplicate_site") + }) + .or_else(|| { + (site.observation_kind != NUMERIC_ARRAY_ELEMENT) + .then_some("unsupported_observation_kind") + }); + if let Some(reason) = reason { + state + .decisions + .push(rejected(&state.identity.module, site, reason)); + } else { + state.pending.insert(site.site_id, site.clone()); + } + } + } + } + state + } +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} +struct Scope(Option); +impl Drop for Scope { + fn drop(&mut self) { + ACTIVE.with(|active| { + active.replace(self.0.take()); + }); + } +} + +pub(crate) fn register_site(site_id: u64, function: &str, kind: &str, operation: &str) { + ACTIVE.with(|active| { + if let Some(state) = active.borrow_mut().as_mut() { + state.sites.insert( + site_id, + Site { + site_id, + function: function.into(), + kind: kind.into(), + operation: operation.into(), + observation_kind: "unobserved".into(), + }, + ); + } + }); +} + +/// The sole selection seam: called only for an existing plain, checked array +/// read. The caller must emit the full numeric guard (inline or runtime) and +/// boxed fallback. +pub(crate) fn select_numeric_array(site_id: u64, already_numeric: bool) -> Option { + ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + let state = active.as_mut()?; + let observed = state.pending.remove(&site_id)?; + let site = state.sites.get(&site_id)?; + let reason = if observed.function != site.function || observed.kind != site.kind || observed.operation != site.operation { + Some("site_identity_mismatch") + } else if already_numeric { + Some("already_specialized") + } else { + None + }; + if let Some(reason) = reason { + state.decisions.push(rejected(&state.identity.module, &observed, reason)); + return None; + } + state.decisions.push(Decision { module: state.identity.module.clone(), site_id: Some(site_id), function: site.function.clone(), accepted: true, reason: "fresh_numeric_array_observation".into() }); + Some(NativeFactUse { + fact_id: format!("typed_feedback_replay:{}:{site_id}", state.identity.module), + kind: "typed_feedback_replay".into(), local_id: None, state: "consumed".into(), + detail: format!("fresh_numeric_array_observation;schema_version={};compiler={};source_hash={};hir_hash={};lowering_hash={};target={};advisory=true", SCHEMA_VERSION, state.compiler, state.identity.source_hash, state.identity.hir_hash, state.identity.lowering_hash, state.identity.target), + reason: None, + }) + }) +} + +pub(crate) fn finish_module(records: &mut Vec) { + ACTIVE.with(|active| { + if let Some(state) = active.borrow_mut().as_mut() { + for (id, site) in std::mem::take(&mut state.pending) { + let reason = if state.sites.contains_key(&id) { + "unsupported_site" + } else { + "unknown_site" + }; + state + .decisions + .push(rejected(&state.identity.module, &site, reason)); + } + state.decisions.sort(); + records.extend( + state + .decisions + .iter() + .filter(|d| !d.accepted) + .map(rejection_record), + ); + } + }); +} + +fn module_rejection(module: &str, reason: &str) -> Decision { + Decision { + module: module.into(), + site_id: None, + function: String::new(), + accepted: false, + reason: reason.into(), + } +} + +fn rejected(module: &str, site: &Site, reason: &str) -> Decision { + Decision { + module: module.into(), + site_id: Some(site.site_id), + function: site.function.clone(), + accepted: false, + reason: reason.into(), + } +} + +fn rejection_record(decision: &Decision) -> NativeRepRecord { + // Reuse the ordinary decision-record representation, with replay's own + // discriminator and facts (not a typed-clone decision). + let mut record = crate::native_value::typed_clone_rejection_record( + &decision.function, + "typed_feedback_profile", + &decision.reason, + Vec::new(), + ); + record.expr_kind = "TypedFeedbackReplayDecision".into(); + record.notes = vec![ + format!("typed_feedback_replay_rejected={}", decision.reason), + format!("profile_module={}", decision.module), + ]; + record.rejected_facts.push(NativeFactUse { + fact_id: format!( + "typed_feedback_replay:{}:{}", + decision.module, + decision + .site_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "profile".into()) + ), + kind: "typed_feedback_replay".into(), + local_id: None, + state: "rejected".into(), + detail: decision.reason.clone(), + reason: None, + }); + record +} + +#[cfg(test)] +mod tests; + +/// Replay claims are valid only when tied to a consumed, fresh observation, +/// the runtime numeric-layout/bounds proof, and the emitted boxed side exit. +pub(crate) fn verify_records(records: &[NativeRepRecord], errors: &mut Vec) { + use crate::native_value::{BoundsState, BufferAccessMode, MaterializationReason}; + for record in records { + let claims_selection = record + .notes + .iter() + .any(|n| n.starts_with("typed_feedback_replay_selected=")); + let facts: Vec<_> = record + .consumed_facts + .iter() + .filter(|f| f.kind == "typed_feedback_replay") + .collect(); + if !claims_selection && facts.is_empty() { + continue; + } + let valid_fact = claims_selection + && facts.len() == 1 + && facts[0].state == "consumed" + && facts[0] + .detail + .starts_with("fresh_numeric_array_observation;"); + let valid_guard = record.expr_kind == "NumericArrayIndexGet" + && record.consumer == "js_array_numeric_get_f64_unboxed" + && record.native_rep == crate::native_value::NativeRep::F64 + && matches!(&record.bounds_state, Some(BoundsState::Guarded { guard_id }) if guard_id == NUMERIC_GUARD) + && record.access_mode == Some(BufferAccessMode::CheckedNative) + && record.consumed_facts.iter().any(|f| { + f.kind == "raw_f64_layout" && f.state == "consumed" && f.detail == NUMERIC_GUARD + }); + let valid_fallback = valid_fact + && records.iter().any(|fallback| { + fallback.function == record.function + && fallback.block_label != record.block_label + && fallback.consumer == ARRAY_FALLBACK + && fallback.access_mode == Some(BufferAccessMode::DynamicFallback) + && fallback.materialization_reason == Some(MaterializationReason::RuntimeApi) + && fallback.notes.contains(&format!( + "typed_feedback_replay_fallback={}", + facts[0].fact_id + )) + }); + if !valid_fact || !valid_guard || !valid_fallback { + errors.push(format!("{}:{} profile-directed specialization requires a consumed fresh replay fact, matching runtime guard, and explicit fallback/materialization record", record.function, record.block_label)); + } + } +} diff --git a/crates/perry-codegen/src/typed_feedback_profile/tests.rs b/crates/perry-codegen/src/typed_feedback_profile/tests.rs new file mode 100644 index 0000000000..39b6c49497 --- /dev/null +++ b/crates/perry-codegen/src/typed_feedback_profile/tests.rs @@ -0,0 +1,202 @@ +use super::*; + +fn identity() -> ModuleIdentity { + ModuleIdentity { + module: "main.ts".into(), + source_hash: "source".into(), + hir_hash: "hir".into(), + lowering_hash: "opts".into(), + target: "x86_64-unknown-linux-gnu".into(), + } +} +fn site() -> Site { + Site { + site_id: 42, + function: "read".into(), + kind: "array_element".into(), + operation: "array[index]".into(), + observation_kind: NUMERIC_ARRAY_ELEMENT.into(), + } +} +fn profile() -> Profile { + Profile { + schema_version: SCHEMA_VERSION, + compiler: "compiler".into(), + modules: vec![ModuleProfile { + identity: identity(), + sites: vec![site()], + }], + } +} +fn enter(profile: &Profile) -> Scope { + Scope(ACTIVE.with(|active| { + active.replace(Some(ModuleState::new( + "compiler", + Some(profile), + identity(), + ))) + })) +} +fn register() { + register_site(42, "read", "array_element", "array[index]"); +} + +#[test] +fn exact_freshness_and_duplicate_rejections() { + let cases: &[(&str, fn(&mut Profile))] = &[ + ("schema_mismatch", |p| p.schema_version += 1), + ("compiler_mismatch", |p| p.compiler.push('x')), + ("source_hash_mismatch", |p| { + p.modules[0].identity.source_hash.push('x') + }), + ("target_mismatch", |p| { + p.modules[0].identity.target.push('x') + }), + ("hir_hash_mismatch", |p| { + p.modules[0].identity.hir_hash.push('x') + }), + ("lowering_inputs_mismatch", |p| { + p.modules[0].identity.lowering_hash.push('x') + }), + ("unsupported_observation_kind", |p| { + p.modules[0].sites[0].observation_kind = "shape_address".into() + }), + ("duplicate_module", |p| p.modules.push(p.modules[0].clone())), + ("duplicate_site", |p| p.modules[0].sites.push(site())), + ]; + for (reason, mutate) in cases { + let mut profile = profile(); + mutate(&mut profile); + let _scope = enter(&profile); + register(); + assert!(select_numeric_array(42, false).is_none(), "{reason}"); + let mut records = Vec::new(); + finish_module(&mut records); + assert!(!records.is_empty(), "{reason}"); + assert!(records.iter().all(|r| r + .notes + .contains(&format!("typed_feedback_replay_rejected={reason}")))); + } +} + +#[test] +fn site_matching_requires_identity_and_supported_lowering() { + for reason in [ + "site_identity_mismatch", + "unknown_site", + "unsupported_site", + "already_specialized", + ] { + let mut profile = profile(); + if reason == "site_identity_mismatch" { + profile.modules[0].sites[0].function = "other".into(); + } + let _scope = enter(&profile); + if reason != "unknown_site" { + register(); + } + if matches!(reason, "site_identity_mismatch" | "already_specialized") { + assert!(select_numeric_array(42, reason == "already_specialized").is_none()); + } + let mut records = Vec::new(); + finish_module(&mut records); + assert_eq!(records[0].rejected_facts[0].detail, reason); + } +} + +#[test] +fn unknown_module_and_empty_stale_profile_are_explained() { + let session = Session::new("compiler".into(), Some(profile())); + assert_eq!(session.finish(None).unwrap()[0].reason, "unknown_module"); + let mut profile = profile(); + profile.modules.clear(); + profile.schema_version += 1; + let session = Session::new("compiler".into(), Some(profile)); + assert_eq!(session.finish(None).unwrap()[0].reason, "schema_mismatch"); +} + +#[test] +fn fresh_fact_is_consumed_once_and_scope_restores_on_unwind() { + let _scope = enter(&profile()); + register(); + let fact = select_numeric_array(42, false).unwrap(); + assert_eq!(fact.state, "consumed"); + assert!(fact.detail.contains("advisory=true")); + assert!(select_numeric_array(42, false).is_none()); + let result = std::panic::catch_unwind(|| { + let _inner = enter(&profile()); + panic!("scope sabotage"); + }); + assert!(result.is_err()); + assert!( + select_numeric_array(42, false).is_none(), + "outer consumed state must be restored" + ); +} + +fn valid_records() -> Vec { + use crate::native_value::{BoundsState, BufferAccessMode, MaterializationReason}; + let _scope = enter(&profile()); + register(); + let fact = select_numeric_array(42, false).unwrap(); + let mut fast = rejection_record(&rejected("main.ts", &site(), "unused")); + fast.expr_kind = "NumericArrayIndexGet".into(); + fast.consumer = "js_array_numeric_get_f64_unboxed".into(); + fast.native_rep = crate::native_value::NativeRep::F64; + fast.notes = vec!["typed_feedback_replay_selected=fresh_numeric_array_observation".into()]; + fast.bounds_state = Some(BoundsState::Guarded { + guard_id: NUMERIC_GUARD.into(), + }); + fast.access_mode = Some(BufferAccessMode::CheckedNative); + fast.consumed_facts = vec![ + fact.clone(), + NativeFactUse { + fact_id: "layout".into(), + kind: "raw_f64_layout".into(), + local_id: None, + state: "consumed".into(), + detail: NUMERIC_GUARD.into(), + reason: None, + }, + ]; + let mut fallback = fast.clone(); + fallback.block_label = "fallback".into(); + fallback.consumed_facts.clear(); + fallback.notes = vec![format!("typed_feedback_replay_fallback={}", fact.fact_id)]; + fallback.consumer = ARRAY_FALLBACK.into(); + fallback.access_mode = Some(BufferAccessMode::DynamicFallback); + fallback.materialization_reason = Some(MaterializationReason::RuntimeApi); + vec![fast, fallback] +} + +#[test] +fn verifier_rejects_replay_claims_without_each_required_proof() { + let mut errors = Vec::new(); + verify_records(&valid_records(), &mut errors); + assert!(errors.is_empty(), "{errors:?}"); + let sabotages: &[fn(&mut Vec)] = &[ + |r| r[0].consumed_facts.remove(0).state.clear(), + |r| r[0].consumed_facts[0].state = "rejected".into(), + |r| r[0].consumed_facts[0].detail = "stale".into(), + |r| r[0].notes.clear(), + |r| r[0].bounds_state = None, + |r| r[0].consumed_facts[1].detail = "wrong_guard".into(), + |r| { + r.pop(); + }, + |r| r[1].notes.clear(), + |r| r[1].function = "different_function".into(), + |r| r[1].materialization_reason = None, + |r| r[1].consumer = "wrong_fallback".into(), + ]; + for sabotage in sabotages { + let mut records = valid_records(); + sabotage(&mut records); + let mut errors = Vec::new(); + verify_records(&records, &mut errors); + assert!( + !errors.is_empty(), + "verifier accepted sabotaged replay record" + ); + } +} diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index f2a19fb0b9..d4ded4521a 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -1331,3 +1331,139 @@ fn typed_feedback_guards_computed_numeric_array_index_hot_path() { assert!(!ir.contains("call double @js_array_numeric_get_f64_unboxed")); assert!(ir.contains("load double")); } + +#[test] +fn profile_replay_selects_numeric_read_with_guard_fallback_and_deterministic_ir() { + use perry_codegen::typed_feedback_profile::{ModuleIdentity, Profile, Session}; + let _lock = env_lock(); + let _feedback = EnvVarGuard::set("PERRY_TYPED_FEEDBACK", None); + let _trace = EnvVarGuard::set("PERRY_TYPED_FEEDBACK_TRACE", None); + let dir = std::env::temp_dir().join(format!("perry-replay-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = module( + "replay.ts", + vec![param(1, "xs", Type::Array(Box::new(Type::Any)))], + Type::Any, + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Number(0.0)), + }))], + ); + let identity = ModuleIdentity { + module: source.name.clone(), + source_hash: "source".into(), + hir_hash: "hir".into(), + lowering_hash: "opts".into(), + target: "host".into(), + }; + let mut opts = empty_opts(); + opts.verify_native_regions = true; + let catalog = Session::new("compiler".into(), None); + let baseline = catalog + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(); + catalog.finish(Some(&dir.join("sites.json"))).unwrap(); + let mut profile: Profile = Session::read_profile(&dir.join("sites.json")).unwrap(); + let sites = &mut profile.modules[0].sites; + sites.retain(|site| site.kind == "array_element" && site.operation == "array[index]"); + assert!( + !sites.is_empty(), + "fixture must reach a supported array read" + ); + for site in sites { + site.observation_kind = "numeric_array_element".into(); + } + let _reps = EnvVarGuard::set("PERRY_NATIVE_REPS", Some("1")); + let _reps_dir = EnvVarGuard::set("PERRY_NATIVE_REPS_DIR", Some(dir.to_str().unwrap())); + let replay = Session::new("compiler".into(), Some(profile.clone())); + let selected = replay + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(); + let decisions = replay.finish(None).unwrap(); + assert!(decisions.iter().any(|d| d.accepted), "{decisions:?}"); + let ir = String::from_utf8(selected.clone()).unwrap(); + assert!(ir.contains("call i32 @js_typed_feedback_numeric_array_index_get_guard")); + assert!(ir.contains("call double @js_typed_feedback_array_index_get_fallback_boxed")); + assert!(ir.contains("br i1")); + assert_ne!(baseline, selected); + let replay2 = Session::new("compiler".into(), Some(profile.clone())); + assert_eq!( + selected, + replay2 + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap() + ); + assert_eq!(decisions, replay2.finish(None).unwrap()); + // Every well-formed mismatch must leave lowering byte-for-byte identical. + let cases: &[(&str, fn(&mut Profile))] = &[ + ("source_hash_mismatch", |p| { + p.modules[0].identity.source_hash.push('x') + }), + ("hir_hash_mismatch", |p| { + p.modules[0].identity.hir_hash.push('x') + }), + ("lowering_inputs_mismatch", |p| { + p.modules[0].identity.lowering_hash.push('x') + }), + ("target_mismatch", |p| { + p.modules[0].identity.target.push('x') + }), + ("compiler_mismatch", |p| p.compiler.push('x')), + ("schema_mismatch", |p| p.schema_version += 1), + ("unknown_module", |p| p.modules[0].identity.module.push('x')), + ("unknown_site", |p| { + for s in &mut p.modules[0].sites { + s.site_id += 1000; + } + }), + ("site_identity_mismatch", |p| { + for s in &mut p.modules[0].sites { + s.function.push('x'); + } + }), + ("unsupported_observation_kind", |p| { + for s in &mut p.modules[0].sites { + s.observation_kind = "method_address".into(); + } + }), + ]; + for (reason, mutate) in cases { + let mut stale_profile = profile.clone(); + mutate(&mut stale_profile); + let stale = Session::new("compiler".into(), Some(stale_profile)); + assert_eq!( + baseline, + stale + .compile_module(&source, opts.clone(), identity.clone()) + .unwrap(), + "{reason}" + ); + let rejected = stale.finish(None).unwrap(); + assert!(!rejected.is_empty(), "{reason}"); + assert!( + rejected.iter().all(|d| !d.accepted && d.reason == *reason), + "{rejected:?}" + ); + } + let artifacts: Vec = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|entry| { + let path = entry.unwrap().path(); + (path.extension().and_then(|s| s.to_str()) == Some("json") + && path.file_name().unwrap() != "sites.json") + .then(|| serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()) + }) + .collect(); + assert!(artifacts + .iter() + .any(|a| a["records"] + .as_array() + .unwrap() + .iter() + .any(|r| r["consumed_facts"] + .as_array() + .unwrap() + .iter() + .any(|f| f["kind"] == "typed_feedback_replay")))); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 453556b499..5b321eb6e0 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -48,6 +48,7 @@ mod post_link; mod precompile_capture; mod reachability; mod size_report; +mod typed_feedback_profile; mod update_config; mod windows_target; // pub(crate): commands/deps.rs (the `check --check-deps` dependency checker) diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 285918a5f9..cccecce670 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -138,6 +138,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_GC_MOVING_LOOP_POLLS", "PERRY_CANONICAL_I32_LOCALS", "PERRY_CANONICAL_STR_LOCALS", + "PERRY_CONCAT_SITE_CACHE", "PERRY_CODEGEN_UNITS", "PERRY_CODEGEN_UNIT_BYTES", "PERRY_CODEGEN_UNIT_SIZE", @@ -815,6 +816,9 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.print_hir || args.trace.is_some() || args.focus.is_some() { return Err("diagnostic-mode".to_string()); } + if args.typed_feedback_profile.is_some() || args.typed_feedback_sites.is_some() { + return Err("typed-feedback-profile".to_string()); + } if args.explain_lowering { return Err("explain-lowering".to_string()); } diff --git a/crates/perry/src/commands/compile/lowering_report.rs b/crates/perry/src/commands/compile/lowering_report.rs index c74e2aaff8..63f9e8334e 100644 --- a/crates/perry/src/commands/compile/lowering_report.rs +++ b/crates/perry/src/commands/compile/lowering_report.rs @@ -365,6 +365,22 @@ fn aggregate_record( let notes_text = notes.join(";"); let access_mode = string_field(record, "access_mode").unwrap_or_default(); + for (prefix, decision) in [ + ("typed_feedback_replay_selected=", "selected"), + ("typed_feedback_replay_rejected=", "rejected"), + ] { + if let Some(reason) = notes.iter().find_map(|note| note.strip_prefix(prefix)) { + push_typed_path_evidence( + summary, + evidence, + module, + record, + decision, + format!("typed_feedback_replay:{reason}"), + ); + } + } + let is_dynamic_fallback = access_mode == "dynamic_fallback" || string_field(record, "fallback_reason").is_some(); if is_dynamic_fallback { diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 36d7643a7b..b7fefc887c 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -260,6 +260,28 @@ pub fn compute_object_cache_key( }) } +/// Replay freshness uses the normal complete lowering key, excluding only +/// instrumentation/reporting switches that capture and replay intentionally vary. +pub(super) fn typed_feedback_lowering_key( + opts: &perry_codegen::CompileOptions, + hir_hash: u64, + version: &str, +) -> u64 { + let mut opts = opts.clone(); + opts.emit_ir_only = false; + opts.verify_native_regions = false; + compute_object_cache_key_with_env(&opts, hir_hash, version, |name| { + if matches!( + name, + "PERRY_TYPED_FEEDBACK" | "PERRY_TYPED_FEEDBACK_TRACE" | "PERRY_VERIFY_NATIVE_REGIONS" + ) { + None + } else { + std::env::var(name).ok() + } + }) +} + fn compute_object_cache_key_with_env( opts: &perry_codegen::CompileOptions, hir_hash: u64, @@ -1092,6 +1114,12 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // Also consumed by the replay freshness fingerprint. A changed concat + // lane must not reuse a catalog produced with different lowering inputs. + h.field( + "env_concat_site_cache", + env_var("PERRY_CONCAT_SITE_CACHE").as_deref().unwrap_or(""), + ); h.field( "env_full_outline_ic", env_var("PERRY_FULL_OUTLINE_IC").as_deref().unwrap_or(""), diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index ac035d7fe2..80a284b298 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -741,6 +741,7 @@ fn key_changes_with_codegen_env_vars() { // Codegen tuning/emission toggles (#6394). "PERRY_TYPED_FEEDBACK", "PERRY_TYPED_FEEDBACK_TRACE", + "PERRY_CONCAT_SITE_CACHE", "PERRY_FULL_OUTLINE_IC", "PERRY_FULL_OUTLINE_IC_MIN_FUNCS", "PERRY_OUTLINE_METHOD_DISPATCH", diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index cd5c539975..8dee26ecab 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -546,6 +546,8 @@ pub fn run_with_parse_cache( use_color: bool, verbose: u8, ) -> Result { + let typed_feedback = super::typed_feedback_profile::prepare(&args)?; + // #4826: fold `--libc musl` into the effective target up-front (before any // downstream code reads `args.target`) so the rest of the pipeline only // ever sees the concrete `linux-musl` triple family. @@ -2521,8 +2523,11 @@ pub fn run_with_parse_cache( .ok() .as_deref() == Some("1"); - let cache_enabled = - !args.no_cache && !cache_env_disabled && !bitcode_link && !verify_native_regions; + let cache_enabled = !args.no_cache + && !cache_env_disabled + && !bitcode_link + && !verify_native_regions + && typed_feedback.is_none(); // Target dir name for the cache layout. Using the resolved LLVM triple // keeps cross-compile caches from colliding with native-host caches. let cache_target_dir = target.as_deref().unwrap_or("host"); @@ -5593,7 +5598,12 @@ pub fn run_with_parse_cache( // everything recorded on this worker thread between these two // calls belongs to this module and nothing else. perry_codegen::ext_registry::begin_module_capture(); - let object_code = perry_codegen::compile_module(hir_module, opts).map_err(|e| { + let compiled = if let Some(session) = &typed_feedback { + super::typed_feedback_profile::compile(session, hir_module, opts, path, perry_version) + } else { + perry_codegen::compile_module(hir_module, opts) + }; + let object_code = compiled.map_err(|e| { perry_codegen::ext_registry::take_module_capture(); format!( "Error compiling module '{}' ({}) with --backend llvm: {:#}", @@ -5940,6 +5950,25 @@ pub fn run_with_parse_cache( } } + if let Some(session) = &typed_feedback { + for decision in session.finish(args.typed_feedback_sites.as_deref())? { + eprintln!( + "[typed-feedback-replay] {} {} site {}: {}", + if decision.accepted { + "accepted" + } else { + "rejected" + }, + decision.module, + decision + .site_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "profile".into()), + decision.reason + ); + } + } + if let Some(explain_lowering) = explain_lowering.as_ref() { explain_lowering.emit(format)?; } diff --git a/crates/perry/src/commands/compile/typed_feedback_profile.rs b/crates/perry/src/commands/compile/typed_feedback_profile.rs new file mode 100644 index 0000000000..7436fccf67 --- /dev/null +++ b/crates/perry/src/commands/compile/typed_feedback_profile.rs @@ -0,0 +1,66 @@ +//! CLI freshness inputs for advisory typed-feedback replay. +use super::CompileArgs; +use anyhow::{Context, Result}; +use perry_codegen::typed_feedback_profile::{ModuleIdentity, Session}; +use sha2::{Digest, Sha256}; +use std::path::Path; + +pub(super) fn prepare(args: &CompileArgs) -> Result> { + if args.typed_feedback_profile.is_none() && args.typed_feedback_sites.is_none() { + return Ok(None); + } + if matches!( + args.target.as_deref(), + Some( + "web" + | "wasm" + | "ios-widget" + | "ios-widget-simulator" + | "watchos-widget" + | "watchos-widget-simulator" + | "android-widget" + | "wearos-tile" + ) + ) { + anyhow::bail!("typed-feedback capture/replay requires a native LLVM target"); + } + let profile = args + .typed_feedback_profile + .as_deref() + .map(Session::read_profile) + .transpose()?; + // No version-only fallback: unreadable compiler identity is an actionable + // error for explicit replay/capture, never permission to trust stale facts. + let executable = + std::env::current_exe().context("cannot identify compiler for typed-feedback replay")?; + let compiler = format!( + "sha256:{}", + hex::encode(Sha256::digest( + std::fs::read(&executable).context("cannot hash compiler for typed-feedback replay")? + )) + ); + Ok(Some(Session::new(compiler, profile))) +} + +pub(super) fn compile( + session: &Session, + hir: &perry_hir::Module, + opts: perry_codegen::CompileOptions, + path: &Path, + version: &str, +) -> Result> { + let source = std::fs::read(path) + .with_context(|| format!("cannot hash typed-feedback source {}", path.display()))?; + let hir_hash = perry_hir::stable_hash::hash_module(hir); + let identity = ModuleIdentity { + module: hir.name.clone(), + source_hash: format!("sha256:{}", hex::encode(Sha256::digest(&source))), + hir_hash: format!("{hir_hash:016x}"), + lowering_hash: format!( + "{:016x}", + super::object_cache::typed_feedback_lowering_key(&opts, hir_hash, version) + ), + target: perry_codegen::typed_feedback_profile::effective_target(&opts), + }; + session.compile_module(hir, opts, identity) +} diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index c19f502c1e..daaffb002a 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -336,6 +336,15 @@ pub struct CompileArgs { #[arg(long)] pub explain_lowering: bool, + /// Replay advisory typed-feedback observations with exact freshness checks. + #[arg(long)] + pub typed_feedback_profile: Option, + + /// Write a versioned site catalog to join with a runtime typed-feedback + /// trace. Compile with PERRY_TYPED_FEEDBACK=1 to record runtime sites. + #[arg(long)] + pub typed_feedback_sites: Option, + /// #504 — emit `.attest.json` next to the compiled /// executable. The sidecar carries SHA-256 of the binary + /// provenance (perry version, git commit, build timestamp) so diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 22a9ebe73e..5b28e33f3a 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -317,6 +317,8 @@ fn build_once( verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + typed_feedback_profile: None, + typed_feedback_sites: None, opt_report: None, statepoint_report: None, emit_attest: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index b6f2512c95..c7fc514104 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -229,6 +229,8 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + typed_feedback_profile: None, + typed_feedback_sites: None, opt_report: None, statepoint_report: None, emit_attest: false, diff --git a/crates/perry/tests/typed_feedback_profile.rs b/crates/perry/tests/typed_feedback_profile.rs new file mode 100644 index 0000000000..a579fc9369 --- /dev/null +++ b/crates/perry/tests/typed_feedback_profile.rs @@ -0,0 +1,249 @@ +//! #8504: exercise real capture/replay, stale-input isolation and JS parity. +#![cfg(unix)] +use serde_json::Value; +use std::path::Path; +use std::process::{Command, Output}; + +const SOURCE: &str = include_str!("../../../test-files/test_typed_feedback_profile_replay.ts"); + +fn success(output: Output) -> Output { + assert!( + output.status.success(), + "status={}\nstdout={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} +fn compile(dir: &Path, name: &str, args: &[&str], instrument: bool) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_perry")); + cmd.current_dir(dir) + .args(["compile", "main.ts", "-o", name, "--no-cache"]) + .args(args) + .env_remove("PERRY_TYPED_FEEDBACK") + .env_remove("PERRY_TYPED_FEEDBACK_TRACE"); + if instrument { + cmd.env("PERRY_TYPED_FEEDBACK", "1"); + } + cmd.output().unwrap() +} +fn run(dir: &Path, name: &str, disagree: bool, trace: Option<&Path>) -> Output { + let mut cmd = Command::new(dir.join(name)); + cmd.current_dir(dir) + .env_remove("PERRY_TYPED_FEEDBACK") + .env_remove("PERRY_TYPED_FEEDBACK_TRACE"); + if disagree { + cmd.arg("disagree"); + } + if let Some(trace) = trace { + cmd.env("PERRY_TYPED_FEEDBACK_TRACE", trace); + } + success(cmd.output().unwrap()) +} +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +#[test] +fn capture_replay_guard_failure_and_semantic_parity() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("main.ts"), SOURCE).unwrap(); + success(compile( + dir, + "capture", + &["--typed-feedback-sites", "sites.json"], + true, + )); + let trace_path = dir.join("capture-trace.json"); + let captured = run(dir, "capture", false, Some(&trace_path)); + let script = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/typed-feedback-profile.py"); + success( + Command::new("python3") + .arg(&script) + .current_dir(dir) + .args([ + "--sites", + "sites.json", + "--trace", + "capture-trace.json", + "-o", + "profile.json", + ]) + .output() + .unwrap(), + ); + // Conversion is deterministic too, independent of output path. + success( + Command::new("python3") + .arg(script) + .current_dir(dir) + .args([ + "--sites", + "sites.json", + "--trace", + "capture-trace.json", + "-o", + "profile2.json", + ]) + .output() + .unwrap(), + ); + assert_eq!( + std::fs::read(dir.join("profile.json")).unwrap(), + std::fs::read(dir.join("profile2.json")).unwrap() + ); + let replay_compile = success(compile( + dir, + "replay", + &[ + "--typed-feedback-profile", + "profile.json", + "--explain-lowering", + ], + true, + )); + let stderr = String::from_utf8_lossy(&replay_compile.stderr); + assert!( + stderr.contains("[typed-feedback-replay] accepted"), + "{stderr}" + ); + assert!(stderr.contains("fresh_numeric_array_observation")); + assert_eq!(captured.stdout, run(dir, "replay", false, None).stdout); + let disagree_trace = dir.join("disagree-trace.json"); + let replay = run(dir, "replay", true, Some(&disagree_trace)); + let trace = read_json(&disagree_trace); + let guarded: Vec<_> = trace["sites"] + .as_array() + .unwrap() + .iter() + .filter(|s| s["guard_name"] == "numeric_array_index_get_guard") + .collect(); + assert!( + guarded + .iter() + .any(|s| s["guard_failures"].as_u64().unwrap_or(0) > 0 + && s["fallback_calls"].as_u64().unwrap_or(0) > 0), + "{trace}" + ); + success(compile(dir, "baseline", &[], false)); + assert_eq!(run(dir, "baseline", true, None).stdout, replay.stdout); + success(compile( + dir, + "replay-normal", + &[ + "--typed-feedback-profile", + "profile.json", + "--verify-native-regions", + ], + false, + )); + assert_eq!(run(dir, "replay-normal", true, None).stdout, replay.stdout); + // Node sees the exact same JS after stripping these three TS annotations. + let js = SOURCE + .replace(": any[]", "") + .replace(": any", "") + .replace(": number", ""); + std::fs::write(dir.join("main.js"), js).unwrap(); + let node = success( + Command::new("node") + .current_dir(dir) + .args(["main.js", "disagree"]) + .output() + .unwrap(), + ); + assert_eq!(node.stdout, replay.stdout); + let mut stale_profile = read_json(&dir.join("profile.json")); + for module in stale_profile["modules"].as_array_mut().unwrap() { + module["identity"]["source_hash"] = Value::String("stale".into()); + } + std::fs::write( + dir.join("stale.json"), + serde_json::to_vec(&stale_profile).unwrap(), + ) + .unwrap(); + let stale_compile = success(compile( + dir, + "stale.o", + &[ + "--typed-feedback-profile", + "stale.json", + "--explain-lowering", + "--no-link", + ], + false, + )); + let stale_stderr = String::from_utf8_lossy(&stale_compile.stderr); + assert!( + stale_stderr.contains("source_hash_mismatch"), + "{stale_stderr}" + ); + assert!(!stale_stderr.contains("[typed-feedback-replay] accepted")); + let reports: Vec<_> = std::fs::read_dir(dir.join(".perry-trace/lowering")) + .unwrap() + .map(|e| read_json(&e.unwrap().path().join("explain-lowering.json"))) + .collect(); + assert!(reports + .iter() + .any(|r| r["summary"]["typed_path_selection_reason_counts"] + ["typed_feedback_replay:fresh_numeric_array_observation"] + .as_u64() + .unwrap_or(0) + > 0)); + assert!(reports + .iter() + .any(|r| r["summary"]["typed_path_rejection_reason_counts"] + ["typed_feedback_replay:source_hash_mismatch"] + .as_u64() + .unwrap_or(0) + > 0)); +} + +#[test] +fn explicit_malformed_profile_has_actionable_diagnostic() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("main.ts"), "console.log(1)").unwrap(); + for invalid in ["{", "{}", "{\"schema_version\":\"one\"}"] { + std::fs::write(temp.path().join("bad.json"), invalid).unwrap(); + let result = compile( + temp.path(), + "unused", + &["--typed-feedback-profile", "bad.json"], + false, + ); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("invalid --typed-feedback-profile"), + "{stderr}" + ); + assert!( + stderr.contains("scripts/typed-feedback-profile.py"), + "{stderr}" + ); + } + std::fs::write( + temp.path().join("future.json"), + r#"{"schema_version": 2, "future_schema_body": []}"#, + ) + .unwrap(); + let future = success(compile( + temp.path(), + "future.o", + &["--typed-feedback-profile", "future.json", "--no-link"], + false, + )); + assert!(String::from_utf8_lossy(&future.stderr).contains("schema_mismatch")); + let result = compile( + temp.path(), + "unused", + &["--typed-feedback-profile", "missing.json"], + false, + ); + assert!(!result.status.success()); + assert!( + String::from_utf8_lossy(&result.stderr).contains("cannot read --typed-feedback-profile") + ); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index e79243618d..fd022b0bf4 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -458,3 +458,87 @@ perry app.ts -o app --target web --minify - [Commands](commands.md) — All CLI commands - [Platform Overview](../platforms/overview.md) — Platform targets + +## Typed-feedback profile replay + +`--typed-feedback-profile ` supplies an **advisory** profile to native LLVM +lowering. Default builds do not read a profile. The first supported observation, +`numeric_array_element`, can select the existing guarded numeric-array read at +an otherwise generic checked `array[index]` site. Already specialized reads and +other site/observation kinds are ignored and explained. + +Capture a workload, then replay it with the **same compiler executable, source, +target and lowering options**: + +```bash +PERRY_TYPED_FEEDBACK=1 perry compile app.ts -o app-capture \ + --typed-feedback-sites typed-feedback-sites.json +PERRY_TYPED_FEEDBACK_TRACE=typed-feedback-trace.json ./app-capture +python3 scripts/typed-feedback-profile.py \ + --sites typed-feedback-sites.json --trace typed-feedback-trace.json \ + -o typed-feedback-profile.json +perry compile app.ts -o app \ + --typed-feedback-profile typed-feedback-profile.json --explain-lowering +``` + +The conversion utility is in the Perry source checkout. Pair the catalog with +the trace from that exact capture build. It retains only observed numeric array +reads and excludes runtime addresses, shape IDs and method identities. A trace +without supported observations produces a diagnostic instead of an empty profile. +The capture build must enable `PERRY_TYPED_FEEDBACK` at compile time; enabling it +only when running a normal binary cannot restore omitted instrumentation. + +The JSON replay schema is version 1: + +```json +{ + "schema_version": 1, + "compiler": "sha256:", + "modules": [{ + "identity": { + "module": "app.ts", + "source_hash": "sha256:", + "hir_hash": "", + "lowering_hash": "", + "target": "x86_64-unknown-linux-gnu" + }, + "sites": [{ + "site_id": 123, + "function": "perry_fn_app_ts__read", + "kind": "array_element", + "operation": "array[index]", + "observation_kind": "numeric_array_element" + }] + }] +} +``` + +Use generated identities, rather than copying this illustrative site ID. Site +IDs identify deterministic lowering sites within an exact module/compiler/input +combination; function, kind and operation must also match. The lowering hash +includes target CPU/features, codegen settings and imported capabilities using +the object cache's complete input fingerprint. Capture instrumentation and +native-region reporting/verification are excluded so they can vary during replay. +Even a comment-only source change invalidates the source hash, and rebuilding +Perry invalidates the compiler hash without needing a version bump. No +cross-version or best-effort stale replay is attempted. + +Malformed or unreadable explicit input is a compilation error naming the profile +and how to create it. Well-formed schema/compiler/target/source/HIR/options +mismatches, unknown modules/sites, duplicate identities and unsupported +observations are ignored for specialization. Each rejected fact is reported on +stderr with its reason. Accepted and rejected facts also appear in native-rep +artifacts (`PERRY_NATIVE_REPS=1`) and `--explain-lowering`'s typed-path evidence and +reason counts. Replay and catalog builds bypass build/object cache reuse to +produce evidence from this compilation. Artifact filenames and report paths have +run-specific nonces; decisions and lowering are deterministic for identical inputs. + +Profiles and TypeScript annotations never authorize an unchecked operation. +Every replay-selected read rechecks the live receiver, array representation, +descriptors/prototype state and bounds with the existing numeric-array runtime +guard. Strings, holes, changed layouts, non-array receivers and other guard +failures use the original boxed JavaScript fallback, with no added number +coercion. Replay does not relax ownership, alias, lifetime or method-identity +checks. Native-region verification requires a consumed fresh replay fact, +a matching runtime guard and an explicit fallback/materialization record for +every claimed profile selection. diff --git a/scripts/typed-feedback-profile.py b/scripts/typed-feedback-profile.py new file mode 100644 index 0000000000..17c37fd8e7 --- /dev/null +++ b/scripts/typed-feedback-profile.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Join a same-build site catalog and runtime trace into an advisory replay profile.""" +import argparse +import copy +import json +from pathlib import Path + + +def make_profile(catalog, trace): + if catalog.get("schema_version") != 1: + raise ValueError("unsupported site catalog schema_version (expected 1)") + rows = {} + for row in trace["sites"]: + key = (row["site_id"], row["function"], row["kind"], row["operation"]) + if key in rows: + raise ValueError(f"duplicate runtime trace site: {key}") + rows[key] = row + profile = copy.deepcopy(catalog) + selected = 0 + for module in profile["modules"]: + sites = [] + for site in module["sites"]: + key = (site["site_id"], site["function"], site["kind"], site["operation"]) + row = rows.get(key) + if row is None or not row.get("observed_count", 0): + continue + observations = row.get("observed_kinds", []) + # Consume only stable, pointer-free numeric observations. Runtime + # addresses, shape IDs, and method/closure identities never replay. + if (site["kind"] == "array_element" and site["operation"] == "array[index]" + and observations and all( + obs.get("source") == "array" + and obs.get("heap_type") == "array" + and obs.get("array_access") == "indexed_in_bounds" + and obs.get("array_element_kind") in ("number", "int32") + for obs in observations)): + site["observation_kind"] = "numeric_array_element" + sites.append(site) + selected += 1 + module["sites"] = sorted(sites, key=lambda site: site["site_id"]) + profile["modules"].sort(key=lambda module: module["identity"]["module"]) + if not selected: + raise ValueError("trace contains no supported numeric array-read observations; compile with PERRY_TYPED_FEEDBACK=1 and exercise an array[index] read") + return profile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sites", required=True, type=Path, help="--typed-feedback-sites catalog from the instrumented build") + parser.add_argument("--trace", required=True, type=Path, help="typed-feedback-trace.json from that same build") + parser.add_argument("-o", "--output", required=True, type=Path) + args = parser.parse_args() + try: + profile = make_profile(json.loads(args.sites.read_text()), json.loads(args.trace.read_text())) + args.output.write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n") + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(2, f"typed-feedback-profile: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/test-files/test_typed_feedback_profile_replay.ts b/test-files/test_typed_feedback_profile_replay.ts new file mode 100644 index 0000000000..d704a88680 --- /dev/null +++ b/test-files/test_typed_feedback_profile_replay.ts @@ -0,0 +1,12 @@ +function read(xs: any[], i: number): any { return xs[i | 0]; } +const getter: any[] = [0]; +Object.defineProperty(getter, "0", { get() { return "getter"; } }); +const grown: any[] = [9]; +const alias = grown; +for (let i = 0; i < 80; i++) grown.push(i); +const samples: any[] = [[11, 22], ["changed"], [true], [{x: 1}], [], new Array(1), {0: "object"}, new Uint8Array([7]), getter, alias]; +const disagree = process.argv.indexOf("disagree") >= 0; +for (let i = 0; i < samples.length; i++) { + const xs: any = disagree ? samples[i] : samples[0]; + console.log(JSON.stringify(read(xs, 0))); +}