Coverage Report

Created: 2026-01-23 22:55

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/trueno/src/brick/exec_graph.rs
Line
Count
Source
1
//! Execution Graph and Brick Profiling Types
2
//!
3
//! This module contains types for execution path tracking and profiling:
4
//!
5
//! - **PAR-073**: BrickSample, BrickBottleneck - foundational profiling primitives
6
//! - **PAR-200**: BrickId, BrickCategory, SyncMode - O(1) hot path brick identification
7
//! - **PAR-201**: ExecutionGraph, ExecutionNode, etc. - full execution hierarchy tracking
8
9
use std::collections::HashMap;
10
use std::fmt;
11
12
// ============================================================================
13
// BrickProfiler: FOUNDATIONAL Real-Time Per-Brick Timing (PAR-073)
14
// ============================================================================
15
16
/// Individual brick timing sample.
17
/// Pure Rust timing using `std::time::Instant`.
18
#[derive(Debug, Clone, Copy)]
19
pub struct BrickSample {
20
    /// Brick name hash (for fast lookup)
21
    pub brick_id: u64,
22
    /// Elapsed time in nanoseconds
23
    pub elapsed_ns: u64,
24
    /// Number of elements processed
25
    pub elements: u64,
26
}
27
28
/// Bottleneck classification for roofline analysis (PMAT-451)
29
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30
pub enum BrickBottleneck {
31
    /// Not classified
32
    #[default]
33
    Unknown,
34
    /// Limited by memory bandwidth
35
    Memory,
36
    /// Limited by compute throughput
37
    Compute,
38
}
39
40
impl fmt::Display for BrickBottleneck {
41
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42
0
        match self {
43
0
            BrickBottleneck::Unknown => write!(f, "unknown"),
44
0
            BrickBottleneck::Memory => write!(f, "memory"),
45
0
            BrickBottleneck::Compute => write!(f, "compute"),
46
        }
47
0
    }
48
}
49
50
// ============================================================================
51
// PAR-200: BrickProfiler v2 - O(1) Hot Path with BrickId Enum
52
// ============================================================================
53
54
/// Well-known brick types for O(1) lookup on hot path.
55
///
56
/// PAR-200: Eliminates string allocation and HashMap hashing during profiling.
57
/// Use `BrickId::Custom` with string fallback for unknown brick types.
58
///
59
/// # Example
60
/// ```rust
61
/// use trueno::brick::BrickId;
62
///
63
/// let brick = BrickId::RmsNorm;
64
/// assert_eq!(brick.category(), trueno::brick::BrickCategory::Norm);
65
/// assert_eq!(brick.name(), "RmsNorm");
66
/// ```
67
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68
#[repr(u8)]
69
pub enum BrickId {
70
    // Normalization (0-1)
71
    /// RMS normalization layer
72
    RmsNorm = 0,
73
    /// Layer normalization
74
    LayerNorm = 1,
75
76
    // Attention (2-7)
77
    /// Q/K/V projection (combined or separate)
78
    QkvProjection = 2,
79
    /// Rotary position embedding
80
    RopeEmbedding = 3,
81
    /// Attention score computation (Q @ K^T)
82
    AttentionScore = 4,
83
    /// Attention softmax
84
    AttentionSoftmax = 5,
85
    /// Attention output (scores @ V)
86
    AttentionOutput = 6,
87
    /// Output projection after attention
88
    OutputProjection = 7,
89
90
    // FFN (8-11)
91
    /// Gate projection (for gated FFN)
92
    GateProjection = 8,
93
    /// Up projection
94
    UpProjection = 9,
95
    /// SiLU/GELU/ReLU activation
96
    Activation = 10,
97
    /// Down projection
98
    DownProjection = 11,
99
100
    // Other (12-14)
101
    /// Token embedding lookup
102
    Embedding = 12,
103
    /// Language model head (logits)
104
    LmHead = 13,
105
    /// Token sampling
106
    Sampling = 14,
107
}
108
109
impl BrickId {
110
    /// Number of well-known brick types.
111
    pub const COUNT: usize = 15;
112
113
    /// Get the category for hierarchical aggregation.
114
    #[inline]
115
0
    pub fn category(self) -> BrickCategory {
116
0
        match self {
117
0
            Self::RmsNorm | Self::LayerNorm => BrickCategory::Norm,
118
            Self::QkvProjection
119
            | Self::RopeEmbedding
120
            | Self::AttentionScore
121
            | Self::AttentionSoftmax
122
            | Self::AttentionOutput
123
0
            | Self::OutputProjection => BrickCategory::Attention,
124
            Self::GateProjection | Self::UpProjection | Self::Activation | Self::DownProjection => {
125
0
                BrickCategory::Ffn
126
            }
127
0
            Self::Embedding | Self::LmHead | Self::Sampling => BrickCategory::Other,
128
        }
129
0
    }
130
131
    /// Get the string name of this brick.
132
    #[inline]
133
0
    pub const fn name(self) -> &'static str {
134
0
        match self {
135
0
            Self::RmsNorm => "RmsNorm",
136
0
            Self::LayerNorm => "LayerNorm",
137
0
            Self::QkvProjection => "QkvProjection",
138
0
            Self::RopeEmbedding => "RopeEmbedding",
139
0
            Self::AttentionScore => "AttentionScore",
140
0
            Self::AttentionSoftmax => "AttentionSoftmax",
141
0
            Self::AttentionOutput => "AttentionOutput",
142
0
            Self::OutputProjection => "OutputProjection",
143
0
            Self::GateProjection => "GateProjection",
144
0
            Self::UpProjection => "UpProjection",
145
0
            Self::Activation => "Activation",
146
0
            Self::DownProjection => "DownProjection",
147
0
            Self::Embedding => "Embedding",
148
0
            Self::LmHead => "LmHead",
149
0
            Self::Sampling => "Sampling",
150
        }
151
0
    }
152
153
    /// Try to parse a string into a BrickId.
154
    #[allow(clippy::should_implement_trait)]
155
0
    pub fn from_str(s: &str) -> Option<Self> {
156
0
        match s {
157
0
            "RmsNorm" => Some(Self::RmsNorm),
158
0
            "LayerNorm" => Some(Self::LayerNorm),
159
0
            "QkvProjection" | "Qkv" => Some(Self::QkvProjection),
160
0
            "RopeEmbedding" | "Rope" | "RoPE" => Some(Self::RopeEmbedding),
161
0
            "AttentionScore" => Some(Self::AttentionScore),
162
0
            "AttentionSoftmax" | "Softmax" => Some(Self::AttentionSoftmax),
163
0
            "AttentionOutput" => Some(Self::AttentionOutput),
164
0
            "OutputProjection" | "OutProj" => Some(Self::OutputProjection),
165
0
            "GateProjection" | "Gate" => Some(Self::GateProjection),
166
0
            "UpProjection" | "Up" => Some(Self::UpProjection),
167
0
            "Activation" | "SiLU" | "GELU" | "ReLU" => Some(Self::Activation),
168
0
            "DownProjection" | "Down" => Some(Self::DownProjection),
169
0
            "Embedding" | "Embed" => Some(Self::Embedding),
170
0
            "LmHead" | "Head" => Some(Self::LmHead),
171
0
            "Sampling" | "Sample" => Some(Self::Sampling),
172
0
            _ => None,
173
        }
174
0
    }
175
}
176
177
impl fmt::Display for BrickId {
178
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179
0
        write!(f, "{}", self.name())
180
0
    }
181
}
182
183
/// Category for hierarchical aggregation of brick statistics.
184
///
185
/// PAR-200: Groups related bricks for high-level performance analysis.
186
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
187
#[repr(u8)]
188
pub enum BrickCategory {
189
    /// Normalization layers (RmsNorm, LayerNorm)
190
    Norm = 0,
191
    /// Attention mechanism (QKV, RoPE, scores, softmax, output)
192
    Attention = 1,
193
    /// Feed-forward network (gate, up, activation, down)
194
    Ffn = 2,
195
    /// Other operations (embedding, lm_head, sampling)
196
    #[default]
197
    Other = 3,
198
}
199
200
impl BrickCategory {
201
    /// Number of categories.
202
    pub const COUNT: usize = 4;
203
204
    /// Get the string name of this category.
205
    #[inline]
206
0
    pub const fn name(self) -> &'static str {
207
0
        match self {
208
0
            Self::Norm => "Norm",
209
0
            Self::Attention => "Attention",
210
0
            Self::Ffn => "FFN",
211
0
            Self::Other => "Other",
212
        }
213
0
    }
214
}
215
216
impl fmt::Display for BrickCategory {
217
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218
0
        write!(f, "{}", self.name())
219
0
    }
