Eagerly evaluate built-in operators for OptimizationLevel::Simple.

This commit is contained in:
Stephen Chung 2020-10-05 10:27:31 +08:00
parent b91a073596
commit 0d0affd5e9
11 changed files with 182 additions and 104 deletions

View File

@ -16,7 +16,9 @@ Breaking changes
New features New features
------------ ------------
* `is_def_var()` to detect if variable is defined and `is_def_fn()` to detect if script function is defined. * `OptimizationLevel::Simple` now eagerly evaluates built-in binary operators of primary types (if not overloaded).
* Added `is_def_var()` to detect if variable is defined and `is_def_fn()` to detect if script function is defined.
* Added `Module::get_script_fn` to get a scripted function in a module, if any, based on name and number of parameters.
Version 0.19.0 Version 0.19.0

View File

@ -3,8 +3,8 @@ Eager Function Evaluation When Using Full Optimization Level
{{#include ../../links.md}} {{#include ../../links.md}}
When the optimization level is [`OptimizationLevel::Full`], the [`Engine`] assumes all functions to be _pure_ and will _eagerly_ When the optimization level is [`OptimizationLevel::Full`], the [`Engine`] assumes all functions to be _pure_
evaluated all function calls with constant arguments, using the result to replace the call. and will _eagerly_ evaluated all function calls with constant arguments, using the result to replace the call.
This also applies to all operators (which are implemented as functions). This also applies to all operators (which are implemented as functions).
@ -14,8 +14,8 @@ For instance, the same example above:
// When compiling the following with OptimizationLevel::Full... // When compiling the following with OptimizationLevel::Full...
const DECISION = 1; const DECISION = 1;
// this condition is now eliminated because 'DECISION == 1' // this condition is now eliminated because 'sign(DECISION) > 0'
if DECISION == 1 { // is a function call to the '==' function, and it returns 'true' if DECISION.sign() > 0 { // is a call to the 'sign' and '>' functions, and they return 'true'
print("hello!"); // this block is promoted to the parent level print("hello!"); // this block is promoted to the parent level
} else { } else {
print("boo!"); // this block is eliminated because it is never reached print("boo!"); // this block is eliminated because it is never reached
@ -24,13 +24,3 @@ if DECISION == 1 { // is a function call to the '==' function, and it r
print("hello!"); // <- the above is equivalent to this print("hello!"); // <- the above is equivalent to this
// ('print' and 'debug' are handled specially) // ('print' and 'debug' are handled specially)
``` ```
Because of the eager evaluation of functions, many constant expressions will be evaluated and replaced by the result.
This does not happen with [`OptimizationLevel::Simple`] which doesn't assume all functions to be _pure_.
```rust
// When compiling the following with OptimizationLevel::Full...
let x = (1+2)*3-4/5%6; // <- will be replaced by 'let x = 9'
let y = (1>2) || (3<=4); // <- will be replaced by 'let y = true'
```

View File

@ -61,17 +61,63 @@ For fixed script texts, the constant values can be provided in a user-defined [`
to the [`Engine`] for use in compilation and evaluation. to the [`Engine`] for use in compilation and evaluation.
Watch Out for Function Calls Eager Operator Evaluations
--------------------------- -------------------------
Beware, however, that most operators are actually function calls, and those functions can be overridden, Beware, however, that most operators are actually function calls, and those functions can be overridden,
so they are not optimized away: so whether they are optimized away depends on the situation:
* If the operands are not _constant_ values, it is not optimized.
* If the operator is [overloaded][operator overloading], it is not optimized because the overloading function may not be _pure_
(i.e. may cause side-effects when called).
* If the operator is not _binary_, it is not optimized. Only binary operators are built-in to Rhai.
* If the operands are not of the same type, it is not optimized.
* If the operator is not _built-in_ (see list of [built-in operators]), it is not optimized.
* If the operator is a binary built-in operator for a [standard type][standard types], it is called and replaced by a constant result.
Rhai guarantees that no external function will be run (in order not to trigger side-effects) during the
optimization process (unless the optimization level is set to [`OptimizationLevel::Full`]).
```rust ```rust
const DECISION = 1; const DECISION = 1; // this is an integer, one of the standard types
if DECISION == 1 { // NOT optimized away because you can define if DECISION == 1 { // this is optimized into 'true'
: // your own '==' function to override the built-in default! :
} else if DECISION == 2 { // this is optimized into 'false'
:
} else if DECISION == 3 { // this is optimized into 'false'
:
} else {
:
}
```
Because of the eager evaluation of operators for [standard types], many constant expressions will be evaluated
and replaced by the result.
```rust
let x = (1+2)*3-4/5%6; // will be replaced by 'let x = 9'
let y = (1>2) || (3<=4); // will be replaced by 'let y = true'
```
For operators that are not optimized away due to one of the above reasons, the function calls
are simply left behind:
```rust
// Assume 'new_state' returns some custom type that is NOT one of the standard types.
// Also assume that the '==; operator is defined for that custom type.
const DECISION_1 = new_state(1);
const DECISION_2 = new_state(2);
const DECISION_3 = new_state(3);
if DECISION == 1 { // NOT optimized away because the operator is not built-in
: // and may cause side-effects if called!
: :
} else if DECISION == 2 { // same here, NOT optimized away } else if DECISION == 2 { // same here, NOT optimized away
: :
@ -82,28 +128,4 @@ if DECISION == 1 { // NOT optimized away because you can define
} }
``` ```
because no operator functions will be run (in order not to trigger side-effects) during the optimization process
(unless the optimization level is set to [`OptimizationLevel::Full`]).
So, instead, do this:
```rust
const DECISION_1 = true;
const DECISION_2 = false;
const DECISION_3 = false;
if DECISION_1 {
: // this branch is kept and promoted to the parent level
} else if DECISION_2 {
: // this branch is eliminated
} else if DECISION_3 {
: // this branch is eliminated
} else {
: // this branch is eliminated
}
```
In general, boolean constants are most effective for the optimizer to automatically prune
large `if`-`else` branches because they do not depend on operators.
Alternatively, turn the optimizer to [`OptimizationLevel::Full`]. Alternatively, turn the optimizer to [`OptimizationLevel::Full`].

View File

@ -8,9 +8,10 @@ There are three levels of optimization: `None`, `Simple` and `Full`.
* `None` is obvious - no optimization on the AST is performed. * `None` is obvious - no optimization on the AST is performed.
* `Simple` (default) performs only relatively _safe_ optimizations without causing side-effects * `Simple` (default) performs only relatively _safe_ optimizations without causing side-effects
(i.e. it only relies on static analysis and will not actually perform any function calls). (i.e. it only relies on static analysis and [built-in operators] for constant [standard types],
and will not perform any external function calls).
* `Full` is _much_ more aggressive, _including_ running functions on constant arguments to determine their result. * `Full` is _much_ more aggressive, _including_ calling external functions on constant arguments to determine their result.
One benefit to this is that many more optimization opportunities arise, especially with regards to comparison operators. One benefit to this is that many more optimization opportunities arise, especially with regards to comparison operators.

View File

@ -16,11 +16,11 @@ The final, optimized [`AST`] is then used for evaluations.
// Compile master script to AST // Compile master script to AST
let master_ast = engine.compile( let master_ast = engine.compile(
r" r"
if SCENARIO_1 { if SCENARIO == 1 {
do_work(); do_work();
} else if SCENARIO_2 { } else if SCENARIO == 2 {
do_something(); do_something();
} else if SCENARIO_3 { } else if SCENARIO == 3 {
do_something_else(); do_something_else();
} else { } else {
do_nothing(); do_nothing();
@ -29,9 +29,7 @@ r"
// Create a new 'Scope' - put constants in it to aid optimization // Create a new 'Scope' - put constants in it to aid optimization
let mut scope = Scope::new(); let mut scope = Scope::new();
scope.push_constant("SCENARIO_1", true); scope.push_constant("SCENARIO", 1_i64);
scope.push_constant("SCENARIO_2", false);
scope.push_constant("SCENARIO_3", false);
// Re-optimize the AST // Re-optimize the AST
let new_ast = engine.optimize_ast(&scope, master_ast.clone(), OptimizationLevel::Simple); let new_ast = engine.optimize_ast(&scope, master_ast.clone(), OptimizationLevel::Simple);

View File

@ -3,17 +3,20 @@ Side-Effect Considerations for Full Optimization Level
{{#include ../../links.md}} {{#include ../../links.md}}
All of Rhai's built-in functions (and operators which are implemented as functions) are _pure_ (i.e. they do not mutate state All of Rhai's built-in functions (and operators which are implemented as functions) are _pure_
nor cause any side-effects, with the exception of `print` and `debug` which are handled specially) so using (i.e. they do not mutate state nor cause any side-effects, with the exception of `print` and `debug`
[`OptimizationLevel::Full`] is usually quite safe _unless_ custom types and functions are registered. which are handled specially) so using [`OptimizationLevel::Full`] is usually quite safe _unless_
custom types and functions are registered.
If custom functions are registered, they _may_ be called (or maybe not, if the calls happen to lie within a pruned code block). If custom functions are registered, they _may_ be called (or maybe not, if the calls happen to lie
within a pruned code block).
If custom functions are registered to overload built-in operators, they will also be called when the operators are used If custom functions are registered to overload built-in operators, they will also be called when
(in an `if` statement, for example) causing side-effects. the operators are used (in an `if` statement, for example) causing side-effects.
Therefore, the rule-of-thumb is: Therefore, the rule-of-thumb is:
* _Always_ register custom types and functions _after_ compiling scripts if [`OptimizationLevel::Full`] is used. * _Always_ register custom types and functions _after_ compiling scripts if [`OptimizationLevel::Full`] is used.
* _DO NOT_ depend on knowledge that the functions have no side-effects, because those functions can change later on and, when that happens, existing scripts may break in subtle ways. * _DO NOT_ depend on knowledge that the functions have no side-effects, because those functions can change later on and,
when that happens, existing scripts may break in subtle ways.

View File

@ -100,6 +100,7 @@
[function namespaces]: {{rootUrl}}/language/fn-namespaces.md [function namespaces]: {{rootUrl}}/language/fn-namespaces.md
[anonymous function]: {{rootUrl}}/language/fn-anon.md [anonymous function]: {{rootUrl}}/language/fn-anon.md
[anonymous functions]: {{rootUrl}}/language/fn-anon.md [anonymous functions]: {{rootUrl}}/language/fn-anon.md
[operator overloading]: {{rootUrl}}/rust/operators.md
[`Module`]: {{rootUrl}}/language/modules/index.md [`Module`]: {{rootUrl}}/language/modules/index.md
[module]: {{rootUrl}}/language/modules/index.md [module]: {{rootUrl}}/language/modules/index.md

View File

@ -146,7 +146,7 @@ fn main() {
#[cfg(not(feature = "no_optimize"))] #[cfg(not(feature = "no_optimize"))]
{ {
ast = engine.optimize_ast(&scope, r, OptimizationLevel::Full); ast = engine.optimize_ast(&scope, r, OptimizationLevel::Simple);
} }
#[cfg(feature = "no_optimize")] #[cfg(feature = "no_optimize")]

View File

@ -439,7 +439,33 @@ impl Engine {
} }
// Has a system function an override? // Has a system function an override?
fn has_override(&self, lib: &Module, hash_fn: u64, hash_script: u64, pub_only: bool) -> bool { pub(crate) fn has_override_by_name_and_arguments(
&self,
lib: &Module,
name: &str,
arg_types: &[TypeId],
pub_only: bool,
) -> bool {
let arg_len = if arg_types.is_empty() {
usize::MAX
} else {
arg_types.len()
};
let hash_fn = calc_fn_hash(empty(), name, arg_len, arg_types.iter().cloned());
let hash_script = calc_fn_hash(empty(), name, arg_types.len(), empty());
self.has_override(lib, hash_fn, hash_script, pub_only)
}
// Has a system function an override?
pub(crate) fn has_override(
&self,
lib: &Module,
hash_fn: u64,
hash_script: u64,
pub_only: bool,
) -> bool {
// NOTE: We skip script functions for global_module and packages, and native functions for lib // NOTE: We skip script functions for global_module and packages, and native functions for lib
// First check script-defined functions // First check script-defined functions

View File

@ -294,6 +294,23 @@ impl Module {
hash_script hash_script
} }
/// Get a script-defined function in the module based on name and number of parameters.
pub fn get_script_fn(
&self,
name: &str,
num_params: usize,
public_only: bool,
) -> Option<&Shared<ScriptFnDef>> {
self.functions
.values()
.find(|(fn_name, access, num, _, _)| {
(!public_only || *access == FnAccess::Public)
&& *num == num_params
&& fn_name == name
})
.map(|(_, _, _, _, f)| f.get_shared_fn_def())
}
/// Does a sub-module exist in the module? /// Does a sub-module exist in the module?
/// ///
/// # Examples /// # Examples

View File

@ -5,6 +5,7 @@ use crate::calc_fn_hash;
use crate::engine::{ use crate::engine::{
Engine, KEYWORD_DEBUG, KEYWORD_EVAL, KEYWORD_FN_PTR, KEYWORD_PRINT, KEYWORD_TYPE_OF, Engine, KEYWORD_DEBUG, KEYWORD_EVAL, KEYWORD_FN_PTR, KEYWORD_PRINT, KEYWORD_TYPE_OF,
}; };
use crate::fn_call::run_builtin_binary_op;
use crate::fn_native::FnPtr; use crate::fn_native::FnPtr;
use crate::module::Module; use crate::module::Module;
use crate::parser::{map_dynamic_to_expr, Expr, ScriptFnDef, Stmt, AST}; use crate::parser::{map_dynamic_to_expr, Expr, ScriptFnDef, Stmt, AST};
@ -568,28 +569,46 @@ fn optimize_expr(expr: Expr, state: &mut State) -> Expr {
} }
} }
// Call built-in functions
Expr::FnCall(mut x)
if x.1.is_none() // Non-qualified
&& state.optimization_level == OptimizationLevel::Simple // simple optimizations
&& x.3.len() == 2 // binary call
&& x.3.iter().all(Expr::is_constant) // all arguments are constants
=> {
let ((name, _, _, pos), _, _, args, _) = x.as_mut();
let arg_values: StaticVec<_> = args.iter().map(Expr::get_constant_value).collect();
let arg_types: StaticVec<_> = arg_values.iter().map(Dynamic::type_id).collect();
// Search for overloaded operators (can override built-in).
if !state.engine.has_override_by_name_and_arguments(state.lib, name, arg_types.as_ref(), false) {
if let Some(expr) = run_builtin_binary_op(name, &arg_values[0], &arg_values[1])
.ok().flatten()
.and_then(|result| map_dynamic_to_expr(result, *pos))
{
state.set_dirty();
return expr;
}
}
x.3 = x.3.into_iter().map(|a| optimize_expr(a, state)).collect();
Expr::FnCall(x)
}
// Eagerly call functions // Eagerly call functions
Expr::FnCall(mut x) Expr::FnCall(mut x)
if x.1.is_none() // Non-qualified if x.1.is_none() // Non-qualified
&& state.optimization_level == OptimizationLevel::Full // full optimizations && state.optimization_level == OptimizationLevel::Full // full optimizations
&& x.3.iter().all(|expr| expr.is_constant()) // all arguments are constants && x.3.iter().all(Expr::is_constant) // all arguments are constants
=> { => {
let ((name, _, _, pos), _, _, args, def_value) = x.as_mut(); let ((name, _, _, pos), _, _, args, def_value) = x.as_mut();
// First search in functions lib (can override built-in) // First search for script-defined functions (can override built-in)
// Cater for both normal function call style and method call style (one additional arguments) let has_script_fn = cfg!(not(feature = "no_function"))
let has_script_fn = cfg!(not(feature = "no_function")) && state.lib.iter_fn().find(|(_, _, _, _,f)| { && state.lib.get_script_fn(name, args.len(), false).is_some();
if !f.is_script() { return false; }
let fn_def = f.get_fn_def();
fn_def.name == name && (args.len()..=args.len() + 1).contains(&fn_def.params.len())
}).is_some();
if has_script_fn {
// A script-defined function overrides the built-in function - do not make the call
x.3 = x.3.into_iter().map(|a| optimize_expr(a, state)).collect();
return Expr::FnCall(x);
}
if !has_script_fn {
let mut arg_values: StaticVec<_> = args.iter().map(Expr::get_constant_value).collect(); let mut arg_values: StaticVec<_> = args.iter().map(Expr::get_constant_value).collect();
// Save the typename of the first argument if it is `type_of()` // Save the typename of the first argument if it is `type_of()`
@ -600,7 +619,7 @@ fn optimize_expr(expr: Expr, state: &mut State) -> Expr {
"" ""
}; };
call_fn_with_constant_arguments(&state, name, arg_values.as_mut()) if let Some(expr) = call_fn_with_constant_arguments(&state, name, arg_values.as_mut())
.or_else(|| { .or_else(|| {
if !arg_for_type_of.is_empty() { if !arg_for_type_of.is_empty() {
// Handle `type_of()` // Handle `type_of()`
@ -611,15 +630,14 @@ fn optimize_expr(expr: Expr, state: &mut State) -> Expr {
} }
}) })
.and_then(|result| map_dynamic_to_expr(result, *pos)) .and_then(|result| map_dynamic_to_expr(result, *pos))
.map(|expr| { {
state.set_dirty(); state.set_dirty();
expr return expr;
}) }
.unwrap_or_else(|| { }
// Optimize function call arguments
x.3 = x.3.into_iter().map(|a| optimize_expr(a, state)).collect(); x.3 = x.3.into_iter().map(|a| optimize_expr(a, state)).collect();
Expr::FnCall(x) Expr::FnCall(x)
})
} }
// id(args ..) -> optimize function call arguments // id(args ..) -> optimize function call arguments