diff --git a/crates/jsshaker/src/analyzer/mod.rs b/crates/jsshaker/src/analyzer/mod.rs index ddb77f22..3fb4cff3 100644 --- a/crates/jsshaker/src/analyzer/mod.rs +++ b/crates/jsshaker/src/analyzer/mod.rs @@ -6,6 +6,7 @@ mod post; mod pre; pub mod rw_tracking; +use std::cell::RefCell; use std::collections::BTreeSet; use conditional::ConditionalDataMap; @@ -28,6 +29,7 @@ use crate::{ module::{ModuleId, Modules}, scope::Scoping, utils::ExtraData, + value::FnCacheStats, vfs::Vfs, }; @@ -55,6 +57,7 @@ pub struct Analyzer<'a> { pub mangler: Mangler<'a>, pub pending_deps: FxHashSet>, pub diagnostics: BTreeSet, + pub fn_cache_stats: Option>, } impl<'a> Analyzer<'a> { @@ -86,6 +89,7 @@ impl<'a> Analyzer<'a> { mangler, pending_deps: Default::default(), diagnostics: Default::default(), + fn_cache_stats: config.enable_fn_cache_stats.then(|| RefCell::new(FnCacheStats::new())), } } diff --git a/crates/jsshaker/src/config.rs b/crates/jsshaker/src/config.rs index 6ebb0192..a2c08e85 100644 --- a/crates/jsshaker/src/config.rs +++ b/crates/jsshaker/src/config.rs @@ -20,6 +20,7 @@ pub struct TreeShakeConfig { pub max_recursion_depth: usize, pub remember_exhausted_variables: bool, pub enable_fn_cache: bool, + pub enable_fn_cache_stats: bool, pub mangling: Option, pub unknown_global_side_effects: bool, @@ -51,6 +52,7 @@ impl TreeShakeConfig { max_recursion_depth: 2, remember_exhausted_variables: true, enable_fn_cache: true, + enable_fn_cache_stats: false, mangling: Some(false), unknown_global_side_effects: true, @@ -131,4 +133,9 @@ impl TreeShakeConfig { self.enable_fn_cache = yes; self } + + pub fn with_fn_cache_stats(mut self, yes: bool) -> Self { + self.enable_fn_cache_stats = yes; + self + } } diff --git a/crates/jsshaker/src/lib.rs b/crates/jsshaker/src/lib.rs index 33e0ed5e..56888253 100644 --- a/crates/jsshaker/src/lib.rs +++ b/crates/jsshaker/src/lib.rs @@ -60,6 +60,12 @@ pub fn tree_shake(options: JsShakerOptions, entry: String) let module_id = analyzer.parse_module(normalize_path::normalize_str(&entry)); analyzer.exec_module(module_id); analyzer.post_analysis(); + + // Print cache stats if enabled + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow().print_summary(); + } + let Analyzer { modules, diagnostics, diff --git a/crates/jsshaker/src/main.rs b/crates/jsshaker/src/main.rs index 323ed0a4..f3ad2346 100644 --- a/crates/jsshaker/src/main.rs +++ b/crates/jsshaker/src/main.rs @@ -49,6 +49,9 @@ struct Args { #[arg(long, default_value_t = false)] no_fn_cache: bool, + + #[arg(long, default_value_t = false)] + fn_cache_stats: bool, } fn main() { @@ -77,7 +80,8 @@ fn main() { }) .with_max_recursion_depth(args.recursion_depth) .with_remember_exhausted(!args.no_remember_exhausted) - .with_fn_cache(!args.no_fn_cache); + .with_fn_cache(!args.no_fn_cache) + .with_fn_cache_stats(args.fn_cache_stats); let minify_options = MinifierOptions { mangle: Some(MangleOptions { top_level: true, ..Default::default() }), diff --git a/crates/jsshaker/src/value/cacheable.rs b/crates/jsshaker/src/value/cacheable.rs index 8fa9f5bc..1af312db 100644 --- a/crates/jsshaker/src/value/cacheable.rs +++ b/crates/jsshaker/src/value/cacheable.rs @@ -53,7 +53,9 @@ impl<'a> Cacheable<'a> { pub fn is_copyable(&self) -> bool { match self { - Self::Array(_) | Self::Object(_) => false, + // Enable identity-based caching for objects/arrays + // Object and Array IDs are copyable and provide identity-based equality + Self::Array(_) | Self::Object(_) => true, Self::Union(u) => u.iter().all(|c| c.is_copyable()), _ => true, } diff --git a/crates/jsshaker/src/value/function/cache.rs b/crates/jsshaker/src/value/function/cache.rs index 8e2dbde9..5fa8f10e 100644 --- a/crates/jsshaker/src/value/function/cache.rs +++ b/crates/jsshaker/src/value/function/cache.rs @@ -16,6 +16,7 @@ pub struct FnCachedInput<'a> { pub is_ctor: bool, pub this: &'a Cacheable<'a>, pub args: &'a [Cacheable<'a>], + pub rest: Option<&'a Cacheable<'a>>, } #[derive(Debug)] @@ -166,18 +167,53 @@ impl<'a> FnCache<'a> { args: ArgumentsValue<'a>, ) -> Option> { if !analyzer.config.enable_fn_cache { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_config_disabled += 1; + } return None; } - let this = analyzer.factory.alloc(this.as_cacheable(analyzer)?); - if args.rest.is_some() { - return None; // TODO: Support this case - } + let Some(this_cacheable) = this.as_cacheable(analyzer) else { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_non_copyable_this += 1; + } + return None; + }; + let this = analyzer.factory.alloc(this_cacheable); + + let rest = match args.rest { + Some(rest_arg) => match rest_arg.as_cacheable(analyzer) { + Some(cacheable) => { + let rest_ref = &*analyzer.factory.alloc(cacheable); + Some(rest_ref) + } + None => { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_rest_params += 1; + } + return None; + } + }, + None => None, + }; + let mut cargs = analyzer.factory.vec(); for arg in args.elements { - cargs.push(arg.as_cacheable(analyzer)?); + if let Some(cacheable) = arg.as_cacheable(analyzer) { + cargs.push(cacheable); + } else { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_non_copyable_args += 1; + } + return None; + } } - Some(FnCachedInput { is_ctor: IS_CTOR, this, args: cargs.into_bump_slice() }) + + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().cache_attempts += 1; + } + + Some(FnCachedInput { is_ctor: IS_CTOR, this, args: cargs.into_bump_slice(), rest }) } pub fn retrieve( @@ -200,15 +236,28 @@ impl<'a> FnCache<'a> { if c1.is_compatible(&c2) { analyzer.add_assoc_entity_dep(tracking_dep, e2); } else { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_read_dep_incompatible += 1; + } return None; } } } (None, None) => {} - _ => return None, + _ => { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_read_dep_incompatible += 1; + } + return None; + } } } + // Cache hit successful! + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().cache_hits += 1; + } + for (&target, &(non_det, cacheable)) in &cached.effects.writes { analyzer.set_rw_target_current_value( target, @@ -237,6 +286,11 @@ impl<'a> FnCache<'a> { Some(ret) } else { + if let Some(stats) = &analyzer.fn_cache_stats { + let mut stats = stats.borrow_mut(); + stats.cache_misses += 1; + stats.miss_cache_empty += 1; + } None } } @@ -251,15 +305,24 @@ impl<'a> FnCache<'a> { has_global_effects: bool, ) { let FnCacheTrackingData::Tracked { effects } = tracking_data else { - return; - }; - if !ret.as_cacheable(analyzer).is_some_and(|c| c.is_copyable()) { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().miss_state_untrackable += 1; + } return; }; + // Removed copyability check - allow caching functions that return objects/arrays + // The return entity is properly wrapped with dependencies + self .table .borrow_mut() .insert(key, FnCachedInfo { track_deps, effects, has_global_effects, ret }); + + if let Some(stats) = &analyzer.fn_cache_stats { + let mut stats = stats.borrow_mut(); + stats.cache_updates += 1; + stats.cache_table_size = self.table.borrow().len(); + } } } diff --git a/crates/jsshaker/src/value/function/cache_stats.rs b/crates/jsshaker/src/value/function/cache_stats.rs new file mode 100644 index 00000000..3e1eb0d2 --- /dev/null +++ b/crates/jsshaker/src/value/function/cache_stats.rs @@ -0,0 +1,75 @@ +#[derive(Debug, Default)] +pub struct FnCacheStats { + // Overall metrics + pub total_calls: usize, + pub cache_attempts: usize, + pub cache_hits: usize, + pub cache_misses: usize, + pub cache_updates: usize, + + // Miss reason breakdown + pub miss_config_disabled: usize, + pub miss_non_copyable_this: usize, + pub miss_non_copyable_args: usize, + pub miss_rest_params: usize, + pub miss_non_copyable_return: usize, + pub miss_state_untrackable: usize, + pub miss_read_dep_incompatible: usize, + pub miss_cache_empty: usize, + + // Per-function statistics (optional: implement later if needed) + pub cache_table_size: usize, +} + +impl FnCacheStats { + pub fn new() -> Self { + Self::default() + } + + pub fn print_summary(&self) { + println!("\n=== Function Cache Statistics ==="); + println!("Total Function Calls: {}", self.total_calls); + println!("Cache Key Generated: {}", self.cache_attempts); + println!("Cache Hits: {} ({:.1}%)", self.cache_hits, self.hit_rate_percent()); + println!( + "Cache Misses: {} ({:.1}%)", + self.cache_misses, + 100.0 - self.hit_rate_percent() + ); + println!("Successful Updates: {}", self.cache_updates); + println!("Cache Table Size: {} entries", self.cache_table_size); + + if self.cache_misses > 0 { + println!("\n--- Miss Reason Breakdown ---"); + self.print_miss_reason("Config Disabled", self.miss_config_disabled); + self.print_miss_reason("Non-copyable This", self.miss_non_copyable_this); + self.print_miss_reason("Non-copyable Args", self.miss_non_copyable_args); + self.print_miss_reason("Rest Parameters", self.miss_rest_params); + self.print_miss_reason("Non-copyable Return", self.miss_non_copyable_return); + self.print_miss_reason("State Untrackable", self.miss_state_untrackable); + self.print_miss_reason("Read Dep Incompatible", self.miss_read_dep_incompatible); + self.print_miss_reason("Cache Empty (First Call)", self.miss_cache_empty); + } + println!("=================================\n"); + } + + fn hit_rate_percent(&self) -> f64 { + if self.cache_attempts == 0 { + 0.0 + } else { + (self.cache_hits as f64 / self.cache_attempts as f64) * 100.0 + } + } + + fn print_miss_reason(&self, reason: &str, count: usize) { + if count > 0 { + let total_calls = self.total_calls.max(1); + println!( + " {:30} {:8} ({:5.1}% of total calls)", + format!("{}:", reason), + count, + (count as f64 / total_calls as f64) * 100.0 + ); + } + } +} diff --git a/crates/jsshaker/src/value/function/call.rs b/crates/jsshaker/src/value/function/call.rs index d65929f1..431d94ac 100644 --- a/crates/jsshaker/src/value/function/call.rs +++ b/crates/jsshaker/src/value/function/call.rs @@ -29,6 +29,10 @@ impl<'a> FunctionValue<'a> { mut args: ArgumentsValue<'a>, consume: bool, ) -> Entity<'a> { + if let Some(stats) = &analyzer.fn_cache_stats { + stats.borrow_mut().total_calls += 1; + } + let call_id = DepAtom::from_counter(); let call_dep = analyzer.dep((self.callee.into_node(), dep, call_id)); diff --git a/crates/jsshaker/src/value/function/mod.rs b/crates/jsshaker/src/value/function/mod.rs index bc729439..38df20a9 100644 --- a/crates/jsshaker/src/value/function/mod.rs +++ b/crates/jsshaker/src/value/function/mod.rs @@ -2,6 +2,7 @@ mod arguments; pub mod bound; mod builtin; pub mod cache; +pub mod cache_stats; pub mod call; use std::cell::Cell; @@ -23,6 +24,7 @@ use crate::{ }; pub use arguments::*; pub use builtin::*; +pub use cache_stats::FnCacheStats; #[derive(Debug)] pub struct FunctionValue<'a> { diff --git a/crates/jsshaker/tests/snapshots/test@recursion.js.snap b/crates/jsshaker/tests/snapshots/test@recursion.js.snap index 7b2f59c2..949f3cb6 100644 --- a/crates/jsshaker/tests/snapshots/test@recursion.js.snap +++ b/crates/jsshaker/tests/snapshots/test@recursion.js.snap @@ -30,7 +30,9 @@ export function complex2() { function resolveTransitionHooks(postClone) { const hooks = { clone() { const hooks2 = resolveTransitionHooks(postClone); - if (postClone) postClone(hooks2); + { + postClone(hooks2); + } return hooks2; } }; return hooks;