220
}
221
222
/// Synchronization mode for GPU profiling.
223
///
224
/// PAR-200: Controls the trade-off between accuracy and overhead.
225
///
226
/// # Performance Characteristics
227
///
228
/// | Mode | Overhead | Accuracy | Use Case |
229
/// |------|----------|----------|----------|
230
/// | `Immediate` | ~200% | Exact per-kernel | Debugging |
231
/// | `PerLayer` | ~20% | Per-layer exact | Development |
232
/// | `Deferred` | ~5% | Approximate | Production |
233
/// | `None` | 0% | N/A | Disabled |
234
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235
pub enum SyncMode {
236
    /// Sync after each kernel (accurate but slow).
237
    /// Best for debugging and detailed optimization.
238
    Immediate,
239
    /// Sync once per transformer layer.
240
    /// Good balance for development.
241
    PerLayer,
242
    /// Sync once per forward pass (fast, approximate).
243
    /// Best for production profiling.
244
    #[default]
245
    Deferred,
246
    /// No synchronization (profiling disabled or CPU-only).
247
    None,
248
}
249
250
// ============================================================================
251
// PAR-201: Execution Path Graph Types
252
// ============================================================================
253
254
/// Node ID in the execution graph.
255
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
256
pub struct ExecutionNodeId(pub u32);
257
258
/// Execution graph node types.
259
///
260
/// PAR-201: Represents different levels of the execution hierarchy.
261
#[derive(Debug, Clone)]
262
pub enum ExecutionNode {
263
    /// High-level brick (BrickId from v2)
264
    Brick {
265
        id: BrickId,
266
        timing_ns: u64,
267
        elements: u64,
268
    },
269
    /// GPU kernel launch
270
    Kernel {
271
        name: String,
272
        /// FNV-1a hash of PTX source for identity
273
        ptx_hash: u64,
274
        /// Grid dimensions (blocks)
275
        grid: (u32, u32, u32),
276
        /// Block dimensions (threads)
277
        block: (u32, u32, u32),
278
        /// Shared memory bytes
279
        shared_mem: u32,
280
        /// Kernel execution time in nanoseconds (Phase 9: for CPA)
281
        timing_ns: Option<u64>,
282
        /// Arithmetic intensity (FLOPs/byte) for roofline analysis (Phase 9)
283
        arithmetic_intensity: Option<f32>,
284
        /// Achieved throughput in TFLOP/s (Phase 9)
285
        achieved_tflops: Option<f32>,
286
    },
287
    /// Memory transfer operation (Phase 9: data movement topology)
288
    Transfer {
289
        /// Source location description
290
        src: String,
291
        /// Destination location description
292
        dst: String,
293
        /// Bytes transferred
294
        bytes: u64,
295
        /// Transfer direction
296
        direction: TransferDirection,
297
        /// Transfer time in nanoseconds
298
        timing_ns: Option<u64>,
299
    },
300
    /// Rust function (from DWARF or manual annotation)
301
    Function {
302
        name: String,
303
        file: Option<String>,
304
        line: Option<u32>,
305
    },
306
    /// Transformer layer grouping
307
    Layer { index: u32 },
308
    /// Phase 11 (E.9.4): Async task metrics for poll efficiency tracking
309
    AsyncTask {
310
        /// Task name for identification
311
        name: String,
312
        /// Number of times poll() was called
313
        poll_count: u64,
314
        /// Number of times poll() returned Pending
315
        yield_count: u64,
316
        /// Total time spent in poll() (nanoseconds)
317
        total_poll_ns: u64,
318
    },
319
}
320
321
impl ExecutionNode {
322
    /// Get the display name of this node.
323
0
    pub fn name(&self) -> String {
324
0
        match self {
325
0
            Self::Brick { id, .. } => id.name().to_string(),
326
0
            Self::Kernel { name, .. } => name.clone(),
327
0
            Self::Function { name, .. } => name.clone(),
328
0
            Self::Layer { index } => format!("Layer{}", index),
329
            Self::Transfer {
330
0
                src,
331
0
                dst,
332
0
                direction,
333
                ..
334
            } => {
335
0
                let dir = match direction {
336
0
                    TransferDirection::H2D => "H2D",
337
0
                    TransferDirection::D2H => "D2H",
338
0
                    TransferDirection::D2D => "D2D",
339
                };
340
0
                format!("{}:{}->{}", dir, src, dst)
341
            }
342
0
            Self::AsyncTask { name, .. } => name.clone(),
343
        }
344
0
    }
345
346
    /// Check if this is a kernel node.
347
0
    pub fn is_kernel(&self) -> bool {
348
0
        matches!(self, Self::Kernel { .. })
349
0
    }
350
351
    /// Check if this is a brick node.
352
0
    pub fn is_brick(&self) -> bool {
353
0
        matches!(self, Self::Brick { .. })
354
0
    }
355
356
    /// Check if this is a transfer node.
357
0
    pub fn is_transfer(&self) -> bool {
358
0
        matches!(self, Self::Transfer { .. })
359
0
    }
360
361
    /// Get timing if available (bricks, kernels, and transfers).
362
0
    pub fn timing_ns(&self) -> Option<u64> {
363
0
        match self {
364
0
            Self::Brick { timing_ns, .. } => Some(*timing_ns),
365
0
            Self::Kernel { timing_ns, .. } => *timing_ns,
366
0
            Self::Transfer { timing_ns, .. } => *timing_ns,
367
0
            _ => None,
368
        }
369
0
    }
370
371
    /// Get PTX hash if available (kernels only).
372
0
    pub fn ptx_hash(&self) -> Option<u64> {
373
0
        match self {
374
0
            Self::Kernel { ptx_hash, .. } => Some(*ptx_hash),
375
0
            _ => None,
376
        }
377
0
    }
378
379
    /// Get arithmetic intensity if available (kernels only, Phase 9).
380
0
    pub fn arithmetic_intensity(&self) -> Option<f32> {
381
0
        match self {
382
            Self::Kernel {
383
0
                arithmetic_intensity,
384
                ..
385
0
            } => *arithmetic_intensity,
386
0
            _ => None,
387
        }
388
0
    }
389
390
    /// Get achieved TFLOP/s if available (kernels only, Phase 9).
391
0
    pub fn achieved_tflops(&self) -> Option<f32> {
392
0
        match self {
393
            Self::Kernel {
394
0
                achieved_tflops, ..
395
0
            } => *achieved_tflops,
396
0
            _ => None,
397
        }
398
0
    }
399
400
    /// Get transfer bytes if available (transfers only, Phase 9).
401
0
    pub fn transfer_bytes(&self) -> Option<u64> {
402
0
        match self {
403
0
            Self::Transfer { bytes, .. } => Some(*bytes),
404
0
            _ => None,
405
        }
406
0
    }
407
}
408
409
/// Edge types in execution graph.
410
///
411
/// PAR-201: Describes relationships between execution nodes.
412
/// Phase 9 (E.7.12): Added DependsOn and Transfer for advanced profiling.
413
#[derive(Debug, Clone, PartialEq)]
414
pub enum EdgeType {
415
    /// Function calls function
416
    Calls,
417
    /// Brick contains sub-operations
418
    Contains,
419
    /// Function launches GPU kernel
420
    Launches,
421
    /// Temporal sequence (A happens before B)
422
    Sequence,
423
    /// Dependency edge for critical path analysis (CUDA events, stream sync)
424
    /// PAR-201 Phase 9: CPA requires tracking true dependencies vs containment
425
    DependsOn,
426
    /// Data transfer edge with byte count (H2D/D2H/D2D)
427
    /// PAR-201 Phase 9: For data movement topology and ping-pong detection
428
    Transfer {
429
        /// Bytes transferred
430
        bytes: u64,
431
        /// Transfer direction
432
        direction: TransferDirection,
433
    },
434
}
435
436
/// Direction of memory transfer.
437
///
438
/// PAR-201 Phase 9: Used with EdgeType::Transfer for data movement analysis.
439
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440
pub enum TransferDirection {
441
    /// Host to Device
442
    H2D,
443
    /// Device to Host
444
    D2H,
445
    /// Device to Device
446
    D2D,
447
}
448
449
/// An edge in the execution graph.
450
#[derive(Debug, Clone)]
451
pub struct ExecutionEdge {
452
    /// Source node ID
453
    pub src: ExecutionNodeId,
454
    /// Destination node ID
455
    pub dst: ExecutionNodeId,
456
    /// Edge type
457
    pub edge_type: EdgeType,
458
    /// Optional weight (e.g., call count, timing)
459
    pub weight: f32,
460
}
461
462
/// Execution path graph for tracking brick → kernel → PTX relationships.
463
///
464
/// PAR-201: Captures the full execution hierarchy for profiling analysis.
465
///
466
/// # Example
467
///
468
/// ```rust,ignore
469
/// use trueno::brick::{ExecutionGraph, ExecutionNode, EdgeType};
470
///
471
/// let mut graph = ExecutionGraph::new();
472
///
473
/// // Add layer scope
474
/// let layer_id = graph.add_node(ExecutionNode::Layer { index: 0 });
475
///
476
/// // Add brick within layer
477
/// let brick_id = graph.add_node(ExecutionNode::Brick {
478
///     id: BrickId::QkvProjection,
479
///     timing_ns: 1000,
480
///     elements: 4096,
481
/// });
482
/// graph.add_edge(layer_id, brick_id, EdgeType::Contains);
483
///
484
/// // Add kernel launched by brick
485
/// let kernel_id = graph.add_node(ExecutionNode::Kernel {
486
///     name: "batched_q4k_gemv".into(),
487
///     ptx_hash: 0x7a3b1c2d,
488
///     grid: (32, 1, 1),
489
///     block: (256, 1, 1),
490
///     shared_mem: 4096,
491
/// });
492
/// graph.add_edge(brick_id, kernel_id, EdgeType::Launches);
493
///
494
/// // Export to trueno-graph for analysis
495
/// #[cfg(feature = "execution-graph")]
496
/// let csr = graph.to_csr();
497
/// ```
498
#[derive(Debug, Default)]
499
pub struct ExecutionGraph {
500
    /// All nodes in the graph
501
    nodes: Vec<ExecutionNode>,
502
    /// All edges in the graph
503
    edges: Vec<ExecutionEdge>,
504
    /// Scope stack for hierarchical recording
505
    scope_stack: Vec<ExecutionNodeId>,
506
    /// Node name → ID mapping for fast lookup
507
    name_to_id: HashMap<String, ExecutionNodeId>,
508
}
509
510
impl ExecutionGraph {
511
    /// Create a new empty execution graph.
512
0
    pub fn new() -> Self {
513
0
        Self::default()
514
0
    }
515
516
    /// Add a node to the graph, returning its ID.
517
0
    pub fn add_node(&mut self, node: ExecutionNode) -> ExecutionNodeId {
518
0
        let id = ExecutionNodeId(self.nodes.len() as u32);
519
0
        let name = node.name();
520
0
        self.name_to_id.insert(name, id);
521
0
        self.nodes.push(node);
522
0
        id
523
0
    }
524
525
    /// Add an edge between two nodes.
526
0
    pub fn add_edge(&mut self, src: ExecutionNodeId, dst: ExecutionNodeId, edge_type: EdgeType) {
527
0
        self.edges.push(ExecutionEdge {
528
0
            src,
529
0
            dst,
530
0
            edge_type,
531
0
            weight: 1.0,
532
0
        });
533
0
    }
534
535
    /// Add an edge with a weight.
536
0
    pub fn add_weighted_edge(
537
0
        &mut self,
538
0
        src: ExecutionNodeId,
539
0
        dst: ExecutionNodeId,
540
0
        edge_type: EdgeType,
541
0
        weight: f32,
542
0
    ) {
543
0
        self.edges.push(ExecutionEdge {
544
0
            src,
545
0
            dst,
546
0
            edge_type,
547
0
            weight,
548
0
        });
549
0
    }
550
551
    /// Push a scope for hierarchical recording.
552
    /// All subsequent nodes will be children of this scope.
553
0
    pub fn push_scope(&mut self, node: ExecutionNode) -> ExecutionNodeId {
554
0
        let id = self.add_node(node);
555
0
        if let Some(&parent) = self.scope_stack.last() {
556
0
            self.add_edge(parent, id, EdgeType::Contains);
557
0
        }
558
0
        self.scope_stack.push(id);
559
0
        id
560
0
    }
561
562
    /// Pop the current scope.
563
0
    pub fn pop_scope(&mut self) -> Option<ExecutionNodeId> {
564
0
        self.scope_stack.pop()
565
0
    }
566
567
    /// Get the current scope (if any).
568
0
    pub fn current_scope(&self) -> Option<ExecutionNodeId> {
569
0
        self.scope_stack.last().copied()
570
0
    }
571
572
    /// Add a node under the current scope.
573
0
    pub fn add_node_in_scope(&mut self, node: ExecutionNode) -> ExecutionNodeId {
574
0
        let id = self.add_node(node);
575
0
        if let Some(&parent) = self.scope_stack.last() {
576
0
            self.add_edge(parent, id, EdgeType::Contains);
577
0
        }
578
0
        id
579
0
    }
580
581
    /// Record a kernel launch under the current scope.
582
0
    pub fn record_kernel_launch(
583
0
        &mut self,
584
0
        name: &str,
585
0
        ptx_hash: u64,
586
0
        grid: (u32, u32, u32),
587
0
        block: (u32, u32, u32),
588
0
        shared_mem: u32,
589
0
    ) -> ExecutionNodeId {
590
0
        let kernel = ExecutionNode::Kernel {
591
0
            name: name.to_string(),
592
0
            ptx_hash,
593
0
            grid,
594
0
            block,
595
0
            shared_mem,
596
0
            timing_ns: None,
597
0
            arithmetic_intensity: None,
598
0
            achieved_tflops: None,
599
0
        };
600
0
        let kernel_id = self.add_node(kernel);
601
602
        // Link from current scope with Launches edge
603
0
        if let Some(&parent) = self.scope_stack.last() {
604
0
            self.add_edge(parent, kernel_id, EdgeType::Launches);
605
0
        }
606
607
0
        kernel_id
608
0
    }
609
610
    /// Record a kernel launch with roofline metrics (Phase 9).
611
    #[allow(clippy::too_many_arguments)]
612
0
    pub fn record_kernel_launch_with_metrics(
613
0
        &mut self,
614
0
        name: &str,
615
0
        ptx_hash: u64,
616
0
        grid: (u32, u32, u32),
617
0
        block: (u32, u32, u32),
618
0
        shared_mem: u32,
619
0
        timing_ns: u64,
620
0
        arithmetic_intensity: f32,
621
0
        achieved_tflops: f32,
622
0
    ) -> ExecutionNodeId {
623
0
        let kernel = ExecutionNode::Kernel {
624
0
            name: name.to_string(),
625
0
            ptx_hash,
626
0
            grid,
627
0
            block,
628
0
            shared_mem,
629
0
            timing_ns: Some(timing_ns),
630
0
            arithmetic_intensity: Some(arithmetic_intensity),
631
0
            achieved_tflops: Some(achieved_tflops),
632
0
        };
633
0
        let kernel_id = self.add_node(kernel);
634
635
0
        if let Some(&parent) = self.scope_stack.last() {
636
0
            self.add_edge(parent, kernel_id, EdgeType::Launches);
637
0
        }
638
639
0
        kernel_id
640
0
    }
641
642
    /// Record a memory transfer (Phase 9: data movement topology).
643
0
    pub fn record_transfer(
644
0
        &mut self,
645
0
        src: &str,
646
0
        dst: &str,
647
0
        bytes: u64,
648
0
        direction: TransferDirection,
649
0
        timing_ns: Option<u64>,
650
0
    ) -> ExecutionNodeId {
651
0
        let transfer = ExecutionNode::Transfer {
652
0
            src: src.to_string(),
653
0
            dst: dst.to_string(),
654
0
            bytes,
655
0
            direction,
656
0
            timing_ns,
657
0
        };
658
0
        let transfer_id = self.add_node(transfer);
659
660
0
        if let Some(&parent) = self.scope_stack.last() {
661
0
            self.add_edge(parent, transfer_id, EdgeType::Contains);
662
0
        }
663
664
0
        transfer_id
665
0
    }
666
667
    /// Add a dependency edge for critical path analysis (Phase 9).
668
0
    pub fn add_dependency(&mut self, from: ExecutionNodeId, to: ExecutionNodeId) {
669
0
        self.add_edge(from, to, EdgeType::DependsOn);
670
0
    }
671
672
    /// Get a node by ID.
673
0
    pub fn node(&self, id: ExecutionNodeId) -> Option<&ExecutionNode> {
674
0
        self.nodes.get(id.0 as usize)
675
0
    }
676
677
    /// Get a node by name.
678
0
    pub fn node_by_name(&self, name: &str) -> Option<(ExecutionNodeId, &ExecutionNode)> {
679
0
        self.name_to_id
680
0
            .get(name)
681
0
            .and_then(|&id| self.nodes.get(id.0 as usize).map(|n| (id, n)))
682
0
    }
683
684
    /// Get all nodes.
685
0
    pub fn nodes(&self) -> &[ExecutionNode] {
686
0
        &self.nodes
687
0
    }
688
689
    /// Get all edges.
690
0
    pub fn edges(&self) -> &[ExecutionEdge] {
691
0
        &self.edges
692
0
    }
693
694
    /// Number of nodes.
695
0
    pub fn num_nodes(&self) -> usize {
696
0
        self.nodes.len()
697
0
    }
698
699
    /// Number of edges.
700
0
    pub fn num_edges(&self) -> usize {
701
0
        self.edges.len()
702
0
    }
703
704
    /// Get outgoing edges for a node.
705
0
    pub fn outgoing_edges(&self, node: ExecutionNodeId) -> impl Iterator<Item = &ExecutionEdge> {
706
0
        self.edges.iter().filter(move |e| e.src == node)
707
0
    }
708
709
    /// Get incoming edges for a node.
710
0
    pub fn incoming_edges(&self, node: ExecutionNodeId) -> impl Iterator<Item = &ExecutionEdge> {
711
0
        self.edges.iter().filter(move |e| e.dst == node)
712
0
    }
713
714
    /// Find all kernel nodes.
715
0
    pub fn kernel_nodes(&self) -> impl Iterator<Item = (ExecutionNodeId, &ExecutionNode)> {
716
0
        self.nodes
717
0
            .iter()
718
0
            .enumerate()
719
0
            .filter(|(_, n)| n.is_kernel())
720
0
            .map(|(i, n)| (ExecutionNodeId(i as u32), n))
721
0
    }
722
723
    /// Find the slowest kernel (by parent brick timing).
724
0
    pub fn slowest_kernel(&self) -> Option<(ExecutionNodeId, &ExecutionNode, u64)> {
725
0
        let mut slowest: Option<(ExecutionNodeId, &ExecutionNode, u64)> = None;
726
727
0
        for (id, node) in self.nodes.iter().enumerate() {
728
0
            if let ExecutionNode::Brick { timing_ns, .. } = node {
729
                // Check if this brick has kernel children
730
0
                let node_id = ExecutionNodeId(id as u32);
731
0
                let has_kernel = self
732
0
                    .outgoing_edges(node_id)
733
0
                    .any(|e| e.edge_type == EdgeType::Launches);
734
735
0
                if has_kernel {
736
0
                    match &slowest {
737
0
                        None => slowest = Some((node_id, node, *timing_ns)),
738
0
                        Some((_, _, t)) if *timing_ns > *t => {
739
0
                            slowest = Some((node_id, node, *timing_ns))
740
                        }
741
0
                        _ => {}
742
                    }
743
0
                }
744
0
            }
745
        }
746
747
0
        slowest
748
0
    }
749
750
    /// Export to DOT format for Graphviz visualization.
751
0
    pub fn to_dot(&self) -> String {
752
0
        let mut dot = String::from("digraph ExecutionGraph {\n");
753
0
        dot.push_str("  rankdir=TB;\n");
754
0
        dot.push_str("  node [shape=box];\n\n");
755
756
        // Add nodes with styling based on type
757
0
        for (i, node) in self.nodes.iter().enumerate() {
758
0
            let (label, style) = match node {
759
0
                ExecutionNode::Layer { index } => {
760
0
                    (format!("Layer {}", index), "style=filled,fillcolor=lightblue")
761
                }
762
0
                ExecutionNode::Brick { id, timing_ns, .. } => (
763
0
                    format!("{}\\n{:.1}µs", id.name(), *timing_ns as f64 / 1000.0),
764
0
                    "style=filled,fillcolor=lightgreen",
765
0
                ),
766
                ExecutionNode::Kernel {
767
0
                    name, grid, block, ..
768
0
                } => (
769
0
                    format!("{}\\n<<<{},{},{}>>>", name, grid.0, block.0, block.1),
770
0
                    "style=filled,fillcolor=lightyellow",
771
0
                ),
772
0
                ExecutionNode::Function { name, file, line } => {
773
0
                    let loc = match (file, line) {
774
0
                        (Some(f), Some(l)) => format!("\\n{}:{}", f, l),
775
0
                        _ => String::new(),
776
                    };
777
0
                    (
778
0
                        format!("{}{}", name, loc),
779
0
                        "style=filled,fillcolor=lightgray",
780
0
                    )
781
                }
782
                ExecutionNode::Transfer {
783
0
                    src,
784
0
                    dst,
785
0
                    bytes,
786
0
                    direction,
787
                    ..
788
                } => {
789
0
                    let dir = match direction {
790
0
                        TransferDirection::H2D => "H2D",
791
0
                        TransferDirection::D2H => "D2H",
792
0
                        TransferDirection::D2D => "D2D",
793
                    };
794
0
                    (
795
0
                        format!("{}\\n{}->{}\\n{:.1}MB", dir, src, dst, *bytes as f64 / 1e6),
796
0
                        "style=filled,fillcolor=lightsalmon",
797
0
                    )
798
                }
799
                ExecutionNode::AsyncTask {
800
0
                    name,
801
0
                    poll_count,
802
0
                    yield_count,
803
0
                    total_poll_ns,
804
                } => {
805
0
                    let efficiency = if *poll_count > 0 {
806
0
                        100.0 / *poll_count as f64
807
                    } else {
808
0
                        0.0
809
                    };
810
0
                    (
811
0
                        format!(
812
0
                            "{}\\npolls:{} yields:{}\\n{:.1}µs ({:.0}%)",
813
0
                            name,
814
0
                            poll_count,
815
0
                            yield_count,
816
0
                            *total_poll_ns as f64 / 1000.0,
817
0
                            efficiency
818
0
                        ),
819
0
                        "style=filled,fillcolor=lightcyan",
820
0
                    )
821
                }
822
            };
823
0
            dot.push_str(&format!("  n{} [label=\"{}\",{}];\n", i, label, style));
824
        }
825
826
0
        dot.push('\n');
827
828
        // Add edges with styling based on type
829
0
        for edge in &self.edges {
830
0
            let style = match edge.edge_type {
831
0
                EdgeType::Calls => "style=solid",
832
0
                EdgeType::Contains => "style=dashed",
833
0
                EdgeType::Launches => "style=bold,color=red",
834
0
                EdgeType::Sequence => "style=dotted",
835
0
                EdgeType::DependsOn => "style=solid,color=blue",
836
0
                EdgeType::Transfer { .. } => "style=bold,color=orange",
837
            };
838
0
            dot.push_str(&format!(
839
0
                "  n{} -> n{} [{}];\n",
840
0
                edge.src.0, edge.dst.0, style
841
0
            ));
842
        }
843
844
0
        dot.push_str("}\n");
845
0
        dot
846
0
    }
847
848
    /// Export to trueno-graph CsrGraph format.
849
    #[cfg(feature = "execution-graph")]
850
    pub fn to_csr(&self) -> trueno_graph::CsrGraph {
851
        use trueno_graph::{CsrGraph, NodeId};
852
853
        let edges: Vec<(NodeId, NodeId, f32)> = self
854
            .edges
855
            .iter()
856
            .map(|e| (NodeId(e.src.0), NodeId(e.dst.0), e.weight))
857
            .collect();
858
859
        let mut graph = CsrGraph::from_edge_list(&edges).unwrap_or_default();
860
861
        // Set node names for querying
862
        for (i, node) in self.nodes.iter().enumerate() {
863
            graph.set_node_name(NodeId(i as u32), node.name());
864
        }
865
866
        graph
867
    }
868
869
    /// Convert to presentar-terminal TreeNode for TUI visualization.
870
    ///
871
    /// PAR-201: Renders the execution graph as a collapsible tree in the terminal.
872
    #[cfg(feature = "presentar-tui")]
873
    pub fn to_tree_node(&self) -> presentar_terminal::TreeNode {
874
        use presentar_terminal::{Color, TreeNode};
875
876
        // Color scheme for node types
877
        let layer_color = Color::new(0.4, 0.6, 1.0, 1.0); // Light blue
878
        let brick_color = Color::new(0.4, 0.8, 0.4, 1.0); // Light green
879
        let kernel_color = Color::new(1.0, 0.8, 0.3, 1.0); // Yellow/orange
880
        let func_color = Color::new(0.7, 0.7, 0.7, 1.0); // Light gray
881
882
        // Build child map: parent -> [children]
883
        let mut children_map: HashMap<u32, Vec<u32>> = HashMap::new();
884
        let mut has_parent: std::collections::HashSet<u32> = std::collections::HashSet::new();
885
886
        for edge in &self.edges {
887
            if edge.edge_type == EdgeType::Contains || edge.edge_type == EdgeType::Launches {
888
                children_map
889
                    .entry(edge.src.0)
890
                    .or_default()
891
                    .push(edge.dst.0);
892
                has_parent.insert(edge.dst.0);
893
            }
894
        }
895
896
        // Find root nodes (nodes with no parent)
897
        let root_ids: Vec<u32> = (0..self.nodes.len() as u32)
898
            .filter(|id| !has_parent.contains(id))
899
            .collect();
900
901
        // Recursive function to build TreeNode
902
        fn build_node(
903
            graph: &ExecutionGraph,
904
            id: u32,
905
            children_map: &HashMap<u32, Vec<u32>>,
906
            layer_color: Color,
907
            brick_color: Color,
908
            kernel_color: Color,
909
            func_color: Color,
910
        ) -> TreeNode {
911
            let node = &graph.nodes[id as usize];
912
            let (label, info, color) = match node {
913
                ExecutionNode::Layer { index } => {
914
                    (format!("Layer {}", index), None, layer_color)
915
                }
916
                ExecutionNode::Brick {
917
                    id: brick_id,
918
                    timing_ns,
919
                    elements,
920
                } => (
921
                    brick_id.name().to_string(),
922
                    Some(format!(
923
                        "{:.1}µs ({} elem)",
924
                        *timing_ns as f64 / 1000.0,
925
                        elements
926
                    )),
927
                    brick_color,
928
                ),
929
                ExecutionNode::Kernel {
930
                    name,
931
                    grid,
932
                    block,
933
                    shared_mem,
934
                    ..
935
                } => (
936
                    name.clone(),
937
                    Some(format!(
938
                        "<<<{},{},{}>>> smem={}B",
939
                        grid.0, block.0, block.1, shared_mem
940
                    )),
941
                    kernel_color,
942
                ),
943
                ExecutionNode::Function { name, file, line } => {
944
                    let loc = match (file, line) {
945
                        (Some(f), Some(l)) => format!(" ({}:{})", f, l),
946
                        _ => String::new(),
947
                    };
948
                    (format!("{}{}", name, loc), None, func_color)
949
                }
950
                ExecutionNode::Transfer {
951
                    src,
952
                    dst,
953
                    bytes,
954
                    direction,
955
                    timing_ns,
956
                } => {
957
                    let timing_str = timing_ns
958
                        .map(|ns| format!(" {:.1}µs", ns as f64 / 1000.0))
959
                        .unwrap_or_default();
960
                    (
961
                        format!("{:?}: {} → {}", direction, src, dst),
962
                        Some(format!("{}B{}", bytes, timing_str)),
963
                        Color::Magenta, // Transfer color
964
                    )
965
                }
966
                ExecutionNode::AsyncTask {
967
                    name,
968
                    poll_count,
969
                    yield_count,
970
                    total_poll_ns,
971
                } => {
972
                    let efficiency = if *poll_count > 0 {
973
                        100.0 / *poll_count as f64
974
                    } else {
975
                        0.0
976
                    };
977
                    (
978
                        name.clone(),
979
                        Some(format!(
980
                            "polls:{} yields:{} {:.1}µs ({:.0}% eff)",
981
                            poll_count,
982
                            yield_count,
983
                            *total_poll_ns as f64 / 1000.0,
984
                            efficiency
985
                        )),
986
                        Color::Cyan, // Async task color
987
                    )
988
                }
989
            };
990
991
            let mut tree_node = TreeNode::new(id as u64, label).with_color(color);
992
            if let Some(info_str) = info {
993
                tree_node = tree_node.with_info(info_str);
994
            }
995
996
            // Add children
997
            if let Some(child_ids) = children_map.get(&id) {
998
                for &child_id in child_ids {
999
                    let child = build_node(
1000
                        graph,
1001
                        child_id,
1002
                        children_map,
1003
                        layer_color,
1004
                        brick_color,
1005
                        kernel_color,
1006
                        func_color,
1007
                    );
1008
                    tree_node = tree_node.with_child(child);
1009
                }
1010
            }
1011
1012
            tree_node
1013
        }
1014
1015
        // Build root node
1016
        if root_ids.is_empty() {
1017
            TreeNode::new(0, "Empty Graph")
1018
        } else if root_ids.len() == 1 {
1019
            build_node(
1020
                self,
1021
                root_ids[0],
1022
                &children_map,
1023
                layer_color,
1024
                brick_color,
1025
                kernel_color,
1026
                func_color,
1027
            )
1028
        } else {
1029
            // Multiple roots: wrap in a synthetic root
1030
            let mut root =
1031
                TreeNode::new(u64::MAX, "Execution Graph").with_color(Color::new(0.9, 0.9, 0.9, 1.0));
1032
            for &root_id in &root_ids {
1033
                let child = build_node(
1034
                    self,
1035
                    root_id,
1036
                    &children_map,
1037
                    layer_color,
1038
                    brick_color,
1039
                    kernel_color,
1040
                    func_color,
1041
                );
1042
                root = root.with_child(child);
1043
            }
1044
            root
1045
        }
1046
    }
1047
1048
    /// Render graph to ASCII tree string (headless mode for testing/automation).
1049
    ///
1050
    /// PAR-201: Zero-dependency tree visualization for CI/CD, logging, and snapshot tests.
1051
    #[must_use]
1052
0
    pub fn to_ascii_tree(&self) -> String {
1053
        // Build child map: parent -> [children]
1054
0
        let mut children_map: HashMap<u32, Vec<u32>> = HashMap::new();
1055
0
        let mut has_parent: std::collections::HashSet<u32> = std::collections::HashSet::new();
1056
1057
0
        for edge in &self.edges {
1058
0
            if edge.edge_type == EdgeType::Contains || edge.edge_type == EdgeType::Launches {
1059
0
                children_map
1060
0
                    .entry(edge.src.0)
1061
0
                    .or_default()
1062
0
                    .push(edge.dst.0);
1063
0
                has_parent.insert(edge.dst.0);
1064
0
            }
1065
        }
1066
1067
        // Find root nodes (nodes with no parent)
1068
0
        let root_ids: Vec<u32> = (0..self.nodes.len() as u32)
1069
0
            .filter(|id| !has_parent.contains(id))
1070
0
            .collect();
1071
1072
        // Recursive function to build tree string
1073
0
        fn build_tree(
1074
0
            graph: &ExecutionGraph,
1075
0
            id: u32,
1076
0
            children_map: &HashMap<u32, Vec<u32>>,
1077
0
            prefix: &str,
1078
0
            connector: &str,
1079
0
            output: &mut String,
1080
0
        ) {
1081
0
            let node = &graph.nodes[id as usize];
1082
0
            let (label, info) = match node {
1083
0
                ExecutionNode::Layer { index } => (format!("Layer {}", index), String::new()),
1084
                ExecutionNode::Brick {
1085
0
                    id: brick_id,
1086
0
                    timing_ns,
1087
0
                    elements,
1088
0
                } => (
1089
0
                    brick_id.name().to_string(),
1090
0
                    format!("  {:.1}µs ({} elem)", *timing_ns as f64 / 1000.0, elements),
1091
0
                ),
1092
                ExecutionNode::Kernel {
1093
0
                    name,
1094
0
                    grid,
1095
0
                    block,
1096
0
                    shared_mem,
1097
                    ..
1098
0
                } => (
1099
0
                    name.clone(),
1100
0
                    format!(
1101
0
                        "  <<<{},{},{}>>> smem={}B",
1102
0
                        grid.0, block.0, block.1, shared_mem
1103
0
                    ),
1104
0
                ),
1105
0
                ExecutionNode::Function { name, file, line } => {
1106
0
                    let loc = match (file, line) {
1107
0
                        (Some(f), Some(l)) => format!(" ({}:{})", f, l),
1108
0
                        _ => String::new(),
1109
                    };
1110
0
                    (format!("{}{}", name, loc), String::new())
1111
                }
1112
                ExecutionNode::Transfer {
1113
0
                    src,
1114
0
                    dst,
1115
0
                    bytes,
1116
0
                    direction,
1117
0
                    timing_ns,
1118
                } => {
1119
0
                    let timing_str = timing_ns
1120
0
                        .map(|ns| format!(" {:.1}µs", ns as f64 / 1000.0))
1121
0
                        .unwrap_or_default();
1122
0
                    (
1123
0
                        format!("{:?}: {} → {}", direction, src, dst),
1124
0
                        format!("  {}B{}", bytes, timing_str),
1125
0
                    )
1126
                }
1127
                ExecutionNode::AsyncTask {
1128
0
                    name,
1129
0
                    poll_count,
1130
0
                    yield_count,
1131
0
                    total_poll_ns,
1132
                } => {
1133
0
                    let efficiency = if *poll_count > 0 {
1134
0
                        100.0 / *poll_count as f64
1135
                    } else {
1136
0
                        0.0
1137
                    };
1138
0
                    (
1139
0
                        name.clone(),
1140
0
                        format!(
1141
0
                            "  polls:{} yields:{} {:.1}µs ({:.0}% eff)",
1142
0
                            poll_count,
1143
0
                            yield_count,
1144
0
                            *total_poll_ns as f64 / 1000.0,
1145
0
                            efficiency
1146
0
                        ),
1147
0
                    )
1148
                }
1149
            };
1150
1151
0
            output.push_str(&format!("{}{}{}{}\n", prefix, connector, label, info));
1152
1153
0
            if let Some(child_ids) = children_map.get(&id) {
1154
0
                let child_count = child_ids.len();
1155
0
                for (i, &child_id) in child_ids.iter().enumerate() {
1156
0
                    let is_last = i == child_count - 1;
1157
0
                    let new_connector = if is_last { "└── " } else { "├── " };
1158
0
                    let new_prefix = if connector.is_empty() {
1159
0
                        prefix.to_string()
1160
0
                    } else if connector == "└── " {
1161
0
                        format!("{}    ", prefix)
1162
                    } else {
1163
0
                        format!("{}│   ", prefix)
1164
                    };
1165
0
                    build_tree(graph, child_id, children_map, &new_prefix, new_connector, output);
1166
                }
1167
0
            }
1168
0
        }
1169
1170
0
        let mut output = String::new();
1171
1172
0
        if root_ids.is_empty() {
1173
0
            output.push_str("(empty graph)\n");
1174
0
        } else if root_ids.len() == 1 {
1175
0
            build_tree(self, root_ids[0], &children_map, "", "", &mut output);
1176
0
        } else {
1177
            // Multiple roots: add synthetic root
1178
0
            output.push_str("Execution Graph\n");
1179
0
            let root_count = root_ids.len();
1180
0
            for (i, &root_id) in root_ids.iter().enumerate() {
1181
0
                let is_last = i == root_count - 1;
1182
0
                let connector = if is_last { "└── " } else { "├── " };
1183
0
                build_tree(self, root_id, &children_map, "", connector, &mut output);
1184
            }
1185
        }
1186
1187
        // Remove trailing newline for cleaner output
1188
0
        if output.ends_with('\n') {
1189
0
            output.pop();
1190
0
        }
1191
0
        output
1192
0
    }
1193
1194
    // ========================
1195
    // Phase 9: Critical Path Analysis (CPA)
1196
    // ========================
1197
1198
    /// Get timing for a node (ns). Returns 0 for non-timed nodes.
1199
0
    fn node_timing_ns(&self, id: ExecutionNodeId) -> u64 {
1200
0
        match &self.nodes[id.0 as usize] {
1201
0
            ExecutionNode::Brick { timing_ns, .. } => *timing_ns,
1202
0
            ExecutionNode::Kernel { timing_ns, .. } => timing_ns.unwrap_or(0),
1203
0
            ExecutionNode::Transfer { timing_ns, .. } => timing_ns.unwrap_or(0),
1204
0
            _ => 0,
1205
        }
1206
0
    }
1207
1208
    /// Compute critical path through execution graph using longest-path algorithm.
1209
    ///
1210
    /// Returns (critical_path_nodes, total_time_ns). The critical path represents
1211
    /// the longest chain of dependencies that determines total execution time.
1212
    ///
1213
    /// Reference: Graham et al. (1979) "Scheduling Algorithms for Multi-Processor Systems"
1214
0
    pub fn critical_path(&self) -> (Vec<ExecutionNodeId>, u64) {
1215
0
        if self.nodes.is_empty() {
1216
0
            return (vec![], 0);
1217
0
        }
1218
1219
        // Build adjacency list for DependsOn and Sequence edges
1220
0
        let mut adj: Vec<Vec<(u32, u64)>> = vec![vec![]; self.nodes.len()];
1221
0
        for edge in &self.edges {
1222
0
            match &edge.edge_type {
1223
0
                EdgeType::DependsOn | EdgeType::Sequence => {
1224
0
                    let weight = self.node_timing_ns(edge.dst);
1225
0
                    adj[edge.src.0 as usize].push((edge.dst.0, weight));
1226
0
                }
1227
0
                EdgeType::Contains | EdgeType::Calls | EdgeType::Launches => {
1228
0
                    // Hierarchical edges: children contribute to parent time
1229
0
                    let weight = self.node_timing_ns(edge.dst);
1230
0
                    adj[edge.src.0 as usize].push((edge.dst.0, weight));
1231
0
                }
1232
0
                EdgeType::Transfer { .. } => {
1233
0
                    // Transfer edges carry their own timing
1234
0
                    let weight = self.node_timing_ns(edge.dst);
1235
0
                    adj[edge.src.0 as usize].push((edge.dst.0, weight));
1236
0
                }
1237
            }
1238
        }
1239
1240
        // Topological sort using Kahn's algorithm
1241
0
        let mut in_degree = vec![0u32; self.nodes.len()];
1242
0
        for edges in &adj {
1243
0
            for (dst, _) in edges {
1244
0
                in_degree[*dst as usize] += 1;
1245
0
            }
1246
        }
1247
1248
0
        let mut queue: Vec<u32> = (0..self.nodes.len() as u32)
1249
0
            .filter(|&i| in_degree[i as usize] == 0)
1250
0
            .collect();
1251
0
        let mut topo_order = Vec::with_capacity(self.nodes.len());
1252
1253
0
        while let Some(u) = queue.pop() {
1254
0
            topo_order.push(u);
1255
0
            for (v, _) in &adj[u as usize] {
1256
0
                in_degree[*v as usize] -= 1;
1257
0
                if in_degree[*v as usize] == 0 {
1258
0
                    queue.push(*v);
1259
0
                }
1260
            }
1261
        }
1262
1263
        // Longest path DP
1264
0
        let mut dist = vec![0u64; self.nodes.len()];
1265
0
        let mut pred = vec![None::<u32>; self.nodes.len()];
1266
1267
        // Initialize with node's own timing for roots
1268
0
        for &node in &topo_order {
1269
0
            if self.edges.iter().all(|e| e.dst.0 != node) {
1270
0
                dist[node as usize] = self.node_timing_ns(ExecutionNodeId(node));
1271
0
            }
1272
        }
1273
1274
0
        for &u in &topo_order {
1275
0
            for (v, weight) in &adj[u as usize] {
1276
0
                let new_dist = dist[u as usize] + weight;
1277
0
                if new_dist > dist[*v as usize] {
1278
0
                    dist[*v as usize] = new_dist;
1279
0
                    pred[*v as usize] = Some(u);
1280
0
                }
1281
            }
1282
        }
1283
1284
        // Find endpoint with maximum distance
1285
0
        let (end_node, &total_time) = dist
1286
0
            .iter()
1287
0
            .enumerate()
1288
0
            .max_by_key(|(_, &d)| d)
1289
0
            .unwrap_or((0, &0));
1290
1291
        // Reconstruct path
1292
0
        let mut path = vec![];
1293
0
        let mut current = Some(end_node as u32);
1294
0
        while let Some(node) = current {
1295
0
            path.push(ExecutionNodeId(node));
1296
0
            current = pred[node as usize];
1297
0
        }
1298
0
        path.reverse();
1299
1300
0
        (path, total_time)
1301
0
    }
1302
1303
    /// Compute slack for each node (how much it can be delayed without affecting total time).
1304
    ///
1305
    /// Returns map from node ID to slack in nanoseconds. Nodes on critical path have slack = 0.
1306
0
    pub fn compute_slack(&self) -> HashMap<ExecutionNodeId, u64> {
1307
0
        let (critical_path, total_time) = self.critical_path();
1308
0
        let critical_set: std::collections::HashSet<_> = critical_path.iter().copied().collect();
1309
1310
0
        let mut slack = HashMap::new();
1311
1312
        // Build reverse adjacency
1313
0
        let mut reverse_adj: Vec<Vec<u32>> = vec![vec![]; self.nodes.len()];
1314
0
        for edge in &self.edges {
1315
0
            reverse_adj[edge.dst.0 as usize].push(edge.src.0);
1316
0
        }
1317
1318
        // Forward pass: earliest start time
1319
0
        let mut earliest = vec![0u64; self.nodes.len()];
1320
0
        for i in 0..self.nodes.len() {
1321
0
            let mut max_pred = 0u64;
1322
0
            for &pred in &reverse_adj[i] {
1323
0
                max_pred =
1324
0
                    max_pred.max(earliest[pred as usize] + self.node_timing_ns(ExecutionNodeId(pred)));
1325
0
            }
1326
0
            earliest[i] = max_pred;
1327
        }
1328
1329
        // Backward pass: latest start time
1330
0
        let mut latest = vec![total_time; self.nodes.len()];
1331
0
        for i in (0..self.nodes.len()).rev() {
1332
0
            let timing = self.node_timing_ns(ExecutionNodeId(i as u32));
1333
0
            let mut min_succ = total_time;
1334
0
            for edge in &self.edges {
1335
0
                if edge.src.0 == i as u32 {
1336
0
                    min_succ = min_succ.min(latest[edge.dst.0 as usize]);
1337
0
                }
1338
            }
1339
0
            latest[i] = min_succ.saturating_sub(timing);
1340
        }
1341
1342
        // Slack = latest - earliest
1343
0
        for i in 0..self.nodes.len() {
1344
0
            let node_id = ExecutionNodeId(i as u32);
1345
0
            let node_slack = if critical_set.contains(&node_id) {
1346
0
                0
1347
            } else {
1348
0
                latest[i].saturating_sub(earliest[i])
1349
            };
1350
0
            slack.insert(node_id, node_slack);
1351
        }
1352
1353
0
        slack
1354
0
    }
1355
1356
    /// Compute roofline distance for kernel nodes.
1357
    ///
1358
    /// Returns map from kernel node ID to distance from roofline (0.0 = optimal).
1359
    /// Distance = 1.0 - min(achieved/peak_compute, achieved/peak_bandwidth).
1360
    ///
1361
    /// Reference: Williams et al. (2009) "Roofline: An Insightful Visual Performance Model"
1362
0
    pub fn roofline_distance(
1363
0
        &self,
1364
0
        peak_tflops: f32,
1365
0
        peak_bandwidth_gb_s: f32,
1366
0
    ) -> HashMap<ExecutionNodeId, f32> {
1367
0
        let mut distances = HashMap::new();
1368
1369
0
        for (i, node) in self.nodes.iter().enumerate() {
1370
            if let ExecutionNode::Kernel {
1371
0
                arithmetic_intensity,
1372
0
                achieved_tflops,
1373
                ..
1374
0
            } = node
1375
            {
1376
0
                if let (Some(ai), Some(achieved)) = (arithmetic_intensity, achieved_tflops) {
1377
0
                    // Roofline model: achievable = min(peak_compute, ai * bandwidth)
1378
0
                    let bandwidth_bound = *ai * peak_bandwidth_gb_s / 1000.0; // Convert GB/s to TFLOP/s
1379
0
                    let roofline_bound = peak_tflops.min(bandwidth_bound);
1380
0
                    let efficiency = achieved / roofline_bound;
1381
0
                    let distance = 1.0 - efficiency.min(1.0);
1382
0
                    distances.insert(ExecutionNodeId(i as u32), distance);
1383
0
                }
1384
0
            }
1385
        }
1386
1387
0
        distances
1388
0
    }
1389
1390
    /// Detect ping-pong memory transfer patterns (wasteful H2D followed by D2H).
1391
    ///
1392
    /// Returns pairs of transfer node IDs that exhibit ping-pong behavior.
1393
0
    pub fn detect_ping_pong(&self) -> Vec<(ExecutionNodeId, ExecutionNodeId)> {
1394
0
        let mut patterns = Vec::new();
1395
1396
        // Find transfer nodes
1397
0
        let transfers: Vec<(usize, &ExecutionNode)> = self
1398
0
            .nodes
1399
0
            .iter()
1400
0
            .enumerate()
1401
0
            .filter(|(_, n)| matches!(n, ExecutionNode::Transfer { .. }))
1402
0
            .collect();
1403
1404
        // Check for H2D followed by D2H on same data
1405
0
        for i in 0..transfers.len() {
1406
0
            for j in (i + 1)..transfers.len() {
1407
                if let (
1408
                    ExecutionNode::Transfer {
1409
0
                        src: src1,
1410
0
                        dst: dst1,
1411
0
                        direction: dir1,
1412
0
                        bytes: bytes1,
1413
                        ..
1414
                    },
1415
                    ExecutionNode::Transfer {
1416
0
                        src: src2,
1417
0
                        dst: dst2,
1418
0
                        direction: dir2,
1419
0
                        bytes: bytes2,
1420
                        ..
1421
                    },
1422
0
                ) = (&transfers[i].1, &transfers[j].1)
1423
                {
1424
                    // Ping-pong: H2D then D2H with matching src/dst and same size
1425
0
                    let is_ping_pong = (*dir1 == TransferDirection::H2D
1426
0
                        && *dir2 == TransferDirection::D2H
1427
0
                        && dst1 == src2
1428
0
                        && bytes1 == bytes2)
1429
0
                        || (*dir1 == TransferDirection::D2H
1430
0
                            && *dir2 == TransferDirection::H2D
1431
0
                            && src1 == dst2
1432
0
                            && bytes1 == bytes2);
1433
1434
0
                    if is_ping_pong {
1435
0
                        patterns.push((
1436
0
                            ExecutionNodeId(transfers[i].0 as u32),
1437
0
                            ExecutionNodeId(transfers[j].0 as u32),
1438
0
                        ));
1439
0
                    }
1440
0
                }
1441
            }
1442
        }
1443
1444
0
        patterns
1445
0
    }
1446
1447
    /// Get critical path analysis summary as formatted string.
1448
0
    pub fn critical_path_summary(&self) -> String {
1449
0
        let (path, total_ns) = self.critical_path();
1450
0
        let slack = self.compute_slack();
1451
1452
0
        let mut output = String::new();
1453
0
        output.push_str(&format!(
1454
0
            "Critical Path: {:.2}ms ({} nodes)\n",
1455
0
            total_ns as f64 / 1_000_000.0,
1456
0
            path.len()
1457
0
        ));
1458
0
        output.push_str("─".repeat(50).as_str());
1459
0
        output.push('\n');
1460
1461
0
        for (i, node_id) in path.iter().enumerate() {
1462
0
            let node = &self.nodes[node_id.0 as usize];
1463
0
            let timing = self.node_timing_ns(*node_id);
1464
0
            let node_name = match node {
1465
0
                ExecutionNode::Layer { index } => format!("Layer {}", index),
1466
0
                ExecutionNode::Brick { id, .. } => id.name().to_string(),
1467
0
                ExecutionNode::Kernel { name, .. } => name.clone(),
1468
0
                ExecutionNode::Function { name, .. } => name.clone(),
1469
                ExecutionNode::Transfer {
1470
0
                    direction, src, dst, ..
1471
                } => {
1472
0
                    format!("{:?} {} → {}", direction, src, dst)
1473
                }
1474
                ExecutionNode::AsyncTask {
1475
0
                    name, poll_count, ..
1476
                } => {
1477
0
                    format!("{} ({}polls)", name, poll_count)
1478
                }
1479
            };
1480
1481
0
            let prefix = if i == 0 {
1482
0
                "┌"
1483
0
            } else if i == path.len() - 1 {
1484
0
                "â””"
1485
            } else {
1486
0
                "│"
1487
            };
1488
0
            output.push_str(&format!(
1489
0
                "{} {} ({:.1}µs)\n",
1490
0
                prefix,
1491
0
                node_name,
1492
0
                timing as f64 / 1000.0
1493
0
            ));
1494
        }
1495
1496
        // Show nodes with most slack (parallelization opportunities)
1497
0
        let mut slack_vec: Vec<_> = slack.iter().collect();
1498
0
        slack_vec.sort_by(|a, b| b.1.cmp(a.1));
1499
1500
0
        if slack_vec.iter().any(|(_, &s)| s > 0) {
1501
0
            output.push_str("\nParallelization Opportunities (high slack):\n");
1502
0
            for (node_id, &node_slack) in slack_vec.iter().take(5) {
1503
0
                if node_slack > 0 {
1504
0
                    let node = &self.nodes[node_id.0 as usize];
1505
0
                    let node_name = match node {
1506
0
                        ExecutionNode::Layer { index } => format!("Layer {}", index),
1507
0
                        ExecutionNode::Brick { id, .. } => id.name().to_string(),
1508
0
                        ExecutionNode::Kernel { name, .. } => name.clone(),
1509
0
                        ExecutionNode::Function { name, .. } => name.clone(),
1510
                        ExecutionNode::Transfer {
1511
0
                            direction, src, dst, ..
1512
                        } => {
1513
0
                            format!("{:?} {} → {}", direction, src, dst)
1514
                        }
1515
                        ExecutionNode::AsyncTask {
1516
0
                            name, poll_count, ..
1517
                        } => {
1518
0
                            format!("{} ({}polls)", name, poll_count)
1519
                        }
1520
                    };
1521
0
                    output.push_str(&format!(
1522
0
                        "  {} slack={:.1}µs\n",
1523
0
                        node_name,
1524
0
                        node_slack as f64 / 1000.0
1525
0
                    ));
1526
0
                }
1527
            }
1528
0
        }
1529
1530
0
        output
1531
0
    }
1532
1533
    /// Clear the graph.
1534
0
    pub fn clear(&mut self) {
1535
0
        self.nodes.clear();
1536
0
        self.edges.clear();
1537
0
        self.scope_stack.clear();
1538
0
        self.name_to_id.clear();
1539
0
    }
1540
1541
    /// Check if scope stack is balanced (empty).
1542
0
    pub fn is_scope_balanced(&self) -> bool {
1543
0
        self.scope_stack.is_empty()
1544
0
    }
1545
}
1546
1547
/// PTX kernel registry for execution graph correlation.
1548
///
1549
/// PAR-201: Maps PTX hashes to source code for debugging and analysis.
1550
#[derive(Debug, Default)]
1551
pub struct PtxRegistry {
1552
    /// Hash → (kernel_name, ptx_source, file_path)
1553
    kernels: HashMap<u64, (String, String, Option<std::path::PathBuf>)>,
1554
}
1555
1556
impl PtxRegistry {
1557
    /// Create a new empty registry.
1558
0
    pub fn new() -> Self {
1559
0
        Self::default()
1560
0
    }
1561
1562
    /// Register PTX source code.
1563
    ///
1564
    /// # Arguments
1565
    /// - `name`: Kernel name (e.g., "batched_q4k_gemv")
1566
    /// - `ptx`: PTX source code
1567
    /// - `path`: Optional file path for source correlation
1568
0
    pub fn register(&mut self, name: &str, ptx: &str, path: Option<&std::path::Path>) {
1569
0
        let hash = Self::hash_ptx(ptx);
1570
0
        self.kernels.insert(
1571
0
            hash,
1572
            (
1573
0
                name.to_string(),
1574
0
                ptx.to_string(),
1575
0
                path.map(|p| p.to_path_buf()),
1576
            ),
1577
        );
1578
0
    }
1579
1580
    /// Compute FNV-1a hash of PTX source.
1581
    #[inline]
1582
0
    pub fn hash_ptx(ptx: &str) -> u64 {
1583
        // FNV-1a hash
1584
0
        let mut hash: u64 = 0xcbf29ce484222325;
1585
0
        for byte in ptx.bytes() {
1586
0
            hash ^= byte as u64;
1587
0
            hash = hash.wrapping_mul(0x100000001b3);
1588
0
        }
1589
0
        hash
1590
0
    }
1591
1592
    /// Lookup PTX source by hash.
1593
0
    pub fn lookup(&self, hash: u64) -> Option<&str> {
1594
0
        self.kernels.get(&hash).map(|(_, ptx, _)| ptx.as_str())
1595
0
    }
1596
1597
    /// Lookup kernel name by hash.
1598
0
    pub fn lookup_name(&self, hash: u64) -> Option<&str> {
1599
0
        self.kernels.get(&hash).map(|(name, _, _)| name.as_str())
1600
0
    }
1601
1602
    /// Lookup file path by hash.
1603
0
    pub fn lookup_path(&self, hash: u64) -> Option<&std::path::Path> {
1604
0
        self.kernels
1605
0
            .get(&hash)
1606
0
            .and_then(|(_, _, path)| path.as_deref())
1607
0
    }
1608
1609
    /// Get all registered hashes.
1610
0
    pub fn hashes(&self) -> impl Iterator<Item = u64> + '_ {
1611
0
        self.kernels.keys().copied()
1612
0
    }
