Skip to main content

linkrs_query/optimizer/cost_based/
memory_budget.rs

1//! Memory Budget Allocator Module
2//!
3//! Allocates memory budgets to different stages of query execution based on
4//! the cost model configuration and plan structure.
5//!
6//! ## Usage Examples
7//!
8//! ```rust
9//! use linkrs_query::optimizer::cost::CostModelConfig;
10//! use linkrs_query::optimizer::cost_based::MemoryBudgetAllocator;
11//!
12//! let allocator = MemoryBudgetAllocator::with_config(
13//!     100 * 1024 * 1024, // 100MB total budget
14//!     CostModelConfig::default(),
15//! );
16//!
17//! // let budgets = allocator.allocate_budget(&plan_root);
18//! ```
19
20use std::collections::HashMap;
21
22use crate::optimizer::cost::config::UNKNOWN_SCAN_ROWS;
23use crate::optimizer::cost::CostModelConfig;
24use crate::optimizer::cost_based::ndv::estimate_group_count;
25use crate::planning::plan::core::nodes::base::plan_node_traits::SingleInputNode;
26use crate::planning::plan::core::nodes::PlanNodeEnum;
27
28/// Unique identifier for plan nodes
29pub type NodeId = u64;
30
31/// Memory budget allocation result
32#[derive(Debug, Clone)]
33pub struct MemoryBudgetAllocation {
34    /// Node identifier
35    pub node_id: NodeId,
36    /// Allocated memory budget in bytes
37    pub budget_bytes: usize,
38    /// Estimated memory requirement in bytes
39    pub estimated_requirement: usize,
40    /// Allocation priority (higher = more important)
41    pub priority: u32,
42}
43
44/// Operator implementation strategy based on memory budget
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum OperatorImplementation {
47    /// Standard in-memory implementation
48    InMemory,
49    /// External/memory-efficient implementation
50    External,
51    /// Hybrid approach (partial in-memory, partial external)
52    Hybrid,
53}
54
55/// Memory Budget Allocator
56///
57/// Allocates memory budgets to query plan nodes based on their requirements
58/// and the global memory constraints.
59#[derive(Debug, Clone)]
60pub struct MemoryBudgetAllocator {
61    /// Total memory budget in bytes
62    total_budget: usize,
63    /// Cost model configuration
64    config: CostModelConfig,
65    /// Default row size for memory estimation (bytes)
66    default_row_size: usize,
67}
68
69impl MemoryBudgetAllocator {
70    /// Create a new memory budget allocator
71    pub fn new(total_budget: usize) -> Self {
72        let config = CostModelConfig::default();
73        let default_row_size = config.row_width_bytes;
74        Self {
75            total_budget,
76            config,
77            default_row_size,
78        }
79    }
80
81    /// Create with specific configuration
82    pub fn with_config(total_budget: usize, config: CostModelConfig) -> Self {
83        let default_row_size = config.row_width_bytes;
84        Self {
85            total_budget,
86            config,
87            default_row_size,
88        }
89    }
90
91    /// Set default row size for estimation
92    pub fn with_row_size(mut self, row_size: usize) -> Self {
93        self.default_row_size = row_size;
94        self
95    }
96
97    /// Allocate memory budget to all nodes in the plan
98    ///
99    /// Returns a map from node ID to allocated budget
100    pub fn allocate_budget(&self, plan: &PlanNodeEnum) -> HashMap<NodeId, MemoryBudgetAllocation> {
101        let mut allocations = HashMap::new();
102        let mut requirements = Vec::new();
103
104        // First pass: collect all nodes and their requirements
105        self.collect_requirements(plan, &mut requirements);
106
107        // Calculate total requirement
108        let total_required: usize = requirements.iter().map(|r| r.estimated_requirement).sum();
109
110        // Second pass: allocate budgets
111        if total_required <= self.total_budget {
112            // Sufficient memory - allocate full requirements
113            for req in requirements {
114                allocations.insert(
115                    req.node_id,
116                    MemoryBudgetAllocation {
117                        node_id: req.node_id,
118                        budget_bytes: req.estimated_requirement,
119                        estimated_requirement: req.estimated_requirement,
120                        priority: req.priority,
121                    },
122                );
123            }
124        } else {
125            // Insufficient memory - proportional allocation with priority weighting
126            allocations = self.allocate_with_constraints(requirements, total_required);
127        }
128
129        allocations
130    }
131
132    /// Collect memory requirements from all nodes
133    fn collect_requirements(&self, plan: &PlanNodeEnum, requirements: &mut Vec<MemoryRequirement>) {
134        let node_id = self.get_node_id(plan);
135        let (requirement, priority) = self.estimate_node_memory(plan);
136
137        requirements.push(MemoryRequirement {
138            node_id,
139            estimated_requirement: requirement,
140            priority,
141        });
142
143        // Recursively collect from children
144        for child in self.get_children(plan) {
145            self.collect_requirements(child, requirements);
146        }
147    }
148
149    /// Estimate memory requirement for a single node
150    /// Row width comes from the shared cost-config source;
151    /// group counts come from the shared `estimate_group_count` helper.
152    fn estimate_node_memory(&self, plan: &PlanNodeEnum) -> (usize, u32) {
153        self.estimate_node_memory_with_rows(plan, None)
154    }
155
156    /// Estimate memory with an optional row-estimate override.
157    /// When `row_override` is present it replaces the fixed fallback so
158    /// budgeting consumes real row estimates instead of constants.
159    fn estimate_node_memory_with_rows(
160        &self,
161        plan: &PlanNodeEnum,
162        row_override: Option<usize>,
163    ) -> (usize, u32) {
164        let input_rows = |default_node: &PlanNodeEnum| {
165            row_override.unwrap_or_else(|| self.estimate_input_rows(default_node))
166        };
167        match plan {
168            PlanNodeEnum::Sort(node) => {
169                // Sorting needs to buffer all input rows
170                let rows = input_rows(plan);
171                let _ = node;
172                let memory = rows * self.default_row_size;
173                (memory, 100) // High priority - sorting is memory-intensive
174            }
175            PlanNodeEnum::InnerJoin(_) | PlanNodeEnum::LeftJoin(_) => {
176                // Hash join needs hash table for left input
177                let rows = input_rows(plan);
178                let memory = rows * self.default_row_size * 2; // Hash table overhead
179                (memory, 90)
180            }
181            PlanNodeEnum::Aggregate(node) => {
182                // Aggregation needs hash table or sort buffer; group count
183                // shares the single estimate_group_count source.
184                let rows = input_rows(plan);
185                let groups =
186                    estimate_group_count(rows as u64, node.group_keys().len().max(1)) as usize;
187                let memory = groups * self.default_row_size * 2;
188                (memory, 80)
189            }
190            PlanNodeEnum::Limit(node) => {
191                // Limit only needs to buffer limited rows
192                let limit = node.count() as usize;
193                let memory = limit * self.default_row_size;
194                (memory, 50) // Lower priority
195            }
196            PlanNodeEnum::Filter(_) => {
197                // Filter is streaming, minimal memory
198                (self.default_row_size * 10, 30)
199            }
200            PlanNodeEnum::Project(_) => {
201                // Projection is streaming, minimal memory
202                (self.default_row_size * 10, 30)
203            }
204            PlanNodeEnum::ScanVertices(_) | PlanNodeEnum::ScanEdges(_) => {
205                // Scan is streaming
206                (self.default_row_size * 10, 20)
207            }
208            PlanNodeEnum::IndexScan(_) => {
209                // Index scan is streaming
210                (self.default_row_size * 10, 20)
211            }
212            _ => {
213                // Default estimate
214                (self.default_row_size * 100, 50)
215            }
216        }
217    }
218
219    /// Estimate input rows for a node.
220    /// Single fallback source (`UNKNOWN_SCAN_ROWS`); callers with real
221    /// row estimates should use `allocate_budget_with_row_estimates`.
222    fn estimate_input_rows(&self, _plan: &PlanNodeEnum) -> usize {
223        UNKNOWN_SCAN_ROWS as usize
224    }
225
226    /// Allocate budgets using caller-provided row estimates keyed by node id.
227    /// Nodes missing from the map fall back to the single default.
228    pub fn allocate_budget_with_row_estimates(
229        &self,
230        plan: &PlanNodeEnum,
231        row_estimates: &HashMap<NodeId, u64>,
232    ) -> HashMap<NodeId, MemoryBudgetAllocation> {
233        let mut allocations = HashMap::new();
234        let mut requirements = Vec::new();
235        self.collect_requirements_with_rows(plan, row_estimates, &mut requirements);
236        let total_required: usize = requirements.iter().map(|r| r.estimated_requirement).sum();
237        if total_required <= self.total_budget {
238            for req in requirements {
239                allocations.insert(
240                    req.node_id,
241                    MemoryBudgetAllocation {
242                        node_id: req.node_id,
243                        budget_bytes: req.estimated_requirement,
244                        estimated_requirement: req.estimated_requirement,
245                        priority: req.priority,
246                    },
247                );
248            }
249        } else {
250            allocations = self.allocate_with_constraints(requirements, total_required);
251        }
252        allocations
253    }
254
255    /// Collect requirements honoring per-node row overrides.
256    fn collect_requirements_with_rows(
257        &self,
258        plan: &PlanNodeEnum,
259        row_estimates: &HashMap<NodeId, u64>,
260        requirements: &mut Vec<MemoryRequirement>,
261    ) {
262        let node_id = self.get_node_id(plan);
263        let override_rows = row_estimates.get(&node_id).copied().map(|v| v as usize);
264        let (requirement, priority) = self.estimate_node_memory_with_rows(plan, override_rows);
265        requirements.push(MemoryRequirement {
266            node_id,
267            estimated_requirement: requirement,
268            priority,
269        });
270        for child in self.get_children(plan) {
271            self.collect_requirements_with_rows(child, row_estimates, requirements);
272        }
273    }
274
275    /// Get children of a node
276    fn get_children<'a>(&self, plan: &'a PlanNodeEnum) -> Vec<&'a PlanNodeEnum> {
277        match plan {
278            PlanNodeEnum::Sort(node) => vec![node.input()],
279            PlanNodeEnum::InnerJoin(node) => {
280                vec![node.left_input(), node.right_input()]
281            }
282            PlanNodeEnum::LeftJoin(node) => {
283                vec![node.left_input(), node.right_input()]
284            }
285            PlanNodeEnum::Aggregate(node) => vec![node.input()],
286            PlanNodeEnum::Limit(node) => vec![node.input()],
287            PlanNodeEnum::Filter(node) => vec![node.input()],
288            PlanNodeEnum::Project(node) => vec![node.input()],
289            _ => vec![],
290        }
291    }
292
293    /// Get unique identifier for a node
294    ///
295    /// Uses the plan node's unique ID (i64) converted to u64.
296    /// This is stable across moves unlike pointer addresses.
297    fn get_node_id(&self, plan: &PlanNodeEnum) -> NodeId {
298        // Use the plan node's unique ID instead of pointer address
299        // This ensures stability even when the plan is moved
300        plan.id() as u64
301    }
302
303    /// Allocate budgets with constraints (when total requirement > budget)
304    fn allocate_with_constraints(
305        &self,
306        requirements: Vec<MemoryRequirement>,
307        _total_required: usize,
308    ) -> HashMap<NodeId, MemoryBudgetAllocation> {
309        let mut allocations = HashMap::new();
310
311        // Calculate priority-weighted allocation
312        let total_priority: u32 = requirements.iter().map(|r| r.priority).sum();
313
314        for req in requirements {
315            // Weight by priority
316            let priority_ratio = req.priority as f64 / total_priority as f64;
317            let allocated = (self.total_budget as f64 * priority_ratio) as usize;
318
319            // Ensure minimum allocation for critical operations
320            let min_allocation = if req.priority >= 80 {
321                req.estimated_requirement.min(self.total_budget / 4)
322            } else {
323                1024 // Minimum 1KB
324            };
325
326            let final_allocation = allocated.max(min_allocation);
327
328            allocations.insert(
329                req.node_id,
330                MemoryBudgetAllocation {
331                    node_id: req.node_id,
332                    budget_bytes: final_allocation,
333                    estimated_requirement: req.estimated_requirement,
334                    priority: req.priority,
335                },
336            );
337        }
338
339        allocations
340    }
341
342    /// Select operator implementation based on budget
343    pub fn select_operator_implementation(
344        &self,
345        plan: &PlanNodeEnum,
346        budget: usize,
347    ) -> OperatorImplementation {
348        self.select_operator_implementation_with_rows(plan, budget, None)
349    }
350
351    /// Select operator implementation consuming a real row estimate when
352    /// available, otherwise the single no-statistics fallback.
353    pub fn select_operator_implementation_with_rows(
354        &self,
355        plan: &PlanNodeEnum,
356        budget: usize,
357        row_override: Option<usize>,
358    ) -> OperatorImplementation {
359        let rows = |default_node: &PlanNodeEnum| {
360            row_override.unwrap_or_else(|| self.estimate_input_rows(default_node))
361        };
362        match plan {
363            PlanNodeEnum::Sort(_) => {
364                let required = rows(plan) * self.default_row_size;
365                if budget < required / 4 {
366                    OperatorImplementation::External
367                } else if budget < required {
368                    OperatorImplementation::Hybrid
369                } else {
370                    OperatorImplementation::InMemory
371                }
372            }
373            PlanNodeEnum::InnerJoin(_) | PlanNodeEnum::LeftJoin(_) => {
374                let required = rows(plan) * self.default_row_size * 2;
375                if budget < required / 2 {
376                    // Fall back to nested loop join
377                    OperatorImplementation::External
378                } else {
379                    OperatorImplementation::InMemory
380                }
381            }
382            PlanNodeEnum::Aggregate(_) => {
383                let required = rows(plan) * self.default_row_size;
384                if budget < required / 4 {
385                    OperatorImplementation::External
386                } else {
387                    OperatorImplementation::InMemory
388                }
389            }
390            _ => OperatorImplementation::InMemory,
391        }
392    }
393
394    /// Check if plan can execute within budget
395    pub fn can_execute_within_budget(&self, plan: &PlanNodeEnum) -> bool {
396        let allocations = self.allocate_budget(plan);
397        allocations
398            .values()
399            .all(|a| a.budget_bytes >= a.estimated_requirement / 4)
400    }
401
402    /// Check executability consuming caller-provided row estimates.
403    pub fn can_execute_within_budget_with_rows(
404        &self,
405        plan: &PlanNodeEnum,
406        row_estimates: &HashMap<NodeId, u64>,
407    ) -> bool {
408        let allocations = self.allocate_budget_with_row_estimates(plan, row_estimates);
409        allocations
410            .values()
411            .all(|a| a.budget_bytes >= a.estimated_requirement / 4)
412    }
413
414    /// Get total budget
415    pub fn total_budget(&self) -> usize {
416        self.total_budget
417    }
418
419    /// Get memory pressure threshold from config
420    pub fn memory_pressure_threshold(&self) -> usize {
421        self.config.memory_pressure_threshold
422    }
423}
424
425/// Internal structure for memory requirements
426#[derive(Debug, Clone)]
427struct MemoryRequirement {
428    node_id: NodeId,
429    estimated_requirement: usize,
430    priority: u32,
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_allocator_creation() {
439        let allocator = MemoryBudgetAllocator::new(100 * 1024 * 1024);
440        assert_eq!(allocator.total_budget(), 100 * 1024 * 1024);
441    }
442
443    #[test]
444    fn test_allocator_with_config() {
445        let config = CostModelConfig::default();
446        let allocator = MemoryBudgetAllocator::with_config(50 * 1024 * 1024, config);
447        assert_eq!(allocator.total_budget(), 50 * 1024 * 1024);
448    }
449
450    #[test]
451    fn test_memory_pressure_threshold() {
452        let config = CostModelConfig::default();
453        let allocator = MemoryBudgetAllocator::with_config(100 * 1024 * 1024, config);
454        assert_eq!(
455            allocator.memory_pressure_threshold(),
456            config.memory_pressure_threshold
457        );
458    }
459
460    #[test]
461    fn test_select_operator_implementation() {
462        let allocator = MemoryBudgetAllocator::new(100 * 1024 * 1024);
463
464        // Create a simple start node for testing
465        let start_node = crate::planning::plan::core::nodes::StartNode::new();
466        let plan = PlanNodeEnum::Start(start_node);
467
468        // Start node should use in-memory
469        let impl_choice = allocator.select_operator_implementation(&plan, 1024 * 1024);
470        assert_eq!(impl_choice, OperatorImplementation::InMemory);
471    }
472
473    #[test]
474    fn test_can_execute_within_budget() {
475        let allocator = MemoryBudgetAllocator::new(100 * 1024 * 1024);
476
477        let start_node = crate::planning::plan::core::nodes::StartNode::new();
478        let plan = PlanNodeEnum::Start(start_node);
479
480        // Simple plan should be executable
481        assert!(allocator.can_execute_within_budget(&plan));
482    }
483}