Skip to content

Commit 4de9a13

Browse files
Pre-compute map_each args for expensive functions
1 parent dfe210d commit 4de9a13

2 files changed

Lines changed: 141 additions & 1 deletion

File tree

engine/src/ast/field_expr.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ impl Expr for ComparisonExpr {
795795
#[allow(clippy::bool_assert_comparison)]
796796
mod tests {
797797
use super::*;
798+
use crate::ast::ValueExpr;
798799
use crate::ast::function_expr::{FunctionCallArgExpr, FunctionCallExpr};
799800
use crate::ast::logical_expr::LogicalExpr;
800801
use crate::execution_context::ExecutionContext;
@@ -1034,6 +1035,26 @@ mod tests {
10341035
},
10351036
)
10361037
.unwrap();
1038+
builder
1039+
.add_function(
1040+
"concat_fields",
1041+
SimpleFunctionDefinition {
1042+
params: vec![
1043+
SimpleFunctionParam {
1044+
arg_kind: SimpleFunctionArgKind::Field,
1045+
val_type: Type::Bytes,
1046+
},
1047+
SimpleFunctionParam {
1048+
arg_kind: SimpleFunctionArgKind::Field,
1049+
val_type: Type::Bytes,
1050+
},
1051+
],
1052+
opt_params: vec![],
1053+
return_type: Type::Bytes,
1054+
implementation: SimpleFunctionImpl::new(concat_function),
1055+
},
1056+
)
1057+
.unwrap();
10371058
builder
10381059
.add_function("filter", FilterFunction::new())
10391060
.unwrap();
@@ -2207,6 +2228,86 @@ mod tests {
22072228
assert_eq!(expr.execute_one(ctx), true);
22082229
}
22092230

2231+
// The non-mapped argument (the "-cf" literal) must be applied to *every*
2232+
// mapped element, even though it is now evaluated only once per call.
2233+
#[test]
2234+
fn test_map_each_function_non_mapped_arg_applied_to_all_elements() {
2235+
let (expr, rest) = FilterParser::new(&SCHEME)
2236+
.lex_as::<FunctionCallExpr>(r#"concat(http.cookies[*], "-cf")"#)
2237+
.unwrap();
2238+
assert_eq!(rest, "");
2239+
2240+
let expr = expr.compile();
2241+
let ctx = &mut ExecutionContext::new(&SCHEME);
2242+
ctx.set_field_value(
2243+
field("http.cookies"),
2244+
Array::from_iter(["one", "two", "three"]),
2245+
)
2246+
.unwrap();
2247+
2248+
assert_eq!(
2249+
expr.execute(ctx),
2250+
Ok(LhsValue::Array(Array::from_iter([
2251+
"one-cf", "two-cf", "three-cf"
2252+
])))
2253+
);
2254+
}
2255+
2256+
// A non-mapped argument that is expensive to re-evaluate (here a nested
2257+
// function call) is evaluated once and reused for every mapped element.
2258+
#[test]
2259+
fn test_map_each_memoizes_expensive_non_mapped_arg() {
2260+
let (expr, rest) = FilterParser::new(&SCHEME)
2261+
.lex_as::<FunctionCallExpr>(r#"concat_fields(http.cookies[*], lowercase(http.host))"#)
2262+
.unwrap();
2263+
assert_eq!(rest, "");
2264+
2265+
let expr = expr.compile();
2266+
let ctx = &mut ExecutionContext::new(&SCHEME);
2267+
ctx.set_field_value(
2268+
field("http.cookies"),
2269+
Array::from_iter(["one", "two", "three"]),
2270+
)
2271+
.unwrap();
2272+
ctx.set_field_value(field("http.host"), "SUFFIX").unwrap();
2273+
2274+
// `lowercase(http.host)` == "suffix" is appended to every element.
2275+
assert_eq!(
2276+
expr.execute(ctx),
2277+
Ok(LhsValue::Array(Array::from_iter([
2278+
"onesuffix",
2279+
"twosuffix",
2280+
"threesuffix"
2281+
])))
2282+
);
2283+
}
2284+
2285+
// map_each over a Map with no extra args: exercises the fused Map -> Array
2286+
// path (no intermediate array allocation) and the empty-args fast path.
2287+
#[test]
2288+
fn test_map_each_on_map_no_extra_args() {
2289+
let (expr, rest) = FilterParser::new(&SCHEME)
2290+
.lex_as::<FunctionCallExpr>(r#"lowercase(http.headers[*])"#)
2291+
.unwrap();
2292+
assert_eq!(rest, "");
2293+
2294+
let expr = expr.compile();
2295+
let ctx = &mut ExecutionContext::new(&SCHEME);
2296+
let headers = LhsValue::from({
2297+
let mut map = TypedMap::new();
2298+
map.insert(b"0".to_vec().into(), "ONE");
2299+
map.insert(b"1".to_vec().into(), "TWO");
2300+
map.insert(b"2".to_vec().into(), "THREE");
2301+
map
2302+
});
2303+
ctx.set_field_value(field("http.headers"), headers).unwrap();
2304+
2305+
assert_eq!(
2306+
expr.execute(ctx),
2307+
Ok(LhsValue::Array(Array::from_iter(["one", "two", "three"])))
2308+
);
2309+
}
2310+
22102311
#[test]
22112312
fn test_map_each_on_array_for_cmp() {
22122313
let expr = assert_ok!(

engine/src/ast/function_expr.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use super::ValueExpr;
22
use super::parse::FilterParser;
33
use super::visitor::{Visitor, VisitorMut};
44
use crate::FunctionRef;
5-
use crate::ast::field_expr::{ComparisonExpr, ComparisonOp, ComparisonOpExpr};
5+
use crate::ast::field_expr::{ComparisonExpr, ComparisonOp, ComparisonOpExpr, IdentifierExpr};
66
use crate::ast::index_expr::IndexExpr;
77
use crate::ast::logical_expr::{LogicalExpr, UnaryOp};
88
use crate::compiler::Compiler;
@@ -89,6 +89,20 @@ impl FunctionCallArgExpr {
8989
}
9090
}
9191

92+
/// Returns `true` if re-evaluating this argument for every mapped element
93+
/// could be costly, i.e. it is a nested function call or a logical
94+
/// sub-expression. Literals and plain field accesses are cheap to evaluate
95+
/// repeatedly, so memoizing them would only add overhead.
96+
fn is_expensive_to_reevaluate(&self) -> bool {
97+
match self {
98+
FunctionCallArgExpr::Literal(_) => false,
99+
FunctionCallArgExpr::Logical(_) => true,
100+
FunctionCallArgExpr::IndexExpr(index_expr) => {
101+
matches!(index_expr.identifier, IdentifierExpr::FunctionCallExpr(_))
102+
}
103+
}
104+
}
105+
92106
#[allow(dead_code)]
93107
pub(crate) fn simplify(self) -> Self {
94108
match self {
@@ -263,6 +277,16 @@ impl ValueExpr for FunctionCallExpr {
263277
let call = function
264278
.as_definition()
265279
.compile(&mut args.iter().map(|arg| arg.into()), context);
280+
// For `map_each`, only bother evaluating the non-mapped arguments once
281+
// (instead of once per element) when at least one of them is expensive
282+
// to re-evaluate. For trivial arguments (literals / plain field
283+
// accesses) inline re-evaluation is just as cheap and avoids a
284+
// per-call allocation.
285+
let memoize_extra_args = map_each_count > 0
286+
&& args
287+
.iter()
288+
.skip(1)
289+
.any(FunctionCallArgExpr::is_expensive_to_reevaluate);
266290
let mut args = args
267291
.into_iter()
268292
.map(|arg| compiler.compile_function_call_arg_expr(arg))
@@ -322,6 +346,21 @@ impl ValueExpr for FunctionCallExpr {
322346
|elem| once(Ok(elem)),
323347
)
324348
})
349+
} else if memoize_extra_args {
350+
CompiledValueExpr::new(move |ctx| {
351+
// At least one non-mapped argument is expensive to
352+
// re-evaluate, so evaluate all of them once per call and
353+
// reuse them (cheaply cloned) for every element instead of
354+
// re-executing the argument expressions for every element.
355+
let extra_args = args.iter().map(|arg| arg.execute(ctx)).collect::<Vec<_>>();
356+
compute(
357+
first.execute(ctx),
358+
&call,
359+
return_type,
360+
#[inline]
361+
|elem| ExactSizeChain::new(once(Ok(elem)), extra_args.iter().cloned()),
362+
)
363+
})
325364
} else {
326365
CompiledValueExpr::new(move |ctx| {
327366
compute(

0 commit comments

Comments
 (0)