1613
1614
    /// Number of registered kernels.
1615
0
    pub fn len(&self) -> usize {
1616
0
        self.kernels.len()
1617
0
    }
1618
1619
    /// Check if registry is empty.
1620
0
    pub fn is_empty(&self) -> bool {
1621
0
        self.kernels.is_empty()
1622
0
    }
1623
}
1624
1625
/// Aggregated statistics for a brick category.
1626
#[derive(Debug, Clone, Copy, Default)]
1627
pub struct CategoryStats {
1628
    /// Total elapsed time (nanoseconds)
1629
    pub total_ns: u64,
1630
    /// Total elements processed
1631
    pub total_elements: u64,
1632
    /// Total samples
1633
    pub count: u64,
1634
}
1635
1636
impl CategoryStats {
1637
    /// Average time per sample in microseconds.
1638
    #[inline]
1639
0
    pub fn avg_us(&self) -> f64 {
1640
0
        if self.count == 0 {
1641
0
            0.0
1642
        } else {
1643
0
            self.total_ns as f64 / self.count as f64 / 1000.0
1644
        }
1645
0
    }
1646
1647
    /// Throughput in elements per second.
1648
    #[inline]
1649
0
    pub fn throughput(&self) -> f64 {
1650
0
        if self.total_ns == 0 {
1651
0
            0.0
1652
        } else {
1653
0
            self.total_elements as f64 / (self.total_ns as f64 / 1_000_000_000.0)
1654
        }
1655
0
    }
1656
1657
    /// Percentage of total time (given total_ns across all categories).
1658
    #[inline]
1659
0
    pub fn percentage(&self, total: u64) -> f64 {
1660
0
        if total == 0 {
1661
0
            0.0
1662
        } else {
1663
0
            100.0 * self.total_ns as f64 / total as f64
1664
        }
1665
0
    }
1666
}
1667
1668
/// Accumulated per-brick statistics.
1669
#[derive(Debug, Clone, Default)]
1670
pub struct BrickStats {
1671
    /// Brick name
1672
    pub name: String,
1673
    /// Total samples collected
1674
    pub count: u64,
1675
    /// Total elapsed time (nanoseconds)
1676
    pub total_ns: u64,
1677
    /// Min elapsed time (nanoseconds)
1678
    pub min_ns: u64,
1679
    /// Max elapsed time (nanoseconds)
1680
    pub max_ns: u64,
1681
    /// Total elements processed
1682
    pub total_elements: u64,
1683
    /// PMAT-451: Total bytes processed (for throughput calculation)
1684
    pub total_bytes: u64,
1685
    /// PMAT-451: Total compressed bytes (for compression ratio)
1686
    pub total_compressed_bytes: u64,
1687
    /// PMAT-451: Bottleneck classification
1688
    pub bottleneck: BrickBottleneck,
1689
    /// Phase 11 (E.9.2): Total CPU cycles (from RDTSCP/CNTVCT)
1690
    pub total_cycles: u64,
1691
    /// Phase 11: Minimum CPU cycles observed
1692
    pub min_cycles: u64,
1693
    /// Phase 11: Maximum CPU cycles observed
1694
    pub max_cycles: u64,
1695
}
1696
1697
impl BrickStats {
1698
    /// Create new stats for a brick.
1699
0
    pub fn new(name: &str) -> Self {
1700
0
        Self {
1701
0
            name: name.to_string(),
1702
0
            count: 0,
1703
0
            total_ns: 0,
1704
0
            min_ns: u64::MAX,
1705
0
            max_ns: 0,
1706
0
            total_elements: 0,
1707
0
            total_bytes: 0,
1708
0
            total_compressed_bytes: 0,
1709
0
            bottleneck: BrickBottleneck::Unknown,
1710
0
            total_cycles: 0,
1711
0
            min_cycles: u64::MAX,
1712
0
            max_cycles: 0,
1713
0
        }
1714
0
    }
1715
1716
    /// Add a sample to statistics.
1717
0
    pub fn add_sample(&mut self, elapsed_ns: u64, elements: u64) {
1718
0
        self.count += 1;
1719
0
        self.total_ns += elapsed_ns;
1720
0
        self.min_ns = self.min_ns.min(elapsed_ns);
1721
0
        self.max_ns = self.max_ns.max(elapsed_ns);
1722
0
        self.total_elements += elements;
1723
0
    }
1724
1725
    /// Phase 11 (E.9.2): Add a sample with CPU cycle count.
1726
    ///
1727
    /// Use this for frequency-invariant performance analysis.
1728
    /// Cycles are immune to CPU frequency scaling (turbo boost).
1729
0
    pub fn add_sample_with_cycles(&mut self, elapsed_ns: u64, elements: u64, cycles: u64) {
1730
0
        self.add_sample(elapsed_ns, elements);
1731
0
        self.total_cycles += cycles;
1732
0
        self.min_cycles = self.min_cycles.min(cycles);
1733
0
        self.max_cycles = self.max_cycles.max(cycles);
1734
0
    }
1735
1736
    /// Phase 11: Cycles per element (frequency-invariant throughput metric).
1737
    ///
1738
    /// Lower is better. This metric is immune to CPU frequency scaling.
1739
    #[must_use]
1740
0
    pub fn cycles_per_element(&self) -> f64 {
1741
0
        if self.total_elements == 0 {
1742
0
            0.0
1743
        } else {
1744
0
            self.total_cycles as f64 / self.total_elements as f64
1745
        }
1746
0
    }
1747
1748
    /// Phase 11: Average cycles per sample.
1749
    #[must_use]
1750
0
    pub fn avg_cycles(&self) -> f64 {
1751
0
        if self.count == 0 {
1752
0
            0.0
1753
        } else {
1754
0
            self.total_cycles as f64 / self.count as f64
1755
        }
1756
0
    }
1757
1758
    /// Phase 11: Estimated IPC (Instructions Per Cycle).
1759
    ///
1760
    /// Approximation assuming ~1 instruction per element for simple ops.
1761
    /// - Low IPC (<1.0): Memory stalls (cache misses, memory latency)
1762
    /// - High IPC (>2.0): Compute bound (efficient execution)
1763
    #[must_use]
1764
0
    pub fn estimated_ipc(&self) -> f64 {
1765
0
        if self.total_cycles == 0 {
1766
0
            0.0
1767
        } else {
1768
            // Rough approximation: assume 1 instruction per element
1769
0
            self.total_elements as f64 / self.total_cycles as f64
1770
        }
1771
0
    }
1772
1773
    /// Phase 11: Diagnose bottleneck based on cycles vs time ratio.
1774
    ///
1775
    /// High cycles + low time = likely cache misses
1776
    /// Low cycles + high time = likely CPU throttling or context switches
1777
    #[must_use]
1778
0
    pub fn diagnose_from_cycles(&self) -> &'static str {
1779
0
        if self.total_cycles == 0 || self.total_ns == 0 {
1780
0
            return "insufficient data";
1781
0
        }
1782
1783
0
        let ipc = self.estimated_ipc();
1784
0
        let ns_per_cycle = self.total_ns as f64 / self.total_cycles as f64;
1785
1786
        // Typical CPU runs at ~3GHz, so 1 cycle ≈ 0.33ns
1787
        // If ns_per_cycle >> 0.33, we're seeing stalls or throttling
1788
0
        if ipc < 0.5 {
1789
0
            "memory-bound (low IPC, likely cache misses)"
1790
0
        } else if ipc > 2.0 {
1791
0
            "compute-bound (efficient)"
1792
0
        } else if ns_per_cycle > 1.0 {
1793
0
            "throttled or context-switched"
1794
        } else {
1795
0
            "balanced"
1796
        }
1797
0
    }
