From c834484de3c80f88a72189adfbf454bd07ca081b Mon Sep 17 00:00:00 2001 From: Stephen Chung Date: Mon, 27 Nov 2023 14:56:51 +0800 Subject: [PATCH] Optimize function resolution caching. --- CHANGELOG.md | 3 +- src/engine.rs | 29 +++++ src/eval/cache.rs | 50 ++++++--- src/eval/stmt.rs | 12 +- src/func/call.rs | 269 +++++++++++++++++++++++---------------------- src/func/script.rs | 28 +++-- 6 files changed, 229 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ef51f16..b10cf05d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Enhancements ------------ * Avoid cloning values unless needed when performing constants propagation in optimization. +* Avoid using the function resolution cache when there is only one active namespace. * Added `to_int` method for characters. * `Token::FloatConstant` and `Token::DecimalConstant` now carry the original text representation for use in, say, a _token mapper_. * `Dynamic::is_fnptr` is made a public API. @@ -1416,7 +1417,7 @@ New features Enhancements ------------ -* Functions resolution cache is used in more cases, making repeated function calls faster. +* Function resolution cache is used in more cases, making repeated function calls faster. * Added `atan(x, y)` and `hypot(x, y)` to `BasicMathPackage`. * Added standard arithmetic operators between `FLOAT`/[`Decimal`](https://crates.io/crates/rust_decimal) and `INT`. diff --git a/src/engine.rs b/src/engine.rs index 4f43316ae..b5b9a95cc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,6 +1,7 @@ //! Main module defining the script evaluation [`Engine`]. use crate::api::options::LangOptions; +use crate::eval::GlobalRuntimeState; use crate::func::native::{ locked_write, OnDebugCallback, OnDefVarCallback, OnParseTokenCallback, OnPrintCallback, OnVarCallback, @@ -341,6 +342,34 @@ impl Engine { } } + /// Should function resolutions be cached? + #[inline] + #[must_use] + pub(crate) fn should_use_fn_resolution_cache(&self, global: &GlobalRuntimeState) -> bool { + // If more than one layer of scripted functions, use cache + #[cfg(not(feature = "no_function"))] + if global.lib.iter().filter(|&m| m.count().1 > 0).count() > 1 { + return true; + } + + let num_modules = self + .global_modules + .iter() + .filter(|&m| m.count().1 > 0) + .count() + + self + .global_sub_modules + .values() + .filter(|&m| m.contains_indexed_global_functions()) + .count() + + global + .scan_imports_raw() + .filter(|&(_, m)| m.contains_indexed_global_functions()) + .count(); + + num_modules > 1 + } + /// Get an empty [`ImmutableString`] which refers to a shared instance. #[inline(always)] #[must_use] diff --git a/src/eval/cache.rs b/src/eval/cache.rs index 2c4e128e9..5a00181d3 100644 --- a/src/eval/cache.rs +++ b/src/eval/cache.rs @@ -24,17 +24,26 @@ pub struct FnResolutionCacheEntry { #[derive(Debug, Clone, Default)] pub struct FnResolutionCache { /// Hash map containing cached functions. - pub map: StraightHashMap>, + /// + /// If [`None`], caching is disabled. + pub dict: Option>>, /// Bloom filter to avoid caching "one-hit wonders". pub filter: BloomFilterU64, } impl FnResolutionCache { - /// Clear the [`FnResolutionCache`]. + /// Initialize the [`FnResolutionCache`], clearing it. + /// + /// * If `enable` is `true`, caching is re-enabled. + /// * If `enable` is `false`, caching is not disabled if it is already enabled. #[inline(always)] #[allow(dead_code)] - pub fn clear(&mut self) { - self.map.clear(); + pub fn init(&mut self, enable: bool) { + if let Some(ref mut map) = self.dict { + map.clear(); + } else if enable { + self.dict = <_>::default(); + } self.filter.clear(); } } @@ -45,39 +54,44 @@ impl FnResolutionCache { /// The following caches are contained inside this type: /// * A stack of [function resolution caches][FnResolutionCache] #[derive(Debug, Clone)] -pub struct Caches(StaticVec); +pub struct Caches { + /// Stack of function resolution caches. + pub fn_resolution: StaticVec, +} impl Caches { /// Create an empty [`Caches`]. #[inline(always)] #[must_use] pub const fn new() -> Self { - Self(StaticVec::new_const()) + Self { + fn_resolution: StaticVec::new_const(), + } } /// Get the number of function resolution cache(s) in the stack. #[inline(always)] #[must_use] pub fn fn_resolution_caches_len(&self) -> usize { - self.0.len() + self.fn_resolution.len() } - /// Get a mutable reference to the current function resolution cache. - #[inline] + /// Get a mutable reference to the current function resolution cache (if any). + #[inline(always)] #[must_use] - pub fn fn_resolution_cache_mut(&mut self) -> &mut FnResolutionCache { - // Push a new function resolution cache if the stack is empty - if self.0.is_empty() { - self.push_fn_resolution_cache(); - } - self.0.last_mut().unwrap() + pub fn fn_resolution_cache_mut(&mut self) -> Option<&mut FnResolutionCache> { + self.fn_resolution.last_mut() } /// Push an empty function resolution cache onto the stack and make it current. + /// + /// If `use_cache` is `false`, then function resolution caching is disabled. #[inline(always)] - pub fn push_fn_resolution_cache(&mut self) { - self.0.push(<_>::default()); + pub fn push_fn_resolution_cache(&mut self, use_cache: bool) { + let mut cache = FnResolutionCache::default(); + cache.dict = use_cache.then(<_>::default); + self.fn_resolution.push(cache); } /// Rewind the function resolution caches stack to a particular size. #[inline(always)] pub fn rewind_fn_resolution_caches(&mut self, len: usize) { - self.0.truncate(len); + self.fn_resolution.truncate(len); } } diff --git a/src/eval/stmt.rs b/src/eval/stmt.rs index c07b7859b..779a7d023 100644 --- a/src/eval/stmt.rs +++ b/src/eval/stmt.rs @@ -72,7 +72,7 @@ impl Engine { let this_ptr = this_ptr.as_deref_mut(); #[cfg(not(feature = "no_module"))] - let imports_len = global.num_imports(); + let orig_imports_len = global.num_imports(); let result = self.eval_stmt(global, caches, scope, this_ptr, stmt, restore_orig_state)?; @@ -83,7 +83,7 @@ impl Engine { // Without global functions, the extra modules never affect function resolution. if global .scan_imports_raw() - .skip(imports_len) + .skip(orig_imports_len) .any(|(.., m)| m.contains_indexed_global_functions()) { // Different scenarios where the cache must be cleared - notice that this is @@ -91,13 +91,15 @@ impl Engine { if caches.fn_resolution_caches_len() > orig_fn_resolution_caches_len { // When new module is imported with global functions and there is already // a new cache, just clear it - caches.fn_resolution_cache_mut().clear(); + caches.fn_resolution_cache_mut().unwrap().init(true); } else if restore_orig_state { // When new module is imported with global functions, push a new cache - caches.push_fn_resolution_cache(); + caches.push_fn_resolution_cache(true); } else { // When the block is to be evaluated in-place, just clear the current cache - caches.fn_resolution_cache_mut().clear(); + if let Some(cache) = caches.fn_resolution_cache_mut() { + cache.init(true); + } } } } diff --git a/src/func/call.rs b/src/func/call.rs index 09beb3ddc..27ec52705 100644 --- a/src/func/call.rs +++ b/src/func/call.rs @@ -179,148 +179,157 @@ impl Engine { calc_fn_hash_full(hash_base, args.iter().map(|a| a.type_id())) }); - let cache = caches.fn_resolution_cache_mut(); + if caches.fn_resolution_caches_len() == 0 { + caches.push_fn_resolution_cache(self.should_use_fn_resolution_cache(_global)); + } + let cache = caches.fn_resolution_cache_mut().unwrap(); + let entry = cache.dict.as_mut().map(|m| m.entry(hash)); - match cache.map.entry(hash) { - Entry::Occupied(entry) => entry.into_mut().as_ref(), - Entry::Vacant(entry) => { - let num_args = args.as_deref().map_or(0, FnCallArgs::len); - let mut max_bitmask = 0; // One above maximum bitmask based on number of parameters. - // Set later when a specific matching function is not found. - let mut bitmask = 1usize; // Bitmask of which parameter to replace with `Dynamic` + if let Some(Entry::Occupied(entry)) = entry { + // Found in cache + println!("Found function in cache!"); + return entry.into_mut().as_ref(); + } - loop { - #[cfg(not(feature = "no_function"))] - let func = _global + let num_args = args.as_deref().map_or(0, FnCallArgs::len); + let mut max_bitmask = 0; // One above maximum bitmask based on number of parameters. + // Set later when a specific matching function is not found. + let mut bitmask = 1usize; // Bitmask of which parameter to replace with `Dynamic` + + loop { + // First check scripted functions in the AST or embedded environments + #[cfg(not(feature = "no_function"))] + let func = _global + .lib + .iter() + .rev() + .find_map(|m| m.get_fn(hash).map(|f| (f, m.id_raw()))); + #[cfg(feature = "no_function")] + let func = None; + + // Then check the global namespace + let func = func.or_else(|| { + self.global_modules + .iter() + .find_map(|m| m.get_fn(hash).map(|f| (f, m.id_raw()))) + }); + + // Then check imported modules for global functions, then global sub-modules for global functions + #[cfg(not(feature = "no_module"))] + let func = func + .or_else(|| _global.get_qualified_fn(hash, true)) + .or_else(|| { + self.global_sub_modules + .values() + .filter(|&m| m.contains_indexed_global_functions()) + .find_map(|m| m.get_qualified_fn(hash).map(|f| (f, m.id_raw()))) + }); + + if let Some((f, s)) = func { + // Specific version found + let new_entry = FnResolutionCacheEntry { + func: f.clone(), + source: s.cloned(), + }; + return if cache.filter.is_absent_and_set(hash) || entry.is_none() { + // Do not cache "one-hit wonders" + *local_entry = Some(new_entry); + local_entry.as_ref() + } else { + // Cache entry + entry.unwrap().or_insert(Some(new_entry)).as_ref() + }; + } + + // Check `Dynamic` parameters for functions with parameters + if allow_dynamic && max_bitmask == 0 && num_args > 0 { + let is_dynamic = self + .global_modules + .iter() + .any(|m| m.may_contain_dynamic_fn(hash_base)); + + #[cfg(not(feature = "no_function"))] + let is_dynamic = is_dynamic + || _global .lib .iter() - .rev() - .chain(self.global_modules.iter()) - .find_map(|m| m.get_fn(hash).map(|f| (f, m.id_raw()))); - #[cfg(feature = "no_function")] - let func = None; + .any(|m| m.may_contain_dynamic_fn(hash_base)); - let func = func.or_else(|| { - self.global_modules - .iter() - .find_map(|m| m.get_fn(hash).map(|f| (f, m.id_raw()))) - }); - - #[cfg(not(feature = "no_module"))] - let func = func - .or_else(|| _global.get_qualified_fn(hash, true)) - .or_else(|| { - self.global_sub_modules - .values() - .filter(|m| m.contains_indexed_global_functions()) - .find_map(|m| m.get_qualified_fn(hash).map(|f| (f, m.id_raw()))) - }); - - if let Some((f, s)) = func { - // Specific version found - let new_entry = FnResolutionCacheEntry { - func: f.clone(), - source: s.cloned(), - }; - return if cache.filter.is_absent_and_set(hash) { - // Do not cache "one-hit wonders" - *local_entry = Some(new_entry); - local_entry.as_ref() - } else { - // Cache entry - entry.insert(Some(new_entry)).as_ref() - }; - } + #[cfg(not(feature = "no_module"))] + let is_dynamic = is_dynamic + || _global.may_contain_dynamic_fn(hash_base) + || self + .global_sub_modules + .values() + .any(|m| m.may_contain_dynamic_fn(hash_base)); + + // Set maximum bitmask when there are dynamic versions of the function + if is_dynamic { + max_bitmask = 1usize << usize::min(num_args, MAX_DYNAMIC_PARAMETERS); + } + } - // Check `Dynamic` parameters for functions with parameters - if allow_dynamic && max_bitmask == 0 && num_args > 0 { - let is_dynamic = self - .global_modules - .iter() - .any(|m| m.may_contain_dynamic_fn(hash_base)); + // Stop when all permutations are exhausted + if bitmask >= max_bitmask { + if num_args != 2 { + return None; + } - #[cfg(not(feature = "no_function"))] - let is_dynamic = is_dynamic - || _global - .lib - .iter() - .any(|m| m.may_contain_dynamic_fn(hash_base)); - - #[cfg(not(feature = "no_module"))] - let is_dynamic = is_dynamic - || _global.may_contain_dynamic_fn(hash_base) - || self - .global_sub_modules - .values() - .any(|m| m.may_contain_dynamic_fn(hash_base)); - - // Set maximum bitmask when there are dynamic versions of the function - if is_dynamic { - max_bitmask = 1usize << usize::min(num_args, MAX_DYNAMIC_PARAMETERS); + // Try to find a built-in version + let builtin = + args.and_then(|args| match op_token { + None => None, + Some(token) if token.is_op_assignment() => { + let (first_arg, rest_args) = args.split_first().unwrap(); + + get_builtin_op_assignment_fn(token, first_arg, rest_args[0]).map( + |(f, has_context)| FnResolutionCacheEntry { + func: CallableFunction::Method { + func: Shared::new(f), + has_context, + is_pure: false, + }, + source: None, + }, + ) } - } + Some(token) => get_builtin_binary_op_fn(token, args[0], args[1]).map( + |(f, has_context)| FnResolutionCacheEntry { + func: CallableFunction::Method { + func: Shared::new(f), + has_context, + is_pure: true, + }, + source: None, + }, + ), + }); - // Stop when all permutations are exhausted - if bitmask >= max_bitmask { - if num_args != 2 { - return None; - } + return if cache.filter.is_absent_and_set(hash) || entry.is_none() { + // Do not cache "one-hit wonders" + *local_entry = builtin; + local_entry.as_ref() + } else { + // Cache entry + entry.unwrap().or_insert(builtin).as_ref() + }; + } - // Try to find a built-in version - let builtin = - args.and_then(|args| match op_token { - None => None, - Some(token) if token.is_op_assignment() => { - let (first_arg, rest_args) = args.split_first().unwrap(); - - get_builtin_op_assignment_fn(token, first_arg, rest_args[0]) - .map(|(f, has_context)| FnResolutionCacheEntry { - func: CallableFunction::Method { - func: Shared::new(f), - has_context, - is_pure: false, - }, - source: None, - }) - } - Some(token) => get_builtin_binary_op_fn(token, args[0], args[1]) - .map(|(f, has_context)| FnResolutionCacheEntry { - func: CallableFunction::Method { - func: Shared::new(f), - has_context, - is_pure: true, - }, - source: None, - }), - }); - - return if cache.filter.is_absent_and_set(hash) { - // Do not cache "one-hit wonders" - *local_entry = builtin; - local_entry.as_ref() - } else { - // Cache entry - entry.insert(builtin).as_ref() - }; + // Try all permutations with `Dynamic` wildcards + hash = calc_fn_hash_full( + hash_base, + args.as_ref().unwrap().iter().enumerate().map(|(i, a)| { + let mask = 1usize << (num_args - i - 1); + if bitmask & mask == 0 { + a.type_id() + } else { + // Replace with `Dynamic` + TypeId::of::() } + }), + ); - // Try all permutations with `Dynamic` wildcards - hash = calc_fn_hash_full( - hash_base, - args.as_ref().unwrap().iter().enumerate().map(|(i, a)| { - let mask = 1usize << (num_args - i - 1); - if bitmask & mask == 0 { - a.type_id() - } else { - // Replace with `Dynamic` - TypeId::of::() - } - }), - ); - - bitmask += 1; - } - } + bitmask += 1; } } diff --git a/src/func/script.rs b/src/func/script.rs index 443e410b1..a682f53cc 100644 --- a/src/func/script.rs +++ b/src/func/script.rs @@ -209,7 +209,11 @@ impl Engine { _result } - // Does a script-defined function exist? + /// Does a script-defined function exist? + /// + /// # Note + /// + /// If the scripted function is not found, this information is cached for future look-ups. #[must_use] pub(crate) fn has_script_fn( &self, @@ -217,29 +221,37 @@ impl Engine { caches: &mut Caches, hash_script: u64, ) -> bool { - let cache = caches.fn_resolution_cache_mut(); + if caches.fn_resolution_caches_len() == 0 { + caches.push_fn_resolution_cache(self.should_use_fn_resolution_cache(global)); + } + let cache = caches.fn_resolution_cache_mut().unwrap(); - if let Some(result) = cache.map.get(&hash_script).map(Option::is_some) { + if let Some(result) = cache + .dict + .as_ref() + .and_then(|m| m.get(&hash_script)) + .map(Option::is_some) + { return result; } // First check script-defined functions - let r = global.lib.iter().any(|m| m.contains_fn(hash_script)) + let res = global.lib.iter().any(|m| m.contains_fn(hash_script)) // Then check the global namespace and packages || self.global_modules.iter().any(|m| m.contains_fn(hash_script)); #[cfg(not(feature = "no_module"))] - let r = r || + let res = res || // Then check imported modules global.contains_qualified_fn(hash_script) // Then check sub-modules || self.global_sub_modules.values().any(|m| m.contains_qualified_fn(hash_script)); - if !r && !cache.filter.is_absent_and_set(hash_script) { + if !res && !cache.filter.is_absent_and_set(hash_script) && cache.dict.is_some() { // Do not cache "one-hit wonders" - cache.map.insert(hash_script, None); + cache.dict.as_mut().unwrap().insert(hash_script, None); } - r + res } }