1use crate::function::{FnCaps, Function};
7use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
8use rustc_hash::FxHashMap;
9use std::sync::Arc;
10
11type RangeDimsProbe<'a> = dyn Fn(&ReferenceType) -> Option<(u32, u32)> + 'a;
12type FunctionLookup<'a> = dyn Fn(&str, &str) -> Option<Arc<dyn Function>> + 'a;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ExecStrategy {
16 Sequential,
17 ArgParallel,
18 ChunkedReduce,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Semantics {
23 Pure,
24 ShortCircuit,
25 Volatile,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct NodeCost {
30 pub est_nanos: u64, pub cells: u64, pub fanout: u16, }
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct NodeHints {
37 pub has_range: bool,
38 pub dims: Option<(u32, u32)>,
39 pub repeated_fp_count: u16, }
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct NodeAnnot {
44 pub semantics: Semantics,
45 pub cost: NodeCost,
46 pub hints: NodeHints,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PlanNode {
51 pub strategy: ExecStrategy,
52 pub children: Vec<PlanNode>,
53}
54
55#[derive(Debug, Clone)]
56pub struct PlanConfig {
57 pub enable_parallel: bool,
58 pub arg_parallel_min_cost_ns: u64,
59 pub arg_parallel_min_children: u16,
60 pub chunk_min_cells: u64,
61 pub chunk_target_partitions: u16,
62}
63
64impl Default for PlanConfig {
65 fn default() -> Self {
66 Self {
67 enable_parallel: true,
68 arg_parallel_min_cost_ns: 200_000, arg_parallel_min_children: 3,
70 chunk_min_cells: 10_000,
71 chunk_target_partitions: 8,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ExecPlan {
78 pub root: PlanNode,
79}
80
81pub struct Planner<'a> {
82 config: PlanConfig,
83 fp_cache: FxHashMap<u64, u16>,
85 _range_dims_probe: Option<&'a RangeDimsProbe<'a>>,
87 get_fn: Option<&'a FunctionLookup<'a>>,
89}
90
91impl<'a> Planner<'a> {
92 pub fn new(config: PlanConfig) -> Self {
93 Self {
94 config,
95 fp_cache: FxHashMap::default(),
96 _range_dims_probe: None,
97 get_fn: None,
98 }
99 }
100
101 pub fn with_range_probe(mut self, probe: &'a RangeDimsProbe<'a>) -> Self {
102 self._range_dims_probe = Some(probe);
103 self
104 }
105
106 pub fn with_function_lookup(mut self, get_fn: &'a FunctionLookup<'a>) -> Self {
107 self.get_fn = Some(get_fn);
108 self
109 }
110
111 pub fn plan(&mut self, ast: &ASTNode) -> ExecPlan {
112 self.fp_cache.clear();
113 let annot = self.annotate(ast);
114 let root = self.select(ast, &annot);
115 ExecPlan { root }
116 }
117
118 fn annotate(&mut self, ast: &ASTNode) -> NodeAnnot {
119 use ASTNodeType::*;
120 let semantics = if ast.contains_volatile() {
122 Semantics::Volatile
123 } else {
124 match &ast.node_type {
125 ASTNodeType::Function { name, .. } => {
126 if let Some(get) = &self.get_fn {
127 if let Some(f) = get("", name) {
128 let caps = crate::external_callbacks::metadata(
129 f.as_ref(),
130 crate::function::FnCaps::empty,
131 || f.caps(),
132 );
133 if caps.contains(FnCaps::VOLATILE) {
134 Semantics::Volatile
135 } else if caps.contains(FnCaps::SHORT_CIRCUIT) {
136 Semantics::ShortCircuit
137 } else {
138 Semantics::Pure
139 }
140 } else {
141 Semantics::Pure
142 }
143 } else {
144 Semantics::Pure
145 }
146 }
147 _ => Semantics::Pure,
148 }
149 };
150
151 let (cost, has_range, dims, fanout) = match &ast.node_type {
153 Literal(_) | Omitted => (
154 NodeCost {
155 est_nanos: 50,
156 cells: 0,
157 fanout: 0,
158 },
159 false,
160 None,
161 0,
162 ),
163 Reference { reference, .. } => {
164 let dims = self._range_dims_probe.and_then(|p| p(reference));
165 let cells = dims.map(|(r, c)| (r as u64) * (c as u64)).unwrap_or(0);
167 let est = 10_000 + cells / 10; (
169 NodeCost {
170 est_nanos: est,
171 cells,
172 fanout: 0,
173 },
174 true,
175 dims,
176 0,
177 )
178 }
179 UnaryOp { expr, .. } => {
180 let a = self.annotate(expr);
181 (a.cost, a.hints.has_range, a.hints.dims, 1)
182 }
183 BinaryOp { left, right, op: _ } => {
184 let a = self.annotate(left);
185 let b = self.annotate(right);
186 let est = a.cost.est_nanos + b.cost.est_nanos + 1_000;
187 let cells = a.cost.cells + b.cost.cells;
188 let has_range = a.hints.has_range || b.hints.has_range;
189 let dims = a.hints.dims.or(b.hints.dims);
190 (
191 NodeCost {
192 est_nanos: est,
193 cells,
194 fanout: 2,
195 },
196 has_range,
197 dims,
198 2,
199 )
200 }
201 Function { name, args } => {
202 let child_annots: Vec<NodeAnnot> = args.iter().map(|a| self.annotate(a)).collect();
204 let lname = name.to_ascii_lowercase();
206 let base = match lname.as_str() {
207 "sumifs" | "countifs" | "averageifs" => 200_000, "vlookup" | "xlookup" | "search" | "find" => 80_000,
209 _ => 5_000,
210 };
211 let children_cost: u64 = child_annots.iter().map(|a| a.cost.est_nanos).sum();
212 let cells: u64 = child_annots.iter().map(|a| a.cost.cells).sum();
213 let has_range = child_annots.iter().any(|a| a.hints.has_range);
214 let dims = child_annots.iter().find_map(|a| a.hints.dims);
215 let fanout = args.len() as u16;
216 (
217 NodeCost {
218 est_nanos: base + children_cost,
219 cells,
220 fanout,
221 },
222 has_range,
223 dims,
224 fanout,
225 )
226 }
227 Array(rows) => {
228 let mut est = 2_000;
229 let mut has_range = false;
230 let mut dims = Some((
231 rows.len() as u32,
232 rows.first().map(|r| r.len()).unwrap_or(0) as u32,
233 ));
234 for r in rows {
235 for c in r {
236 let a = self.annotate(c);
237 est += a.cost.est_nanos;
238 has_range |= a.hints.has_range;
239 if dims.is_none() {
240 dims = a.hints.dims;
241 }
242 }
243 }
244 (
245 NodeCost {
246 est_nanos: est,
247 cells: 0,
248 fanout: 0,
249 },
250 has_range,
251 dims,
252 0,
253 )
254 }
255 Call { callee, args } => {
256 let callee_annot = self.annotate(callee);
257 let child_annots: Vec<NodeAnnot> = args.iter().map(|a| self.annotate(a)).collect();
258 let children_cost: u64 = callee_annot.cost.est_nanos
259 + child_annots.iter().map(|a| a.cost.est_nanos).sum::<u64>();
260 let cells: u64 = callee_annot.cost.cells
261 + child_annots.iter().map(|a| a.cost.cells).sum::<u64>();
262 let has_range =
263 callee_annot.hints.has_range || child_annots.iter().any(|a| a.hints.has_range);
264 let dims = callee_annot
265 .hints
266 .dims
267 .or_else(|| child_annots.iter().find_map(|a| a.hints.dims));
268 let fanout = (args.len() + 1) as u16;
269 (
270 NodeCost {
271 est_nanos: 5_000 + children_cost,
272 cells,
273 fanout,
274 },
275 has_range,
276 dims,
277 fanout,
278 )
279 }
280 };
281
282 let repeated_fp_count = match &ast.node_type {
284 ASTNodeType::Function { args, .. } => {
285 let mut map: FxHashMap<u64, u16> = FxHashMap::default();
286 for a in args {
287 let fp = a.fingerprint();
288 *map.entry(fp).or_insert(0) += 1;
289 }
290 map.values().copied().filter(|&n| n > 1).sum()
291 }
292 ASTNodeType::BinaryOp { left, right, .. } => {
293 (left.fingerprint() == right.fingerprint()) as u16
294 }
295 _ => 0,
296 };
297
298 NodeAnnot {
299 semantics,
300 cost,
301 hints: NodeHints {
302 has_range,
303 dims,
304 repeated_fp_count,
305 },
306 }
307 }
308
309 fn select(&mut self, ast: &ASTNode, annot: &NodeAnnot) -> PlanNode {
310 use ExecStrategy::*;
311 let strategy = match annot.semantics {
313 Semantics::ShortCircuit => Sequential,
314 Semantics::Volatile => Sequential,
315 Semantics::Pure => {
316 if !self.config.enable_parallel {
317 Sequential
318 } else if annot.hints.has_range && annot.cost.cells >= self.config.chunk_min_cells {
319 ChunkedReduce
320 } else if annot.cost.est_nanos >= self.config.arg_parallel_min_cost_ns
321 && annot.cost.fanout >= self.config.arg_parallel_min_children
322 {
323 ArgParallel
324 } else {
325 Sequential
326 }
327 }
328 };
329
330 let children = match &ast.node_type {
332 ASTNodeType::UnaryOp { expr, .. } => {
333 let a = self.annotate(expr);
334 vec![self.select(expr, &a)]
335 }
336 ASTNodeType::BinaryOp { left, right, .. } => {
337 let la = self.annotate(left);
338 let ra = self.annotate(right);
339 vec![self.select(left, &la), self.select(right, &ra)]
340 }
341 ASTNodeType::Function { args, .. } => {
342 let mut v = Vec::with_capacity(args.len());
343 for a in args {
344 let an = self.annotate(a);
345 v.push(self.select(a, &an));
346 }
347 v
348 }
349 ASTNodeType::Call { callee, args } => {
350 let mut v = Vec::with_capacity(args.len() + 1);
351 let callee_annot = self.annotate(callee);
352 v.push(self.select(callee, &callee_annot));
353 for a in args {
354 let an = self.annotate(a);
355 v.push(self.select(a, &an));
356 }
357 v
358 }
359 ASTNodeType::Array(rows) => {
360 let mut v = Vec::new();
361 for r in rows {
362 for a in r {
363 let an = self.annotate(a);
364 v.push(self.select(a, &an));
365 }
366 }
367 v
368 }
369 _ => Vec::new(),
370 };
371
372 PlanNode { strategy, children }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn ensure_builtins_registered() {
381 use std::sync::Once;
382 static ONCE: Once = Once::new();
383 ONCE.call_once(|| {
384 crate::builtins::logical::register_builtins();
386 crate::builtins::logical_ext::register_builtins();
387 crate::builtins::datetime::register_builtins();
388 crate::builtins::math::register_builtins();
389 crate::builtins::text::register_builtins();
390 });
391 }
392
393 fn plan_for(formula: &str) -> ExecPlan {
394 ensure_builtins_registered();
395 let ast = formualizer_parse::parser::parse(formula).unwrap();
396 let mut planner = Planner::new(PlanConfig::default())
397 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
398 planner.plan(&ast)
399 }
400
401 #[test]
402 fn trivial_arith_is_sequential() {
403 let p = plan_for("=1+2+3");
404 assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
405 }
406
407 #[test]
408 fn sum_of_many_args_prefers_arg_parallel() {
409 let p = plan_for("=SUM(1,2,3,4,5,6)");
410 assert!(!p.root.children.is_empty()); assert!(matches!(
415 p.root.strategy,
416 ExecStrategy::ArgParallel | ExecStrategy::Sequential
417 ));
418 }
419
420 #[test]
421 fn sumifs_triggers_chunked_reduce_when_large() {
422 let ast = formualizer_parse::parser::parse(r#"=SUMIFS(A:A, A:A, ">0")"#).unwrap();
424 let mut planner = Planner::new(PlanConfig {
425 chunk_min_cells: 1000,
426 ..Default::default()
427 })
428 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
429 .with_range_probe(&|r: &ReferenceType| match r {
430 ReferenceType::Range {
431 start_row: None,
432 end_row: None,
433 ..
434 } => Some((10_000, 1)),
435 _ => None,
436 });
437 let plan = planner.plan(&ast);
438 assert!(matches!(
439 plan.root.strategy,
440 ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
441 ));
442 }
443
444 #[test]
445 fn short_circuit_functions_are_sequential() {
446 let p = plan_for("=IF(1,2,3)");
447 assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
448 let p2 = plan_for("=AND(TRUE(), FALSE())");
449 assert!(matches!(p2.root.strategy, ExecStrategy::Sequential));
450 }
451
452 #[test]
453 fn parentheses_do_not_force_parallelism() {
454 let p = plan_for("=(1+2)+(2+3)");
456 assert!(matches!(p.root.strategy, ExecStrategy::Sequential));
457 }
458
459 #[test]
460 fn repeated_subtrees_in_sum_encourage_arg_parallel() {
461 let p = plan_for("=SUM(1+2, 1+2, 1+2, 1+2)");
463 assert!(!p.root.children.is_empty());
465 }
466
467 #[test]
468 fn volatile_forces_sequential() {
469 let ast = formualizer_parse::parser::parse("=NOW()+1").unwrap();
471 let mut planner = Planner::new(PlanConfig::default())
472 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
473 let plan = planner.plan(&ast);
474 assert!(matches!(plan.root.strategy, ExecStrategy::Sequential));
475 }
476
477 #[test]
478 fn whole_column_ranges_prefer_chunked_reduce() {
479 let ast =
481 formualizer_parse::parser::parse(r#"=SUMIFS(A:A, A:A, ">0", B:B, "<5")"#).unwrap();
482 ensure_builtins_registered();
483 let mut planner = Planner::new(PlanConfig {
484 chunk_min_cells: 1000,
485 ..Default::default()
486 })
487 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
488 .with_range_probe(&|r: &ReferenceType| match r {
489 ReferenceType::Range {
490 start_row: None,
491 end_row: None,
492 ..
493 } => Some((50_000, 1)),
494 _ => None,
495 });
496 let plan = planner.plan(&ast);
497 assert!(matches!(
498 plan.root.strategy,
499 ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
500 ));
501 }
502
503 #[test]
504 fn deep_sub_ast_criteria_still_plans() {
505 let p = plan_for("=SUMIFS(A1:A100, B1:B100, TEXT(2024+1, \"0\"))");
507 assert!(!p.root.children.is_empty());
509 }
510
511 #[test]
512 fn sum_mixed_scalars_and_large_range_prefers_chunked_reduce() {
513 let ast = formualizer_parse::parser::parse(r#"=SUM(A:A, 1, 2, 3)"#).unwrap();
515 ensure_builtins_registered();
516 let mut planner = Planner::new(PlanConfig {
517 chunk_min_cells: 500,
518 ..Default::default()
519 })
520 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
521 .with_range_probe(&|r: &ReferenceType| match r {
522 ReferenceType::Range {
523 start_row: None,
524 end_row: None,
525 ..
526 } => Some((25_000, 1)),
527 _ => None,
528 });
529 let plan = planner.plan(&ast);
530 assert!(matches!(
531 plan.root.strategy,
532 ExecStrategy::ChunkedReduce | ExecStrategy::ArgParallel
533 ));
534 }
535
536 #[test]
537 fn nested_short_circuit_child_remains_sequential_under_parallel_parent() {
538 let ast = formualizer_parse::parser::parse("=SUM(AND(TRUE(), FALSE()), 1, 2, 3)").unwrap();
540 ensure_builtins_registered();
541 let cfg = PlanConfig {
542 enable_parallel: true,
543 arg_parallel_min_cost_ns: 0,
544 arg_parallel_min_children: 2,
545 chunk_min_cells: 1_000_000, chunk_target_partitions: 8,
547 };
548 let mut planner = Planner::new(cfg)
549 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name));
550 let plan = planner.plan(&ast);
551 assert!(matches!(
553 plan.root.strategy,
554 ExecStrategy::ArgParallel | ExecStrategy::Sequential
555 ));
556 assert!(!plan.root.children.is_empty());
558 assert!(matches!(
559 plan.root.children[0].strategy,
560 ExecStrategy::Sequential
561 ));
562 }
563
564 #[test]
565 fn repeated_identical_ranges_defaults_to_sequential() {
566 let ast = formualizer_parse::parser::parse(r#"=SUM(A:A, A:A, A:A)"#).unwrap();
568 let mut planner = Planner::new(PlanConfig::default())
569 .with_function_lookup(&|ns, name| crate::function_registry::get(ns, name))
570 .with_range_probe(&|r: &ReferenceType| match r {
571 ReferenceType::Range {
572 start_row: None,
573 end_row: None,
574 ..
575 } => Some((3, 1)),
576 _ => None,
577 });
578 let plan = planner.plan(&ast);
579 assert!(matches!(plan.root.strategy, ExecStrategy::Sequential));
580 assert_eq!(plan.root.children.len(), 3);
581 }
582}