1798
1799
    /// PMAT-451: Add a sample with byte metrics for compression workloads.
1800
    ///
1801
    /// # Arguments
1802
    /// - `elapsed_ns`: Time taken in nanoseconds
1803
    /// - `elements`: Number of elements processed (e.g., pages)
1804
    /// - `input_bytes`: Original uncompressed size
1805
    /// - `output_bytes`: Compressed output size
1806
0
    pub fn add_sample_with_bytes(
1807
0
        &mut self,
1808
0
        elapsed_ns: u64,
1809
0
        elements: u64,
1810
0
        input_bytes: u64,
1811
0
        output_bytes: u64,
1812
0
    ) {
1813
0
        self.add_sample(elapsed_ns, elements);
1814
0
        self.total_bytes += input_bytes;
1815
0
        self.total_compressed_bytes += output_bytes;
1816
0
    }
1817
1818
    /// PMAT-451: Calculate compression ratio (input_size / output_size).
1819
    /// Returns 1.0 if no compression data available.
1820
    #[must_use]
1821
0
    pub fn compression_ratio(&self) -> f64 {
1822
0
        if self.total_compressed_bytes == 0 {
1823
0
            1.0
1824
        } else {
1825
0
            self.total_bytes as f64 / self.total_compressed_bytes as f64
1826
        }
1827
0
    }
