/home/noah/src/trueno/src/brick/exec_graph/traversal.rs
Line | Count | Source |
1 | | //! ExecutionGraph - Execution Path Graph for Profiling |
2 | | //! |
3 | | //! PAR-201: Captures the full execution hierarchy for profiling analysis. |
4 | | |
5 | | use std::collections::HashMap; |
6 | | |
7 | | use super::node::{ |
8 | | EdgeType, ExecutionEdge, ExecutionNode, ExecutionNodeId, TransferDirection, |
9 | | }; |
10 | | |
11 | | /// Execution path graph for tracking brick → kernel → PTX relationships. |
12 | | /// |
13 | | /// PAR-201: Captures the full execution hierarchy for profiling analysis. |
14 | | /// |
15 | | /// # Example |
16 | | /// |
17 | | /// ```rust,ignore |
18 | | /// use trueno::brick::{ExecutionGraph, ExecutionNode, EdgeType}; |
19 | | /// |
20 | | /// let mut graph = ExecutionGraph::new(); |
21 | | /// |
22 | | /// // Add layer scope |
23 | | /// let layer_id = graph.add_node(ExecutionNode::Layer { index: 0 }); |
24 | | /// |
25 | | /// // Add brick within layer |
26 | | /// let brick_id = graph.add_node(ExecutionNode::Brick { |
27 | | /// id: BrickId::QkvProjection, |
28 | | /// timing_ns: 1000, |
29 | | /// elements: 4096, |
30 | | /// }); |
31 | | /// graph.add_edge(layer_id, brick_id, EdgeType::Contains); |
32 | | /// |
33 | | /// // Add kernel launched by brick |
34 | | /// let kernel_id = graph.add_node(ExecutionNode::Kernel { |
35 | | /// name: "batched_q4k_gemv".into(), |
36 | | /// ptx_hash: 0x7a3b1c2d, |
37 | | /// grid: (32, 1, 1), |
38 | | /// block: (256, 1, 1), |
39 | | /// shared_mem: 4096, |
40 | | /// }); |
41 | | /// graph.add_edge(brick_id, kernel_id, EdgeType::Launches); |
42 | | /// |
43 | | /// // Export to trueno-graph for analysis |
44 | | /// #[cfg(feature = "execution-graph")] |
45 | | /// let csr = graph.to_csr(); |
46 | | /// ``` |
47 | | #[derive(Debug, Default)] |
48 | | pub struct ExecutionGraph { |
49 | | /// All nodes in the graph |
50 | | nodes: Vec<ExecutionNode>, |
51 | | /// All edges in the graph |
52 | | edges: Vec<ExecutionEdge>, |
53 | | /// Scope stack for hierarchical recording |
54 | | scope_stack: Vec<ExecutionNodeId>, |
55 | | /// Node name → ID mapping for fast lookup |
56 | | name_to_id: HashMap<String, ExecutionNodeId>, |
57 | | } |
58 | | |
59 | | impl ExecutionGraph { |
60 | | /// Create a new empty execution graph. |
61 | 0 | pub fn new() -> Self { |
62 | 0 | Self::default() |
63 | 0 | } |
64 | | |
65 | | /// Add a node to the graph, returning its ID. |
66 | 0 | pub fn add_node(&mut self, node: ExecutionNode) -> ExecutionNodeId { |
67 | 0 | let id = ExecutionNodeId(self.nodes.len() as u32); |
68 | 0 | let name = node.name(); |
69 | 0 | self.name_to_id.insert(name, id); |
70 | 0 | self.nodes.push(node); |
71 | 0 | id |
72 | 0 | } |
73 | | |
74 | | /// Add an edge between two nodes. |
75 | 0 | pub fn add_edge(&mut self, src: ExecutionNodeId, dst: ExecutionNodeId, edge_type: EdgeType) { |
76 | 0 | self.edges.push(ExecutionEdge { |
77 | 0 | src, |
78 | 0 | dst, |
79 | 0 | edge_type, |
80 | 0 | weight: 1.0, |
81 | 0 | }); |
82 | 0 | } |
83 | | |
84 | | /// Add an edge with a weight. |
85 | 0 | pub fn add_weighted_edge( |
86 | 0 | &mut self, |
87 | 0 | src: ExecutionNodeId, |
88 | 0 | dst: ExecutionNodeId, |
89 | 0 | edge_type: EdgeType, |
90 | 0 | weight: f32, |
91 | 0 | ) { |
92 | 0 | self.edges.push(ExecutionEdge { |
93 | 0 | src, |
94 | 0 | dst, |
95 | 0 | edge_type, |
96 | 0 | weight, |
97 | 0 | }); |
98 | 0 | } |
99 | | |
100 | | /// Push a scope for hierarchical recording. |
101 | | /// All subsequent nodes will be children of this scope. |
102 | 0 | pub fn push_scope(&mut self, node: ExecutionNode) -> ExecutionNodeId { |
103 | 0 | let id = self.add_node(node); |
104 | 0 | if let Some(&parent) = self.scope_stack.last() { |
105 | 0 | self.add_edge(parent, id, EdgeType::Contains); |
106 | 0 | } |
107 | 0 | self.scope_stack.push(id); |
108 | 0 | id |
109 | 0 | } |
110 | | |
111 | | /// Pop the current scope. |
112 | 0 | pub fn pop_scope(&mut self) -> Option<ExecutionNodeId> { |
113 | 0 | self.scope_stack.pop() |
114 | 0 | } |
115 | | |
116 | | /// Get the current scope (if any). |
117 | 0 | pub fn current_scope(&self) -> Option<ExecutionNodeId> { |
118 | 0 | self.scope_stack.last().copied() |
119 | 0 | } |
120 | | |
121 | | /// Add a node under the current scope. |
122 | 0 | pub fn add_node_in_scope(&mut self, node: ExecutionNode) -> ExecutionNodeId { |
123 | 0 | let id = self.add_node(node); |
124 | 0 | if let Some(&parent) = self.scope_stack.last() { |
125 | 0 | self.add_edge(parent, id, EdgeType::Contains); |
126 | 0 | } |
127 | 0 | id |
128 | 0 | } |
129 | | |
130 | | /// Record a kernel launch under the current scope. |
131 | 0 | pub fn record_kernel_launch( |
132 | 0 | &mut self, |
133 | 0 | name: &str, |
134 | 0 | ptx_hash: u64, |
135 | 0 | grid: (u32, u32, u32), |
136 | 0 | block: (u32, u32, u32), |
137 | 0 | shared_mem: u32, |
138 | 0 | ) -> ExecutionNodeId { |
139 | 0 | let kernel = ExecutionNode::Kernel { |
140 | 0 | name: name.to_string(), |
141 | 0 | ptx_hash, |
142 | 0 | grid, |
143 | 0 | block, |
144 | 0 | shared_mem, |
145 | 0 | timing_ns: None, |
146 | 0 | arithmetic_intensity: None, |
147 | 0 | achieved_tflops: None, |
148 | 0 | }; |
149 | 0 | let kernel_id = self.add_node(kernel); |
150 | | |
151 | | // Link from current scope with Launches edge |
152 | 0 | if let Some(&parent) = self.scope_stack.last() { |
153 | 0 | self.add_edge(parent, kernel_id, EdgeType::Launches); |
154 | 0 | } |
155 | | |
156 | 0 | kernel_id |
157 | 0 | } |
158 | | |
159 | | /// Record a kernel launch with roofline metrics (Phase 9). |
160 | | #[allow(clippy::too_many_arguments)] |
161 | 0 | pub fn record_kernel_launch_with_metrics( |
162 | 0 | &mut self, |
163 | 0 | name: &str, |
164 | 0 | ptx_hash: u64, |
165 | 0 | grid: (u32, u32, u32), |
166 | 0 | block: (u32, u32, u32), |
167 | 0 | shared_mem: u32, |
168 | 0 | timing_ns: u64, |
169 | 0 | arithmetic_intensity: f32, |
170 | 0 | achieved_tflops: f32, |
171 | 0 | ) -> ExecutionNodeId { |
172 | 0 | let kernel = ExecutionNode::Kernel { |
173 | 0 | name: name.to_string(), |
174 | 0 | ptx_hash, |
175 | 0 | grid, |
176 | 0 | block, |
177 | 0 | shared_mem, |
178 | 0 | timing_ns: Some(timing_ns), |
179 | 0 | arithmetic_intensity: Some(arithmetic_intensity), |
180 | 0 | achieved_tflops: Some(achieved_tflops), |
181 | 0 | }; |
182 | 0 | let kernel_id = self.add_node(kernel); |
183 | | |
184 | 0 | if let Some(&parent) = self.scope_stack.last() { |
185 | 0 | self.add_edge(parent, kernel_id, EdgeType::Launches); |
186 | 0 | } |
187 | | |
188 | 0 | kernel_id |
189 | 0 | } |
190 | | |
191 | | /// Record a memory transfer (Phase 9: data movement topology). |
192 | 0 | pub fn record_transfer( |
193 | 0 | &mut self, |
194 | 0 | src: &str, |
195 | 0 | dst: &str, |
196 | 0 | bytes: u64, |
197 | 0 | direction: TransferDirection, |
198 | 0 | timing_ns: Option<u64>, |
199 | 0 | ) -> ExecutionNodeId { |
200 | 0 | let transfer = ExecutionNode::Transfer { |
201 | 0 | src: src.to_string(), |
202 | 0 | dst: dst.to_string(), |
203 | 0 | bytes, |
204 | 0 | direction, |
205 | 0 | timing_ns, |
206 | 0 | }; |
207 | 0 | let transfer_id = self.add_node(transfer); |
208 | | |
209 | 0 | if let Some(&parent) = self.scope_stack.last() { |
210 | 0 | self.add_edge(parent, transfer_id, EdgeType::Contains); |
211 | 0 | } |
212 | | |
213 | 0 | transfer_id |
214 | 0 | } |
215 | | |
216 | | /// Add a dependency edge for critical path analysis (Phase 9). |
217 | 0 | pub fn add_dependency(&mut self, from: ExecutionNodeId, to: ExecutionNodeId) { |
218 | 0 | self.add_edge(from, to, EdgeType::DependsOn); |
219 | 0 | } |
220 | | |
221 | | /// Get a node by ID. |
222 | 0 | pub fn node(&self, id: ExecutionNodeId) -> Option<&ExecutionNode> { |
223 | 0 | self.nodes.get(id.0 as usize) |
224 | 0 | } |
225 | | |
226 | | /// Get a node by name. |
227 | 0 | pub fn node_by_name(&self, name: &str) -> Option<(ExecutionNodeId, &ExecutionNode)> { |
228 | 0 | self.name_to_id |
229 | 0 | .get(name) |
230 | 0 | .and_then(|&id| self.nodes.get(id.0 as usize).map(|n| (id, n))) |
231 | 0 | } |
232 | | |
233 | | /// Get all nodes. |
234 | 0 | pub fn nodes(&self) -> &[ExecutionNode] { |
235 | 0 | &self.nodes |
236 | 0 | } |
237 | | |
238 | | /// Get all edges. |
239 | 0 | pub fn edges(&self) -> &[ExecutionEdge] { |
240 | 0 | &self.edges |
241 | 0 | } |
242 | | |
243 | | /// Number of nodes. |
244 | 0 | pub fn num_nodes(&self) -> usize { |
245 | 0 | self.nodes.len() |
246 | 0 | } |
247 | | |
248 | | /// Number of edges. |
249 | 0 | pub fn num_edges(&self) -> usize { |
250 | 0 | self.edges.len() |
251 | 0 | } |
252 | | |
253 | | /// Get outgoing edges for a node. |
254 | 0 | pub fn outgoing_edges(&self, node: ExecutionNodeId) -> impl Iterator<Item = &ExecutionEdge> { |
255 | 0 | self.edges.iter().filter(move |e| e.src == node) |
256 | 0 | } |
257 | | |
258 | | /// Get incoming edges for a node. |
259 | 0 | pub fn incoming_edges(&self, node: ExecutionNodeId) -> impl Iterator<Item = &ExecutionEdge> { |
260 | 0 | self.edges.iter().filter(move |e| e.dst == node) |
261 | 0 | } |
262 | | |
263 | | /// Find all kernel nodes. |
264 | 0 | pub fn kernel_nodes(&self) -> impl Iterator<Item = (ExecutionNodeId, &ExecutionNode)> { |
265 | 0 | self.nodes |
266 | 0 | .iter() |
267 | 0 | .enumerate() |
268 | 0 | .filter(|(_, n)| n.is_kernel()) |
269 | 0 | .map(|(i, n)| (ExecutionNodeId(i as u32), n)) |
270 | 0 | } |
271 | | |
272 | | /// Find the slowest kernel (by parent brick timing). |
273 | 0 | pub fn slowest_kernel(&self) -> Option<(ExecutionNodeId, &ExecutionNode, u64)> { |
274 | 0 | let mut slowest: Option<(ExecutionNodeId, &ExecutionNode, u64)> = None; |
275 | | |
276 | 0 | for (id, node) in self.nodes.iter().enumerate() { |
277 | 0 | if let ExecutionNode::Brick { timing_ns, .. } = node { |
278 | | // Check if this brick has kernel children |
279 | 0 | let node_id = ExecutionNodeId(id as u32); |
280 | 0 | let has_kernel = self |
281 | 0 | .outgoing_edges(node_id) |
282 | 0 | .any(|e| e.edge_type == EdgeType::Launches); |
283 | | |
284 | 0 | if has_kernel { |
285 | 0 | match &slowest { |
286 | 0 | None => slowest = Some((node_id, node, *timing_ns)), |
287 | 0 | Some((_, _, t)) if *timing_ns > *t => { |
288 | 0 | slowest = Some((node_id, node, *timing_ns)) |
289 | | } |
290 | 0 | _ => {} |
291 | | } |
292 | 0 | } |
293 | 0 | } |
294 | | } |
295 | | |
296 | 0 | slowest |
297 | 0 | } |
298 | | |
299 | | /// Export to DOT format for Graphviz visualization. |
300 | 0 | pub fn to_dot(&self) -> String { |
301 | 0 | let mut dot = String::from("digraph ExecutionGraph {\n"); |
302 | 0 | dot.push_str(" rankdir=TB;\n"); |
303 | 0 | dot.push_str(" node [shape=box];\n\n"); |
304 | | |
305 | | // Add nodes with styling based on type |
306 | 0 | for (i, node) in self.nodes.iter().enumerate() { |
307 | 0 | let (label, style) = match node { |
308 | 0 | ExecutionNode::Layer { index } => { |
309 | 0 | (format!("Layer {}", index), "style=filled,fillcolor=lightblue") |
310 | | } |
311 | 0 | ExecutionNode::Brick { id, timing_ns, .. } => ( |
312 | 0 | format!("{}\\n{:.1}µs", id.name(), *timing_ns as f64 / 1000.0), |
313 | 0 | "style=filled,fillcolor=lightgreen", |
314 | 0 | ), |
315 | | ExecutionNode::Kernel { |
316 | 0 | name, grid, block, .. |
317 | 0 | } => ( |
318 | 0 | format!("{}\\n<<<{},{},{}>>>", name, grid.0, block.0, block.1), |
319 | 0 | "style=filled,fillcolor=lightyellow", |
320 | 0 | ), |
321 | 0 | ExecutionNode::Function { name, file, line } => { |
322 | 0 | let loc = match (file, line) { |
323 | 0 | (Some(f), Some(l)) => format!("\\n{}:{}", f, l), |
324 | 0 | _ => String::new(), |
325 | | }; |
326 | 0 | ( |
327 | 0 | format!("{}{}", name, loc), |
328 | 0 | "style=filled,fillcolor=lightgray", |
329 | 0 | ) |
330 | | } |
331 | | ExecutionNode::Transfer { |
332 | 0 | src, |
333 | 0 | dst, |
334 | 0 | bytes, |
335 | 0 | direction, |
336 | | .. |
337 | | } => { |
338 | 0 | let dir = match direction { |
339 | 0 | TransferDirection::H2D => "H2D", |
340 | 0 | TransferDirection::D2H => "D2H", |
341 | 0 | TransferDirection::D2D => "D2D", |
342 | | }; |
343 | 0 | ( |
344 | 0 | format!("{}\\n{}->{}\\n{:.1}MB", dir, src, dst, *bytes as f64 / 1e6), |
345 | 0 | "style=filled,fillcolor=lightsalmon", |
346 | 0 | ) |
347 | | } |
348 | | ExecutionNode::AsyncTask { |
349 | 0 | name, |
350 | 0 | poll_count, |
351 | 0 | yield_count, |
352 | 0 | total_poll_ns, |
353 | | } => { |
354 | 0 | let efficiency = if *poll_count > 0 { |
355 | 0 | 100.0 / *poll_count as f64 |
356 | | } else { |
357 | 0 | 0.0 |
358 | | }; |
359 | 0 | ( |
360 | 0 | format!( |
361 | 0 | "{}\\npolls:{} yields:{}\\n{:.1}µs ({:.0}%)", |
362 | 0 | name, |
363 | 0 | poll_count, |
364 | 0 | yield_count, |
365 | 0 | *total_poll_ns as f64 / 1000.0, |
366 | 0 | efficiency |
367 | 0 | ), |
368 | 0 | "style=filled,fillcolor=lightcyan", |
369 | 0 | ) |
370 | | } |
371 | | }; |
372 | 0 | dot.push_str(&format!(" n{} [label=\"{}\",{}];\n", i, label, style)); |
373 | | } |
374 | | |
375 | 0 | dot.push('\n'); |
376 | | |
377 | | // Add edges with styling based on type |
378 | 0 | for edge in &self.edges { |
379 | 0 | let style = match edge.edge_type { |
380 | 0 | EdgeType::Calls => "style=solid", |
381 | 0 | EdgeType::Contains => "style=dashed", |
382 | 0 | EdgeType::Launches => "style=bold,color=red", |
383 | 0 | EdgeType::Sequence => "style=dotted", |
384 | 0 | EdgeType::DependsOn => "style=solid,color=blue", |
385 | 0 | EdgeType::Transfer { .. } => "style=bold,color=orange", |
386 | | }; |
387 | 0 | dot.push_str(&format!( |
388 | 0 | " n{} -> n{} [{}];\n", |
389 | 0 | edge.src.0, edge.dst.0, style |
390 | 0 | )); |
391 | | } |
392 | | |
393 | 0 | dot.push_str("}\n"); |
394 | 0 | dot |
395 | 0 | } |
396 | | |
397 | | /// Export to trueno-graph CsrGraph format. |
398 | | #[cfg(feature = "execution-graph")] |
399 | | pub fn to_csr(&self) -> trueno_graph::CsrGraph { |
400 | | use trueno_graph::{CsrGraph, NodeId}; |
401 | | |
402 | | let edges: Vec<(NodeId, NodeId, f32)> = self |
403 | | .edges |
404 | | .iter() |
405 | | .map(|e| (NodeId(e.src.0), NodeId(e.dst.0), e.weight)) |
406 | | .collect(); |
407 | | |
408 | | let mut graph = CsrGraph::from_edge_list(&edges).unwrap_or_default(); |
409 | | |
410 | | // Set node names for querying |
411 | | for (i, node) in self.nodes.iter().enumerate() { |
412 | | graph.set_node_name(NodeId(i as u32), node.name()); |
413 | | } |
414 | | |
415 | | graph |
416 | | } |
417 | | |
418 | | /// Convert to presentar-terminal TreeNode for TUI visualization. |
419 | | /// |
420 | | /// PAR-201: Renders the execution graph as a collapsible tree in the terminal. |
421 | | #[cfg(feature = "presentar-tui")] |
422 | | pub fn to_tree_node(&self) -> presentar_terminal::TreeNode { |
423 | | use presentar_terminal::{Color, TreeNode}; |
424 | | |
425 | | // Color scheme for node types |
426 | | let layer_color = Color::new(0.4, 0.6, 1.0, 1.0); // Light blue |
427 | | let brick_color = Color::new(0.4, 0.8, 0.4, 1.0); // Light green |
428 | | let kernel_color = Color::new(1.0, 0.8, 0.3, 1.0); // Yellow/orange |
429 | | let func_color = Color::new(0.7, 0.7, 0.7, 1.0); // Light gray |
430 | | |
431 | | // Build child map: parent -> [children] |
432 | | let mut children_map: HashMap<u32, Vec<u32>> = HashMap::new(); |
433 | | let mut has_parent: std::collections::HashSet<u32> = std::collections::HashSet::new(); |
434 | | |
435 | | for edge in &self.edges { |
436 | | if edge.edge_type == EdgeType::Contains || edge.edge_type == EdgeType::Launches { |
437 | | children_map |
438 | | .entry(edge.src.0) |
439 | | .or_default() |
440 | | .push(edge.dst.0); |
441 | | has_parent.insert(edge.dst.0); |
442 | | } |
443 | | } |
444 | | |
445 | | // Find root nodes (nodes with no parent) |
446 | | let root_ids: Vec<u32> = (0..self.nodes.len() as u32) |
447 | | .filter(|id| !has_parent.contains(id)) |
448 | | .collect(); |
449 | | |
450 | | // Recursive function to build TreeNode |
451 | | fn build_node( |
452 | | graph: &ExecutionGraph, |
453 | | id: u32, |
454 | | children_map: &HashMap<u32, Vec<u32>>, |
455 | | layer_color: Color, |
456 | | brick_color: Color, |
457 | | kernel_color: Color, |
458 | | func_color: Color, |
459 | | ) -> TreeNode { |
460 | | let node = &graph.nodes[id as usize]; |
461 | | let (label, info, color) = match node { |
462 | | ExecutionNode::Layer { index } => { |
463 | | (format!("Layer {}", index), None, layer_color) |
464 | | } |
465 | | ExecutionNode::Brick { |
466 | | id: brick_id, |
467 | | timing_ns, |
468 | | elements, |
469 | | } => ( |
470 | | brick_id.name().to_string(), |
471 | | Some(format!( |
472 | | "{:.1}µs ({} elem)", |
473 | | *timing_ns as f64 / 1000.0, |
474 | | elements |
475 | | )), |
476 | | brick_color, |
477 | | ), |
478 | | ExecutionNode::Kernel { |
479 | | name, |
480 | | grid, |
481 | | block, |
482 | | shared_mem, |
483 | | .. |
484 | | } => ( |
485 | | name.clone(), |
486 | | Some(format!( |
487 | | "<<<{},{},{}>>> smem={}B", |
488 | | grid.0, block.0, block.1, shared_mem |
489 | | )), |
490 | | kernel_color, |
491 | | ), |
492 | | ExecutionNode::Function { name, file, line } => { |
493 | | let loc = match (file, line) { |
494 | | (Some(f), Some(l)) => format!(" ({}:{})", f, l), |
495 | | _ => String::new(), |
496 | | }; |
497 | | (format!("{}{}", name, loc), None, func_color) |
498 | | } |
499 | | ExecutionNode::Transfer { |
500 | | src, |
501 | | dst, |
502 | | bytes, |
503 | | direction, |
504 | | timing_ns, |
505 | | } => { |
506 | | let timing_str = timing_ns |
507 | | .map(|ns| format!(" {:.1}µs", ns as f64 / 1000.0)) |
508 | | .unwrap_or_default(); |
509 | | ( |
510 | | format!("{:?}: {} → {}", direction, src, dst), |
511 | | Some(format!("{}B{}", bytes, timing_str)), |
512 | | Color::new(0.8, 0.4, 0.8, 1.0), // Transfer color (magenta) |
513 | | ) |
514 | | } |
515 | | ExecutionNode::AsyncTask { |
516 | | name, |
517 | | poll_count, |
518 | | yield_count, |
519 | | total_poll_ns, |
520 | | } => { |
521 | | let efficiency = if *poll_count > 0 { |
522 | | 100.0 / *poll_count as f64 |
523 | | } else { |
524 | | 0.0 |
525 | | }; |
526 | | ( |
527 | | name.clone(), |
528 | | Some(format!( |
529 | | "polls:{} yields:{} {:.1}µs ({:.0}% eff)", |
530 | | poll_count, |
531 | | yield_count, |
532 | | *total_poll_ns as f64 / 1000.0, |
533 | | efficiency |
534 | | )), |
535 | | Color::new(0.4, 0.8, 0.8, 1.0), // Async task color (cyan) |
536 | | ) |
537 | | } |
538 | | }; |
539 | | |
540 | | let mut tree_node = TreeNode::new(id as u64, label).with_color(color); |
541 | | if let Some(info_str) = info { |
542 | | tree_node = tree_node.with_info(info_str); |
543 | | } |
544 | | |
545 | | // Add children |
546 | | if let Some(child_ids) = children_map.get(&id) { |
547 | | for &child_id in child_ids { |
548 | | let child = build_node( |
549 | | graph, |
550 | | child_id, |
551 | | children_map, |
552 | | layer_color, |
553 | | brick_color, |
554 | | kernel_color, |
555 | | func_color, |
556 | | ); |
557 | | tree_node = tree_node.with_child(child); |
558 | | } |
559 | | } |
560 | | |
561 | | tree_node |
562 | | } |
563 | | |
564 | | // Build root node |
565 | | if root_ids.is_empty() { |
566 | | TreeNode::new(0, "Empty Graph") |
567 | | } else if root_ids.len() == 1 { |
568 | | build_node( |
569 | | self, |
570 | | root_ids[0], |
571 | | &children_map, |
572 | | layer_color, |
573 | | brick_color, |
574 | | kernel_color, |
575 | | func_color, |
576 | | ) |
577 | | } else { |
578 | | // Multiple roots: wrap in a synthetic root |
579 | | let mut root = |
580 | | TreeNode::new(u64::MAX, "Execution Graph").with_color(Color::new(0.9, 0.9, 0.9, 1.0)); |
581 | | for &root_id in &root_ids { |
582 | | let child = build_node( |
583 | | self, |
584 | | root_id, |
585 | | &children_map, |
586 | | layer_color, |
587 | | brick_color, |
588 | | kernel_color, |
589 | | func_color, |
590 | | ); |
591 | | root = root.with_child(child); |
592 | | } |
593 | | root |
594 | | } |
595 | | } |
596 | | |
597 | | /// Render graph to ASCII tree string (headless mode for testing/automation). |
598 | | /// |
599 | | /// PAR-201: Zero-dependency tree visualization for CI/CD, logging, and snapshot tests. |
600 | | #[must_use] |
601 | 0 | pub fn to_ascii_tree(&self) -> String { |
602 | | // Build child map: parent -> [children] |
603 | 0 | let mut children_map: HashMap<u32, Vec<u32>> = HashMap::new(); |
604 | 0 | let mut has_parent: std::collections::HashSet<u32> = std::collections::HashSet::new(); |
605 | | |
606 | 0 | for edge in &self.edges { |
607 | 0 | if edge.edge_type == EdgeType::Contains || edge.edge_type == EdgeType::Launches { |
608 | 0 | children_map |
609 | 0 | .entry(edge.src.0) |
610 | 0 | .or_default() |
611 | 0 | .push(edge.dst.0); |
612 | 0 | has_parent.insert(edge.dst.0); |
613 | 0 | } |
614 | | } |
615 | | |
616 | | // Find root nodes (nodes with no parent) |
617 | 0 | let root_ids: Vec<u32> = (0..self.nodes.len() as u32) |
618 | 0 | .filter(|id| !has_parent.contains(id)) |
619 | 0 | .collect(); |
620 | | |
621 | | // Recursive function to build tree string |
622 | 0 | fn build_tree( |
623 | 0 | graph: &ExecutionGraph, |
624 | 0 | id: u32, |
625 | 0 | children_map: &HashMap<u32, Vec<u32>>, |
626 | 0 | prefix: &str, |
627 | 0 | connector: &str, |
628 | 0 | output: &mut String, |
629 | 0 | ) { |
630 | 0 | let node = &graph.nodes[id as usize]; |
631 | 0 | let (label, info) = match node { |
632 | 0 | ExecutionNode::Layer { index } => (format!("Layer {}", index), String::new()), |
633 | | ExecutionNode::Brick { |
634 | 0 | id: brick_id, |
635 | 0 | timing_ns, |
636 | 0 | elements, |
637 | 0 | } => ( |
638 | 0 | brick_id.name().to_string(), |
639 | 0 | format!(" {:.1}µs ({} elem)", *timing_ns as f64 / 1000.0, elements), |
640 | 0 | ), |
641 | | ExecutionNode::Kernel { |
642 | 0 | name, |
643 | 0 | grid, |
644 | 0 | block, |
645 | 0 | shared_mem, |
646 | | .. |
647 | 0 | } => ( |
648 | 0 | name.clone(), |
649 | 0 | format!( |
650 | 0 | " <<<{},{},{}>>> smem={}B", |
651 | 0 | grid.0, block.0, block.1, shared_mem |
652 | 0 | ), |
653 | 0 | ), |
654 | 0 | ExecutionNode::Function { name, file, line } => { |
655 | 0 | let loc = match (file, line) { |
656 | 0 | (Some(f), Some(l)) => format!(" ({}:{})", f, l), |
657 | 0 | _ => String::new(), |
658 | | }; |
659 | 0 | (format!("{}{}", name, loc), String::new()) |
660 | | } |
661 | | ExecutionNode::Transfer { |
662 | 0 | src, |
663 | 0 | dst, |
664 | 0 | bytes, |
665 | 0 | direction, |
666 | 0 | timing_ns, |
667 | | } => { |
668 | 0 | let timing_str = timing_ns |
669 | 0 | .map(|ns| format!(" {:.1}µs", ns as f64 / 1000.0)) |
670 | 0 | .unwrap_or_default(); |
671 | 0 | ( |
672 | 0 | format!("{:?}: {} → {}", direction, src, dst), |
673 | 0 | format!(" {}B{}", bytes, timing_str), |
674 | 0 | ) |
675 | | } |
676 | | ExecutionNode::AsyncTask { |
677 | 0 | name, |
678 | 0 | poll_count, |
679 | 0 | yield_count, |
680 | 0 | total_poll_ns, |
681 | | } => { |
682 | 0 | let efficiency = if *poll_count > 0 { |
683 | 0 | 100.0 / *poll_count as f64 |
684 | | } else { |
685 | 0 | 0.0 |
686 | | }; |
687 | 0 | ( |
688 | 0 | name.clone(), |
689 | 0 | format!( |
690 | 0 | " polls:{} yields:{} {:.1}µs ({:.0}% eff)", |
691 | 0 | poll_count, |
692 | 0 | yield_count, |
693 | 0 | *total_poll_ns as f64 / 1000.0, |
694 | 0 | efficiency |
695 | 0 | ), |
696 | 0 | ) |
697 | | } |
698 | | }; |
699 | | |
700 | 0 | output.push_str(&format!("{}{}{}{}\n", prefix, connector, label, info)); |
701 | | |
702 | 0 | if let Some(child_ids) = children_map.get(&id) { |
703 | 0 | let child_count = child_ids.len(); |
704 | 0 | for (i, &child_id) in child_ids.iter().enumerate() { |
705 | 0 | let is_last = i == child_count - 1; |
706 | 0 | let new_connector = if is_last { "└── " } else { "├── " }; |
707 | 0 | let new_prefix = if connector.is_empty() { |
708 | 0 | prefix.to_string() |
709 | 0 | } else if connector == "└── " { |
710 | 0 | format!("{} ", prefix) |
711 | | } else { |
712 | 0 | format!("{}│ ", prefix) |
713 | | }; |
714 | 0 | build_tree(graph, child_id, children_map, &new_prefix, new_connector, output); |
715 | | } |
716 | 0 | } |
717 | 0 | } |
718 | | |
719 | 0 | let mut output = String::new(); |
720 | | |
721 | 0 | if root_ids.is_empty() { |
722 | 0 | output.push_str("(empty graph)\n"); |
723 | 0 | } else if root_ids.len() == 1 { |
724 | 0 | build_tree(self, root_ids[0], &children_map, "", "", &mut output); |
725 | 0 | } else { |
726 | | // Multiple roots: add synthetic root |
727 | 0 | output.push_str("Execution Graph\n"); |
728 | 0 | let root_count = root_ids.len(); |
729 | 0 | for (i, &root_id) in root_ids.iter().enumerate() { |
730 | 0 | let is_last = i == root_count - 1; |
731 | 0 | let connector = if is_last { "└── " } else { "├── " }; |
732 | 0 | build_tree(self, root_id, &children_map, "", connector, &mut output); |
733 | | } |
734 | | } |
735 | | |
736 | | // Remove trailing newline for cleaner output |
737 | 0 | if output.ends_with('\n') { |
738 | 0 | output.pop(); |
739 | 0 | } |
740 | 0 | output |
741 | 0 | } |
742 | | |
743 | | // ======================== |
744 | | // Phase 9: Critical Path Analysis (CPA) |
745 | | // ======================== |
746 | | |
747 | | /// Get timing for a node (ns). Returns 0 for non-timed nodes. |
748 | 0 | fn node_timing_ns(&self, id: ExecutionNodeId) -> u64 { |
749 | 0 | match &self.nodes[id.0 as usize] { |
750 | 0 | ExecutionNode::Brick { timing_ns, .. } => *timing_ns, |
751 | 0 | ExecutionNode::Kernel { timing_ns, .. } => timing_ns.unwrap_or(0), |
752 | 0 | ExecutionNode::Transfer { timing_ns, .. } => timing_ns.unwrap_or(0), |
753 | 0 | _ => 0, |
754 | | } |
755 | 0 | } |
756 | | |
757 | | /// Compute critical path through execution graph using longest-path algorithm. |
758 | | /// |
759 | | /// Returns (critical_path_nodes, total_time_ns). The critical path represents |
760 | | /// the longest chain of dependencies that determines total execution time. |
761 | | /// |
762 | | /// Reference: Graham et al. (1979) "Scheduling Algorithms for Multi-Processor Systems" |
763 | 0 | pub fn critical_path(&self) -> (Vec<ExecutionNodeId>, u64) { |
764 | 0 | if self.nodes.is_empty() { |
765 | 0 | return (vec![], 0); |
766 | 0 | } |
767 | | |
768 | | // Build adjacency list for DependsOn and Sequence edges |
769 | 0 | let mut adj: Vec<Vec<(u32, u64)>> = vec![vec![]; self.nodes.len()]; |
770 | 0 | for edge in &self.edges { |
771 | 0 | match &edge.edge_type { |
772 | 0 | EdgeType::DependsOn | EdgeType::Sequence => { |
773 | 0 | let weight = self.node_timing_ns(edge.dst); |
774 | 0 | adj[edge.src.0 as usize].push((edge.dst.0, weight)); |
775 | 0 | } |
776 | 0 | EdgeType::Contains | EdgeType::Calls | EdgeType::Launches => { |
777 | 0 | // Hierarchical edges: children contribute to parent time |
778 | 0 | let weight = self.node_timing_ns(edge.dst); |
779 | 0 | adj[edge.src.0 as usize].push((edge.dst.0, weight)); |
780 | 0 | } |
781 | 0 | EdgeType::Transfer { .. } => { |
782 | 0 | // Transfer edges carry their own timing |
783 | 0 | let weight = self.node_timing_ns(edge.dst); |
784 | 0 | adj[edge.src.0 as usize].push((edge.dst.0, weight)); |
785 | 0 | } |
786 | | } |
787 | | } |
788 | | |
789 | | // Topological sort using Kahn's algorithm |
790 | 0 | let mut in_degree = vec![0u32; self.nodes.len()]; |
791 | 0 | for edges in &adj { |
792 | 0 | for (dst, _) in edges { |
793 | 0 | in_degree[*dst as usize] += 1; |
794 | 0 | } |
795 | | } |
796 | | |
797 | 0 | let mut queue: Vec<u32> = (0..self.nodes.len() as u32) |
798 | 0 | .filter(|&i| in_degree[i as usize] == 0) |
799 | 0 | .collect(); |
800 | 0 | let mut topo_order = Vec::with_capacity(self.nodes.len()); |
801 | | |
802 | 0 | while let Some(u) = queue.pop() { |
803 | 0 | topo_order.push(u); |
804 | 0 | for (v, _) in &adj[u as usize] { |
805 | 0 | in_degree[*v as usize] -= 1; |
806 | 0 | if in_degree[*v as usize] == 0 { |
807 | 0 | queue.push(*v); |
808 | 0 | } |
809 | | } |
810 | | } |
811 | | |
812 | | // Longest path DP |
813 | 0 | let mut dist = vec![0u64; self.nodes.len()]; |
814 | 0 | let mut pred = vec![None::<u32>; self.nodes.len()]; |
815 | | |
816 | | // Initialize with node's own timing for roots |
817 | 0 | for &node in &topo_order { |
818 | 0 | if self.edges.iter().all(|e| e.dst.0 != node) { |
819 | 0 | dist[node as usize] = self.node_timing_ns(ExecutionNodeId(node)); |
820 | 0 | } |
821 | | } |
822 | | |
823 | 0 | for &u in &topo_order { |
824 | 0 | for (v, weight) in &adj[u as usize] { |
825 | 0 | let new_dist = dist[u as usize] + weight; |
826 | 0 | if new_dist > dist[*v as usize] { |
827 | 0 | dist[*v as usize] = new_dist; |
828 | 0 | pred[*v as usize] = Some(u); |
829 | 0 | } |
830 | | } |
831 | | } |
832 | | |
833 | | // Find endpoint with maximum distance |
834 | 0 | let (end_node, &total_time) = dist |
835 | 0 | .iter() |
836 | 0 | .enumerate() |
837 | 0 | .max_by_key(|(_, &d)| d) |
838 | 0 | .unwrap_or((0, &0)); |
839 | | |
840 | | // Reconstruct path |
841 | 0 | let mut path = vec![]; |
842 | 0 | let mut current = Some(end_node as u32); |
843 | 0 | while let Some(node) = current { |
844 | 0 | path.push(ExecutionNodeId(node)); |
845 | 0 | current = pred[node as usize]; |
846 | 0 | } |
847 | 0 | path.reverse(); |
848 | | |
849 | 0 | (path, total_time) |
850 | 0 | } |
851 | | |
852 | | /// Compute slack for each node (how much it can be delayed without affecting total time). |
853 | | /// |
854 | | /// Returns map from node ID to slack in nanoseconds. Nodes on critical path have slack = 0. |
855 | 0 | pub fn compute_slack(&self) -> HashMap<ExecutionNodeId, u64> { |
856 | 0 | let (critical_path, total_time) = self.critical_path(); |
857 | 0 | let critical_set: std::collections::HashSet<_> = critical_path.iter().copied().collect(); |
858 | | |
859 | 0 | let mut slack = HashMap::new(); |
860 | | |
861 | | // Build reverse adjacency |
862 | 0 | let mut reverse_adj: Vec<Vec<u32>> = vec![vec![]; self.nodes.len()]; |
863 | 0 | for edge in &self.edges { |
864 | 0 | reverse_adj[edge.dst.0 as usize].push(edge.src.0); |
865 | 0 | } |
866 | | |
867 | | // Forward pass: earliest start time |
868 | 0 | let mut earliest = vec![0u64; self.nodes.len()]; |
869 | 0 | for i in 0..self.nodes.len() { |
870 | 0 | let mut max_pred = 0u64; |
871 | 0 | for &pred in &reverse_adj[i] { |
872 | 0 | max_pred = |
873 | 0 | max_pred.max(earliest[pred as usize] + self.node_timing_ns(ExecutionNodeId(pred))); |
874 | 0 | } |
875 | 0 | earliest[i] = max_pred; |
876 | | } |
877 | | |
878 | | // Backward pass: latest start time |
879 | 0 | let mut latest = vec![total_time; self.nodes.len()]; |
880 | 0 | for i in (0..self.nodes.len()).rev() { |
881 | 0 | let timing = self.node_timing_ns(ExecutionNodeId(i as u32)); |
882 | 0 | let mut min_succ = total_time; |
883 | 0 | for edge in &self.edges { |
884 | 0 | if edge.src.0 == i as u32 { |
885 | 0 | min_succ = min_succ.min(latest[edge.dst.0 as usize]); |
886 | 0 | } |
887 | | } |
888 | 0 | latest[i] = min_succ.saturating_sub(timing); |
889 | | } |
890 | | |
891 | | // Slack = latest - earliest |
892 | 0 | for i in 0..self.nodes.len() { |
893 | 0 | let node_id = ExecutionNodeId(i as u32); |
894 | 0 | let node_slack = if critical_set.contains(&node_id) { |
895 | 0 | 0 |
896 | | } else { |
897 | 0 | latest[i].saturating_sub(earliest[i]) |
898 | | }; |
899 | 0 | slack.insert(node_id, node_slack); |
900 | | } |
901 | | |
902 | 0 | slack |
903 | 0 | } |
904 | | |
905 | | /// Compute roofline distance for kernel nodes. |
906 | | /// |
907 | | /// Returns map from kernel node ID to distance from roofline (0.0 = optimal). |
908 | | /// Distance = 1.0 - min(achieved/peak_compute, achieved/peak_bandwidth). |
909 | | /// |
910 | | /// Reference: Williams et al. (2009) "Roofline: An Insightful Visual Performance Model" |
911 | 0 | pub fn roofline_distance( |
912 | 0 | &self, |
913 | 0 | peak_tflops: f32, |
914 | 0 | peak_bandwidth_gb_s: f32, |
915 | 0 | ) -> HashMap<ExecutionNodeId, f32> { |
916 | 0 | let mut distances = HashMap::new(); |
917 | | |
918 | 0 | for (i, node) in self.nodes.iter().enumerate() { |
919 | | if let ExecutionNode::Kernel { |
920 | 0 | arithmetic_intensity, |
921 | 0 | achieved_tflops, |
922 | | .. |
923 | 0 | } = node |
924 | | { |
925 | 0 | if let (Some(ai), Some(achieved)) = (arithmetic_intensity, achieved_tflops) { |
926 | 0 | // Roofline model: achievable = min(peak_compute, ai * bandwidth) |
927 | 0 | let bandwidth_bound = *ai * peak_bandwidth_gb_s / 1000.0; // Convert GB/s to TFLOP/s |
928 | 0 | let roofline_bound = peak_tflops.min(bandwidth_bound); |
929 | 0 | let efficiency = achieved / roofline_bound; |
930 | 0 | let distance = 1.0 - efficiency.min(1.0); |
931 | 0 | distances.insert(ExecutionNodeId(i as u32), distance); |
932 | 0 | } |
933 | 0 | } |
934 | | } |
935 | | |
936 | 0 | distances |
937 | 0 | } |
938 | | |
939 | | /// Detect ping-pong memory transfer patterns (wasteful H2D followed by D2H). |
940 | | /// |
941 | | /// Returns pairs of transfer node IDs that exhibit ping-pong behavior. |
942 | 0 | pub fn detect_ping_pong(&self) -> Vec<(ExecutionNodeId, ExecutionNodeId)> { |
943 | 0 | let mut patterns = Vec::new(); |
944 | | |
945 | | // Find transfer nodes |
946 | 0 | let transfers: Vec<(usize, &ExecutionNode)> = self |
947 | 0 | .nodes |
948 | 0 | .iter() |
949 | 0 | .enumerate() |
950 | 0 | .filter(|(_, n)| matches!(n, ExecutionNode::Transfer { .. })) |
951 | 0 | .collect(); |
952 | | |
953 | | // Check for H2D followed by D2H on same data |
954 | 0 | for i in 0..transfers.len() { |
955 | 0 | for j in (i + 1)..transfers.len() { |
956 | | if let ( |
957 | | ExecutionNode::Transfer { |
958 | 0 | src: src1, |
959 | 0 | dst: dst1, |
960 | 0 | direction: dir1, |
961 | 0 | bytes: bytes1, |
962 | | .. |
963 | | }, |
964 | | ExecutionNode::Transfer { |
965 | 0 | src: src2, |
966 | 0 | dst: dst2, |
967 | 0 | direction: dir2, |
968 | 0 | bytes: bytes2, |
969 | | .. |
970 | | }, |
971 | 0 | ) = (&transfers[i].1, &transfers[j].1) |
972 | | { |
973 | | // Ping-pong: H2D then D2H with matching src/dst and same size |
974 | 0 | let is_ping_pong = (*dir1 == TransferDirection::H2D |
975 | 0 | && *dir2 == TransferDirection::D2H |
976 | 0 | && dst1 == src2 |
977 | 0 | && bytes1 == bytes2) |
978 | 0 | || (*dir1 == TransferDirection::D2H |
979 | 0 | && *dir2 == TransferDirection::H2D |
980 | 0 | && src1 == dst2 |
981 | 0 | && bytes1 == bytes2); |
982 | | |
983 | 0 | if is_ping_pong { |
984 | 0 | patterns.push(( |
985 | 0 | ExecutionNodeId(transfers[i].0 as u32), |
986 | 0 | ExecutionNodeId(transfers[j].0 as u32), |
987 | 0 | )); |
988 | 0 | } |
989 | 0 | } |
990 | | } |
991 | | } |
992 | | |
993 | 0 | patterns |
994 | 0 | } |
995 | | |
996 | | /// Get critical path analysis summary as formatted string. |
997 | 0 | pub fn critical_path_summary(&self) -> String { |
998 | 0 | let (path, total_ns) = self.critical_path(); |
999 | 0 | let slack = self.compute_slack(); |
1000 | | |
1001 | 0 | let mut output = String::new(); |
1002 | 0 | output.push_str(&format!( |
1003 | 0 | "Critical Path: {:.2}ms ({} nodes)\n", |
1004 | 0 | total_ns as f64 / 1_000_000.0, |
1005 | 0 | path.len() |
1006 | 0 | )); |
1007 | 0 | output.push_str("─".repeat(50).as_str()); |
1008 | 0 | output.push('\n'); |
1009 | | |
1010 | 0 | for (i, node_id) in path.iter().enumerate() { |
1011 | 0 | let node = &self.nodes[node_id.0 as usize]; |
1012 | 0 | let timing = self.node_timing_ns(*node_id); |
1013 | 0 | let node_name = match node { |
1014 | 0 | ExecutionNode::Layer { index } => format!("Layer {}", index), |
1015 | 0 | ExecutionNode::Brick { id, .. } => id.name().to_string(), |
1016 | 0 | ExecutionNode::Kernel { name, .. } => name.clone(), |
1017 | 0 | ExecutionNode::Function { name, .. } => name.clone(), |
1018 | | ExecutionNode::Transfer { |
1019 | 0 | direction, src, dst, .. |
1020 | | } => { |
1021 | 0 | format!("{:?} {} → {}", direction, src, dst) |
1022 | | } |
1023 | | ExecutionNode::AsyncTask { |
1024 | 0 | name, poll_count, .. |
1025 | | } => { |
1026 | 0 | format!("{} ({}polls)", name, poll_count) |
1027 | | } |
1028 | | }; |
1029 | | |
1030 | 0 | let prefix = if i == 0 { |
1031 | 0 | "┌" |
1032 | 0 | } else if i == path.len() - 1 { |
1033 | 0 | "└" |
1034 | | } else { |
1035 | 0 | "│" |
1036 | | }; |
1037 | 0 | output.push_str(&format!( |
1038 | 0 | "{} {} ({:.1}µs)\n", |
1039 | 0 | prefix, |
1040 | 0 | node_name, |
1041 | 0 | timing as f64 / 1000.0 |
1042 | 0 | )); |
1043 | | } |
1044 | | |
1045 | | // Show nodes with most slack (parallelization opportunities) |
1046 | 0 | let mut slack_vec: Vec<_> = slack.iter().collect(); |
1047 | 0 | slack_vec.sort_by(|a, b| b.1.cmp(a.1)); |
1048 | | |
1049 | 0 | if slack_vec.iter().any(|(_, &s)| s > 0) { |
1050 | 0 | output.push_str("\nParallelization Opportunities (high slack):\n"); |
1051 | 0 | for (node_id, &node_slack) in slack_vec.iter().take(5) { |
1052 | 0 | if node_slack > 0 { |
1053 | 0 | let node = &self.nodes[node_id.0 as usize]; |
1054 | 0 | let node_name = match node { |
1055 | 0 | ExecutionNode::Layer { index } => format!("Layer {}", index), |
1056 | 0 | ExecutionNode::Brick { id, .. } => id.name().to_string(), |
1057 | 0 | ExecutionNode::Kernel { name, .. } => name.clone(), |
1058 | 0 | ExecutionNode::Function { name, .. } => name.clone(), |
1059 | | ExecutionNode::Transfer { |
1060 | 0 | direction, src, dst, .. |
1061 | | } => { |
1062 | 0 | format!("{:?} {} → {}", direction, src, dst) |
1063 | | } |
1064 | | ExecutionNode::AsyncTask { |
1065 | 0 | name, poll_count, .. |
1066 | | } => { |
1067 | 0 | format!("{} ({}polls)", name, poll_count) |
1068 | | } |
1069 | | }; |
1070 | 0 | output.push_str(&format!( |
1071 | 0 | " {} slack={:.1}µs\n", |
1072 | 0 | node_name, |
1073 | 0 | node_slack as f64 / 1000.0 |
1074 | 0 | )); |
1075 | 0 | } |
1076 | | } |
1077 | 0 | } |
1078 | | |
1079 | 0 | output |
1080 | 0 | } |
1081 | | |
1082 | | /// Clear the graph. |
1083 | 0 | pub fn clear(&mut self) { |
1084 | 0 | self.nodes.clear(); |
1085 | 0 | self.edges.clear(); |
1086 | 0 | self.scope_stack.clear(); |
1087 | 0 | self.name_to_id.clear(); |
1088 | 0 | } |
1089 | | |
1090 | | /// Check if scope stack is balanced (empty). |
1091 | 0 | pub fn is_scope_balanced(&self) -> bool { |
1092 | 0 | self.scope_stack.is_empty() |
1093 | 0 | } |
1094 | | } |