1828
1829
    /// PMAT-451: Calculate throughput in GB/s.
1830
    /// Based on total input bytes processed.
1831
    #[must_use]
1832
0
    pub fn throughput_gbps(&self) -> f64 {
1833
0
        if self.total_ns == 0 {
1834
0
            0.0
1835
        } else {
1836
0
            let bytes_per_ns = self.total_bytes as f64 / self.total_ns as f64;
1837
0
            bytes_per_ns * 1e9 / 1e9 // Convert to GB/s (ns to sec, bytes to GB)
1838
        }
1839
0
    }
1840
1841
    /// PMAT-451: Set bottleneck classification.
1842
0
    pub fn set_bottleneck(&mut self, bottleneck: BrickBottleneck) {
1843
0
        self.bottleneck = bottleneck;
1844
0
    }
1845
1846
    /// PMAT-451: Get bottleneck classification.
1847
    #[must_use]
1848
0
    pub fn get_bottleneck(&self) -> BrickBottleneck {
1849
0
        self.bottleneck
1850
0
    }
1851
1852
    /// Average time in microseconds.
1853
    #[must_use]
1854
0
    pub fn avg_us(&self) -> f64 {
1855
0
        if self.count == 0 {
1856
0
            0.0
1857
        } else {
1858
0
            self.total_ns as f64 / self.count as f64 / 1000.0
1859
        }
1860
0
    }
1861
1862
    /// Throughput in elements/second.
1863
    #[must_use]
1864
0
    pub fn throughput(&self) -> f64 {
1865
0
        if self.total_ns == 0 {
1866
0
            0.0
1867
        } else {
1868
0
            self.total_elements as f64 / (self.total_ns as f64 / 1_000_000_000.0)
1869
        }
1870
0
    }
1871
1872
    /// Throughput in tokens/second (alias for throughput).
1873
    #[must_use]
1874
0
    pub fn tokens_per_sec(&self) -> f64 {
1875
0
        self.throughput()
1876
0
    }
1877
1878
    /// Minimum time in microseconds.
1879
    #[must_use]
1880
0
    pub fn min_us(&self) -> f64 {
1881
0
        if self.min_ns == u64::MAX {
1882
0
            0.0
1883
        } else {
1884
0
            self.min_ns as f64 / 1000.0
1885
        }
1886
0
    }
1887
1888
    /// Maximum time in microseconds.
1889
    #[must_use]
1890
0
    pub fn max_us(&self) -> f64 {
1891
0
        self.max_ns as f64 / 1000.0
1892
0
    }
1893
}
1894
1895
#[cfg(test)]
1896
mod tests {
1897
    use super::*;
1898
1899
    #[test]
1900
    fn test_brick_id_category() {
1901
        assert_eq!(BrickId::RmsNorm.category(), BrickCategory::Norm);
1902
        assert_eq!(BrickId::LayerNorm.category(), BrickCategory::Norm);
1903
        assert_eq!(BrickId::QkvProjection.category(), BrickCategory::Attention);
1904
        assert_eq!(BrickId::GateProjection.category(), BrickCategory::Ffn);
1905
        assert_eq!(BrickId::Embedding.category(), BrickCategory::Other);
1906
    }
1907
1908
    #[test]
1909
    fn test_brick_id_name() {
1910
        assert_eq!(BrickId::RmsNorm.name(), "RmsNorm");
1911
        assert_eq!(BrickId::QkvProjection.name(), "QkvProjection");
1912
    }
1913
1914
    #[test]
1915
    fn test_brick_id_from_str() {
1916
        assert_eq!(BrickId::from_str("RmsNorm"), Some(BrickId::RmsNorm));
1917
        assert_eq!(BrickId::from_str("Qkv"), Some(BrickId::QkvProjection));
1918
        assert_eq!(BrickId::from_str("RoPE"), Some(BrickId::RopeEmbedding));
1919
        assert_eq!(BrickId::from_str("Unknown"), None);
1920
    }
1921
1922
    #[test]
1923
    fn test_brick_id_display() {
1924
        assert_eq!(format!("{}", BrickId::RmsNorm), "RmsNorm");
1925
    }
1926
1927
    #[test]
1928
    fn test_brick_category_name() {
1929
        assert_eq!(BrickCategory::Norm.name(), "Norm");
1930
        assert_eq!(BrickCategory::Ffn.name(), "FFN");
1931
    }
1932
1933
    #[test]
1934
    fn test_brick_bottleneck_display() {
1935
        assert_eq!(format!("{}", BrickBottleneck::Memory), "memory");
1936
        assert_eq!(format!("{}", BrickBottleneck::Compute), "compute");
1937
    }
1938
1939
    #[test]
1940
    fn test_execution_graph_basic() {
1941
        let mut graph = ExecutionGraph::new();
1942
        let layer = graph.add_node(ExecutionNode::Layer { index: 0 });
1943
        let brick = graph.add_node(ExecutionNode::Brick {
1944
            id: BrickId::RmsNorm,
1945
            timing_ns: 1000,
1946
            elements: 4096,
1947
        });
1948
        graph.add_edge(layer, brick, EdgeType::Contains);
1949
1950
        assert_eq!(graph.num_nodes(), 2);
1951
        assert_eq!(graph.num_edges(), 1);
1952
    }
1953
1954
    #[test]
1955
    fn test_execution_node_name() {
1956
        let brick = ExecutionNode::Brick {
1957
            id: BrickId::RmsNorm,
1958
            timing_ns: 1000,
1959
            elements: 4096,
1960
        };
1961
        assert_eq!(brick.name(), "RmsNorm");
1962
1963
        let layer = ExecutionNode::Layer { index: 5 };
1964
        assert_eq!(layer.name(), "Layer5");
1965
    }
1966
1967
    #[test]
1968
    fn test_execution_graph_scopes() {
1969
        let mut graph = ExecutionGraph::new();
1970
        let layer = graph.push_scope(ExecutionNode::Layer { index: 0 });
1971
        let brick = graph.add_node_in_scope(ExecutionNode::Brick {
1972
            id: BrickId::RmsNorm,
1973
            timing_ns: 1000,
1974
            elements: 4096,
1975
        });
1976
        graph.pop_scope();
1977
1978
        assert_eq!(graph.num_nodes(), 2);
1979
        // Should have a Contains edge from layer to brick
1980
        let edges: Vec<_> = graph.outgoing_edges(layer).collect();
1981
        assert_eq!(edges.len(), 1);
1982
        assert_eq!(edges[0].dst, brick);
1983
    }
1984
1985
    #[test]
1986
    fn test_brick_stats_basic() {
1987
        let mut stats = BrickStats::new("test_brick");
1988
        stats.add_sample(1000, 100);
1989
        stats.add_sample(2000, 200);
1990
1991
        assert_eq!(stats.count, 2);
1992
        assert_eq!(stats.total_ns, 3000);
1993
        assert_eq!(stats.total_elements, 300);
1994
        assert_eq!(stats.min_ns, 1000);
1995
        assert_eq!(stats.max_ns, 2000);
1996
    }
1997
1998
    #[test]
1999
    fn test_category_stats_percentage() {
2000
        let stats = CategoryStats {
2001
            total_ns: 250,
2002
            total_elements: 1000,
2003
            count: 10,
2004
        };
2005
        assert!((stats.percentage(1000) - 25.0).abs() < 0.001);
2006
    }
2007
2008
    #[test]
2009
    fn test_ptx_registry() {
2010
        let mut registry = PtxRegistry::new();
2011
        registry.register("test_kernel", ".version 8.0\n.entry test {}", None);
2012
2013
        assert_eq!(registry.len(), 1);
2014
        assert!(!registry.is_empty());
2015
2016
        let hash = PtxRegistry::hash_ptx(".version 8.0\n.entry test {}");
2017
        assert_eq!(registry.lookup_name(hash), Some("test_kernel"));
2018
    }
2019
2020
    #[test]
2021
    fn test_transfer_direction() {
2022
        let node = ExecutionNode::Transfer {
2023
            src: "host".to_string(),
2024
            dst: "device".to_string(),
2025
            bytes: 1024,
2026
            direction: TransferDirection::H2D,
2027
            timing_ns: Some(100),
2028
        };
2029
        assert!(node.is_transfer());
2030
        assert_eq!(node.transfer_bytes(), Some(1024));
2031
    }
2032
2033
    #[test]
2034
    fn test_execution_graph_to_dot() {
2035
        let mut graph = ExecutionGraph::new();
2036
        graph.add_node(ExecutionNode::Layer { index: 0 });
2037
        let dot = graph.to_dot();
2038
        assert!(dot.contains("digraph ExecutionGraph"));
2039
        assert!(dot.contains("Layer 0"));
2040
    }
2041
2042
    #[test]
2043
    fn test_execution_graph_to_ascii_tree() {
2044
        let mut graph = ExecutionGraph::new();
2045
        graph.push_scope(ExecutionNode::Layer { index: 0 });
2046
        graph.add_node_in_scope(ExecutionNode::Brick {
2047
            id: BrickId::RmsNorm,
2048
            timing_ns: 1000,
2049
            elements: 4096,
2050
        });
2051
        graph.pop_scope();
2052
2053
        let tree = graph.to_ascii_tree();
2054
        assert!(tree.contains("Layer 0"));
2055
        assert!(tree.contains("RmsNorm"));
2056
    }
2057
2058
    #[test]
2059
    fn test_brick_stats_cycles() {
2060
        let mut stats = BrickStats::new("test");
2061
        stats.add_sample_with_cycles(1000, 100, 3000);
2062
2063
        assert_eq!(stats.total_cycles, 3000);
2064
        assert!((stats.cycles_per_element() - 30.0).abs() < 0.001);
2065
    }
2066
2067
    // FALSIFICATION TESTS
2068
2069
    /// FALSIFICATION TEST: BrickId round-trip via name
2070
    #[test]
2071
    fn test_falsify_brick_id_round_trip() {
2072
        for brick_id in [
2073
            BrickId::RmsNorm,
2074
            BrickId::LayerNorm,
2075
            BrickId::QkvProjection,
2076
            BrickId::RopeEmbedding,
2077
            BrickId::AttentionScore,
2078
            BrickId::AttentionSoftmax,
2079
            BrickId::AttentionOutput,
2080
            BrickId::OutputProjection,
2081
            BrickId::GateProjection,
2082
            BrickId::UpProjection,
2083
            BrickId::Activation,
2084
            BrickId::DownProjection,
2085
            BrickId::Embedding,
2086
            BrickId::LmHead,
2087
            BrickId::Sampling,
2088
        ] {
2089
            let name = brick_id.name();
2090
            let parsed = BrickId::from_str(name);
2091
            assert_eq!(
2092
                parsed,
2093
                Some(brick_id),
2094
                "FALSIFICATION FAILED: BrickId::{:?}.name() = {:?} does not round-trip",
2095
                brick_id,
2096
                name
2097
            );
2098
        }
2099
    }
2100
2101
    /// FALSIFICATION TEST: ExecutionGraph maintains node/edge count consistency
2102
    #[test]
2103
    fn test_falsify_graph_consistency() {
2104
        let mut graph = ExecutionGraph::new();
2105
2106
        // Add nodes and edges
2107
        let n1 = graph.add_node(ExecutionNode::Layer { index: 0 });
2108
        let n2 = graph.add_node(ExecutionNode::Layer { index: 1 });
2109
        graph.add_edge(n1, n2, EdgeType::Sequence);
2110
2111
        assert_eq!(
2112
            graph.num_nodes(),
2113
            2,
2114
            "FALSIFICATION FAILED: node count mismatch"
2115
        );
2116
        assert_eq!(
2117
            graph.num_edges(),
2118
            1,
2119
            "FALSIFICATION FAILED: edge count mismatch"
2120
        );
2121
2122
        // Clear and verify
2123
        graph.clear();
2124
        assert_eq!(
2125
            graph.num_nodes(),
2126
            0,
2127
            "FALSIFICATION FAILED: clear did not reset nodes"
2128
        );
2129
        assert_eq!(
2130
            graph.num_edges(),
2131
            0,
2132
            "FALSIFICATION FAILED: clear did not reset edges"
2133
        );
2134
    }
2135
2136
    /// FALSIFICATION TEST: BrickStats min/max tracking
2137
    #[test]
2138
    fn test_falsify_brick_stats_minmax() {
2139
        let mut stats = BrickStats::new("test");
2140
2141
        for ns in [1000u64, 500, 2000, 750, 1500] {
2142
            stats.add_sample(ns, 100);
2143
        }
2144
2145
        assert_eq!(
2146
            stats.min_ns, 500,
2147
            "FALSIFICATION FAILED: min_ns should be 500, got {}",
2148
            stats.min_ns
2149
        );
2150
        assert_eq!(
2151
            stats.max_ns, 2000,
2152
            "FALSIFICATION FAILED: max_ns should be 2000, got {}",
2153
            stats.max_ns
2154
        );
2155
    }
2156
}