Skip to main content

oxicuda_launch/
telemetry.rs

1//! Launch telemetry: timing, occupancy, and register usage reporting.
2//!
3//! This module provides post-launch diagnostics for GPU kernel execution.
4//! After a kernel launch, [`LaunchTelemetry`] captures grid/block dimensions,
5//! GPU-side timing, achieved occupancy, and register usage. A
6//! [`TelemetryCollector`] accumulates entries and produces a
7//! [`TelemetrySummary`] with per-kernel aggregation.
8//!
9//! Telemetry can be exported to JSON, CSV, or Chrome trace format via
10//! [`TelemetryExporter`].
11//!
12//! # Example
13//!
14//! ```
15//! use oxicuda_launch::telemetry::{LaunchTelemetry, TelemetryCollector};
16//!
17//! let mut collector = TelemetryCollector::new(1000);
18//! let entry = LaunchTelemetry::new("vector_add", (4, 1, 1), (256, 1, 1))
19//!     .with_elapsed_ms(0.5)
20//!     .with_achieved_occupancy(0.85);
21//! collector.record(entry);
22//! let summary = collector.summary();
23//! assert_eq!(summary.total_launches, 1);
24//! ```
25
26use std::collections::HashMap;
27use std::fmt;
28use std::time::Instant;
29
30// ---------------------------------------------------------------------------
31// SmVersion (local, avoids oxicuda-ptx dependency)
32// ---------------------------------------------------------------------------
33
34/// GPU architecture version for occupancy estimation.
35///
36/// This is a local copy that avoids a dependency on `oxicuda-ptx`.
37/// Each variant encodes the SM architecture parameters needed for
38/// occupancy calculations (max warps per SM, register file size, etc.).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum SmVersion {
41    /// Turing (compute capability 7.5).
42    Sm75,
43    /// Ampere (compute capability 8.0).
44    Sm80,
45    /// Ampere GA10x (compute capability 8.6).
46    Sm86,
47    /// Ada Lovelace (compute capability 8.9).
48    Sm89,
49    /// Hopper (compute capability 9.0).
50    Sm90,
51    /// Blackwell (compute capability 10.0).
52    Sm100,
53    /// Blackwell B200 (compute capability 12.0).
54    Sm120,
55}
56
57impl SmVersion {
58    /// Maximum number of warps that can reside on a single SM.
59    ///
60    /// Ampere GA10x (`Sm86`) and Blackwell consumer (`Sm120`) share Ada
61    /// Lovelace's (`Sm89`) lower 1536-threads/SM ceiling (48 warps),
62    /// unlike the datacenter parts (`Sm80`/`Sm90`/`Sm100`) which reach the
63    /// full 2048 threads/SM (64 warps).
64    #[must_use]
65    pub const fn max_warps_per_sm(self) -> u32 {
66        match self {
67            Self::Sm75 => 32,
68            Self::Sm86 | Self::Sm89 | Self::Sm120 => 48,
69            Self::Sm80 | Self::Sm90 | Self::Sm100 => 64,
70        }
71    }
72
73    /// Maximum number of thread blocks that can reside on a single SM.
74    #[must_use]
75    pub const fn max_blocks_per_sm(self) -> u32 {
76        match self {
77            Self::Sm75 | Self::Sm86 => 16,
78            Self::Sm89 | Self::Sm120 => 24,
79            Self::Sm80 | Self::Sm90 | Self::Sm100 => 32,
80        }
81    }
82
83    /// Total number of 32-bit registers available per SM.
84    #[must_use]
85    pub const fn registers_per_sm(self) -> u32 {
86        65536
87    }
88
89    /// Maximum number of registers a single thread can use.
90    #[must_use]
91    pub const fn max_registers_per_thread(self) -> u32 {
92        255
93    }
94
95    /// Maximum shared memory per SM in bytes.
96    ///
97    /// Note this is the per-*SM* pool, not the smaller per-*block* cap
98    /// (e.g. `Sm86`/`Sm89` allow up to 100 KiB per SM but only 99 KiB
99    /// (101,376 bytes) opt-in per block).
100    #[must_use]
101    pub const fn max_shared_mem_per_sm(self) -> u32 {
102        match self {
103            Self::Sm75 => 65_536,
104            Self::Sm80 => 167_936,
105            Self::Sm86 | Self::Sm89 => 102_400,
106            Self::Sm90 | Self::Sm100 | Self::Sm120 => 232_448,
107        }
108    }
109
110    /// Warp size (always 32 for NVIDIA GPUs).
111    #[must_use]
112    pub const fn warp_size(self) -> u32 {
113        32
114    }
115
116    /// Register allocation granularity (in warps).
117    ///
118    /// Registers are allocated to warps in chunks of this many registers
119    /// per thread, rounded up to the nearest multiple.
120    #[must_use]
121    pub const fn register_alloc_granularity(self) -> u32 {
122        // All modern NVIDIA GPUs allocate registers in units of 256
123        // (i.e., 8 registers per thread * 32 threads = 256 regs per warp).
124        // The granularity for per-thread count rounding is 8.
125        8
126    }
127
128    /// Shared memory allocation granularity in bytes.
129    #[must_use]
130    pub const fn shared_mem_alloc_granularity(self) -> u32 {
131        match self {
132            Self::Sm75 | Self::Sm80 | Self::Sm86 | Self::Sm89 => 256,
133            Self::Sm90 | Self::Sm100 | Self::Sm120 => 128,
134        }
135    }
136}
137
138impl fmt::Display for SmVersion {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        let s = match self {
141            Self::Sm75 => "sm_75",
142            Self::Sm80 => "sm_80",
143            Self::Sm86 => "sm_86",
144            Self::Sm89 => "sm_89",
145            Self::Sm90 => "sm_90",
146            Self::Sm100 => "sm_100",
147            Self::Sm120 => "sm_120",
148        };
149        f.write_str(s)
150    }
151}
152
153// ---------------------------------------------------------------------------
154// LaunchTelemetry
155// ---------------------------------------------------------------------------
156
157/// Telemetry data collected after a single kernel launch.
158///
159/// Records dimensions, timing, occupancy, and register usage.
160/// Use the builder methods (`with_*`) to set optional fields after
161/// constructing with [`LaunchTelemetry::new`].
162#[derive(Debug, Clone)]
163pub struct LaunchTelemetry {
164    /// Name of the launched kernel.
165    pub kernel_name: String,
166    /// Grid dimensions `(x, y, z)`.
167    pub grid_dim: (u32, u32, u32),
168    /// Block dimensions `(x, y, z)`.
169    pub block_dim: (u32, u32, u32),
170    /// Dynamic shared memory allocated in bytes.
171    pub shared_memory_bytes: u32,
172    /// Number of registers used per thread, if known.
173    pub register_count: Option<u32>,
174    /// GPU-side elapsed time in milliseconds, if measured.
175    pub elapsed_ms: Option<f64>,
176    /// Achieved occupancy (0.0..=1.0), if measured.
177    pub achieved_occupancy: Option<f64>,
178    /// Theoretical occupancy (0.0..=1.0), if computed.
179    pub theoretical_occupancy: Option<f64>,
180    /// Wall-clock timestamp when the telemetry was recorded.
181    pub timestamp: Instant,
182}
183
184impl LaunchTelemetry {
185    /// Creates a new telemetry entry with the given kernel name and dimensions.
186    ///
187    /// Optional fields default to `None` / `0`. Use the `with_*` builder
188    /// methods to set them.
189    #[must_use]
190    pub fn new(kernel_name: &str, grid_dim: (u32, u32, u32), block_dim: (u32, u32, u32)) -> Self {
191        Self {
192            kernel_name: kernel_name.to_owned(),
193            grid_dim,
194            block_dim,
195            shared_memory_bytes: 0,
196            register_count: None,
197            elapsed_ms: None,
198            achieved_occupancy: None,
199            theoretical_occupancy: None,
200            timestamp: Instant::now(),
201        }
202    }
203
204    /// Sets the dynamic shared memory allocation.
205    #[must_use]
206    pub fn with_shared_memory(mut self, bytes: u32) -> Self {
207        self.shared_memory_bytes = bytes;
208        self
209    }
210
211    /// Sets the register count per thread.
212    #[must_use]
213    pub fn with_register_count(mut self, count: u32) -> Self {
214        self.register_count = Some(count);
215        self
216    }
217
218    /// Sets the GPU-side elapsed time in milliseconds.
219    #[must_use]
220    pub fn with_elapsed_ms(mut self, ms: f64) -> Self {
221        self.elapsed_ms = Some(ms);
222        self
223    }
224
225    /// Sets the achieved occupancy (0.0..=1.0).
226    #[must_use]
227    pub fn with_achieved_occupancy(mut self, occ: f64) -> Self {
228        self.achieved_occupancy = Some(occ);
229        self
230    }
231
232    /// Sets the theoretical occupancy (0.0..=1.0).
233    #[must_use]
234    pub fn with_theoretical_occupancy(mut self, occ: f64) -> Self {
235        self.theoretical_occupancy = Some(occ);
236        self
237    }
238
239    /// Total number of threads launched (grid_total * block_total).
240    #[must_use]
241    pub fn total_threads(&self) -> u64 {
242        let grid_total = self.grid_dim.0 as u64 * self.grid_dim.1 as u64 * self.grid_dim.2 as u64;
243        let block_total =
244            self.block_dim.0 as u64 * self.block_dim.1 as u64 * self.block_dim.2 as u64;
245        grid_total * block_total
246    }
247}
248
249impl fmt::Display for LaunchTelemetry {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        write!(
252            f,
253            "Kernel '{}': grid=({},{},{}), block=({},{},{}), smem={}B",
254            self.kernel_name,
255            self.grid_dim.0,
256            self.grid_dim.1,
257            self.grid_dim.2,
258            self.block_dim.0,
259            self.block_dim.1,
260            self.block_dim.2,
261            self.shared_memory_bytes,
262        )?;
263        if let Some(regs) = self.register_count {
264            write!(f, ", regs={regs}")?;
265        }
266        if let Some(ms) = self.elapsed_ms {
267            write!(f, ", time={ms:.3}ms")?;
268        }
269        if let Some(occ) = self.achieved_occupancy {
270            write!(f, ", occupancy={:.1}%", occ * 100.0)?;
271        }
272        Ok(())
273    }
274}
275
276// ---------------------------------------------------------------------------
277// KernelStats
278// ---------------------------------------------------------------------------
279
280/// Aggregated statistics for a single kernel across multiple launches.
281#[derive(Debug, Clone)]
282pub struct KernelStats {
283    /// Name of the kernel.
284    pub kernel_name: String,
285    /// Number of times this kernel was launched.
286    pub launch_count: u32,
287    /// Total GPU time across all launches in milliseconds.
288    pub total_time_ms: f64,
289    /// Average GPU time per launch in milliseconds.
290    pub avg_time_ms: f64,
291    /// Minimum GPU time observed in milliseconds.
292    pub min_time_ms: f64,
293    /// Maximum GPU time observed in milliseconds.
294    pub max_time_ms: f64,
295    /// Average achieved occupancy across launches.
296    pub avg_occupancy: f64,
297}
298
299impl fmt::Display for KernelStats {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        write!(
302            f,
303            "{}: {} launches, total={:.3}ms, avg={:.3}ms, min={:.3}ms, max={:.3}ms, occ={:.1}%",
304            self.kernel_name,
305            self.launch_count,
306            self.total_time_ms,
307            self.avg_time_ms,
308            self.min_time_ms,
309            self.max_time_ms,
310            self.avg_occupancy * 100.0,
311        )
312    }
313}
314
315// ---------------------------------------------------------------------------
316// TelemetrySummary
317// ---------------------------------------------------------------------------
318
319/// Summary of all collected telemetry data.
320///
321/// Provides aggregate statistics across all recorded kernel launches
322/// and per-kernel breakdowns via [`KernelStats`].
323#[derive(Debug, Clone)]
324pub struct TelemetrySummary {
325    /// Total number of kernel launches recorded.
326    pub total_launches: usize,
327    /// Total GPU time across all launches in milliseconds.
328    pub total_gpu_time_ms: f64,
329    /// Average GPU time per launch in milliseconds.
330    pub avg_gpu_time_ms: f64,
331    /// Minimum GPU time observed across all launches.
332    pub min_gpu_time_ms: f64,
333    /// Maximum GPU time observed across all launches.
334    pub max_gpu_time_ms: f64,
335    /// Average achieved occupancy across all launches.
336    pub avg_occupancy: f64,
337    /// Kernel with the most cumulative GPU time.
338    pub hottest_kernel: Option<String>,
339    /// Per-kernel aggregated statistics.
340    pub per_kernel_stats: Vec<KernelStats>,
341}
342
343impl fmt::Display for TelemetrySummary {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        writeln!(f, "=== Telemetry Summary ===")?;
346        writeln!(f, "Total launches: {}", self.total_launches)?;
347        writeln!(f, "Total GPU time: {:.3} ms", self.total_gpu_time_ms)?;
348        writeln!(f, "Avg GPU time:   {:.3} ms", self.avg_gpu_time_ms)?;
349        writeln!(f, "Min GPU time:   {:.3} ms", self.min_gpu_time_ms)?;
350        writeln!(f, "Max GPU time:   {:.3} ms", self.max_gpu_time_ms)?;
351        writeln!(f, "Avg occupancy:  {:.1}%", self.avg_occupancy * 100.0)?;
352        if let Some(ref hot) = self.hottest_kernel {
353            writeln!(f, "Hottest kernel: {hot}")?;
354        }
355        if !self.per_kernel_stats.is_empty() {
356            writeln!(f, "--- Per-kernel ---")?;
357            for ks in &self.per_kernel_stats {
358                writeln!(f, "  {ks}")?;
359            }
360        }
361        Ok(())
362    }
363}
364
365// ---------------------------------------------------------------------------
366// TelemetryCollector
367// ---------------------------------------------------------------------------
368
369/// Accumulates [`LaunchTelemetry`] entries and produces summaries.
370///
371/// The collector can be enabled/disabled at runtime and caps the number
372/// of stored entries to `max_entries` to bound memory usage.
373#[derive(Debug)]
374pub struct TelemetryCollector {
375    entries: Vec<LaunchTelemetry>,
376    enabled: bool,
377    max_entries: usize,
378}
379
380impl TelemetryCollector {
381    /// Creates a new collector that stores up to `max_entries` telemetry records.
382    #[must_use]
383    pub fn new(max_entries: usize) -> Self {
384        Self {
385            entries: Vec::new(),
386            enabled: true,
387            max_entries,
388        }
389    }
390
391    /// Records a telemetry entry.
392    ///
393    /// If the collector is disabled or the entry count has reached
394    /// `max_entries`, the entry is silently dropped.
395    pub fn record(&mut self, telemetry: LaunchTelemetry) {
396        if !self.enabled {
397            return;
398        }
399        if self.entries.len() >= self.max_entries {
400            return;
401        }
402        self.entries.push(telemetry);
403    }
404
405    /// Enables telemetry recording.
406    pub fn enable(&mut self) {
407        self.enabled = true;
408    }
409
410    /// Disables telemetry recording. Existing entries are preserved.
411    pub fn disable(&mut self) {
412        self.enabled = false;
413    }
414
415    /// Returns whether the collector is currently enabled.
416    #[must_use]
417    pub fn is_enabled(&self) -> bool {
418        self.enabled
419    }
420
421    /// Clears all recorded entries.
422    pub fn clear(&mut self) {
423        self.entries.clear();
424    }
425
426    /// Returns a reference to all recorded entries.
427    #[must_use]
428    pub fn entries(&self) -> &[LaunchTelemetry] {
429        &self.entries
430    }
431
432    /// Returns the number of recorded entries.
433    #[must_use]
434    pub fn len(&self) -> usize {
435        self.entries.len()
436    }
437
438    /// Returns `true` if no entries have been recorded.
439    #[must_use]
440    pub fn is_empty(&self) -> bool {
441        self.entries.is_empty()
442    }
443
444    /// Computes a summary of all recorded telemetry.
445    ///
446    /// If no entries have been recorded, returns a zeroed summary.
447    #[must_use]
448    pub fn summary(&self) -> TelemetrySummary {
449        compute_summary(&self.entries)
450    }
451}
452
453/// Computes a [`TelemetrySummary`] from a slice of telemetry entries.
454fn compute_summary(entries: &[LaunchTelemetry]) -> TelemetrySummary {
455    if entries.is_empty() {
456        return TelemetrySummary {
457            total_launches: 0,
458            total_gpu_time_ms: 0.0,
459            avg_gpu_time_ms: 0.0,
460            min_gpu_time_ms: 0.0,
461            max_gpu_time_ms: 0.0,
462            avg_occupancy: 0.0,
463            hottest_kernel: None,
464            per_kernel_stats: Vec::new(),
465        };
466    }
467
468    let mut total_time = 0.0_f64;
469    let mut min_time = f64::MAX;
470    let mut max_time = f64::MIN;
471    let mut time_count = 0usize;
472    let mut total_occ = 0.0_f64;
473    let mut occ_count = 0usize;
474
475    // Per-kernel accumulators
476    struct KernelAccum {
477        count: u32,
478        total_time: f64,
479        min_time: f64,
480        max_time: f64,
481        total_occ: f64,
482        occ_count: u32,
483    }
484
485    let mut per_kernel: HashMap<String, KernelAccum> = HashMap::new();
486
487    for entry in entries {
488        if let Some(ms) = entry.elapsed_ms {
489            total_time += ms;
490            if ms < min_time {
491                min_time = ms;
492            }
493            if ms > max_time {
494                max_time = ms;
495            }
496            time_count += 1;
497        }
498        if let Some(occ) = entry.achieved_occupancy {
499            total_occ += occ;
500            occ_count += 1;
501        }
502
503        let acc = per_kernel
504            .entry(entry.kernel_name.clone())
505            .or_insert(KernelAccum {
506                count: 0,
507                total_time: 0.0,
508                min_time: f64::MAX,
509                max_time: f64::MIN,
510                total_occ: 0.0,
511                occ_count: 0,
512            });
513        acc.count += 1;
514        if let Some(ms) = entry.elapsed_ms {
515            acc.total_time += ms;
516            if ms < acc.min_time {
517                acc.min_time = ms;
518            }
519            if ms > acc.max_time {
520                acc.max_time = ms;
521            }
522        }
523        if let Some(occ) = entry.achieved_occupancy {
524            acc.total_occ += occ;
525            acc.occ_count += 1;
526        }
527    }
528
529    // Fix sentinel values when no timing data was present
530    if time_count == 0 {
531        min_time = 0.0;
532        max_time = 0.0;
533    }
534
535    // Build per-kernel stats
536    let mut per_kernel_stats: Vec<KernelStats> = per_kernel
537        .into_iter()
538        .map(|(name, acc)| {
539            let min_t = if acc.min_time == f64::MAX {
540                0.0
541            } else {
542                acc.min_time
543            };
544            let max_t = if acc.max_time == f64::MIN {
545                0.0
546            } else {
547                acc.max_time
548            };
549            let avg_t = if acc.count > 0 {
550                acc.total_time / f64::from(acc.count)
551            } else {
552                0.0
553            };
554            let avg_o = if acc.occ_count > 0 {
555                acc.total_occ / f64::from(acc.occ_count)
556            } else {
557                0.0
558            };
559            KernelStats {
560                kernel_name: name,
561                launch_count: acc.count,
562                total_time_ms: acc.total_time,
563                avg_time_ms: avg_t,
564                min_time_ms: min_t,
565                max_time_ms: max_t,
566                avg_occupancy: avg_o,
567            }
568        })
569        .collect();
570
571    // Sort by total time descending for deterministic output
572    per_kernel_stats.sort_by(|a, b| {
573        b.total_time_ms
574            .partial_cmp(&a.total_time_ms)
575            .unwrap_or(std::cmp::Ordering::Equal)
576    });
577
578    let hottest_kernel = per_kernel_stats.first().map(|ks| ks.kernel_name.clone());
579
580    let avg_gpu_time = if time_count > 0 {
581        total_time / time_count as f64
582    } else {
583        0.0
584    };
585    let avg_occ = if occ_count > 0 {
586        total_occ / occ_count as f64
587    } else {
588        0.0
589    };
590
591    TelemetrySummary {
592        total_launches: entries.len(),
593        total_gpu_time_ms: total_time,
594        avg_gpu_time_ms: avg_gpu_time,
595        min_gpu_time_ms: min_time,
596        max_gpu_time_ms: max_time,
597        avg_occupancy: avg_occ,
598        hottest_kernel,
599        per_kernel_stats,
600    }
601}
602
603// ---------------------------------------------------------------------------
604// TelemetryExporter
605// ---------------------------------------------------------------------------
606
607/// Export telemetry data in various formats.
608///
609/// All methods are stateless and operate on slices of [`LaunchTelemetry`].
610pub struct TelemetryExporter;
611
612impl TelemetryExporter {
613    /// Exports telemetry entries as a JSON array.
614    ///
615    /// Each entry becomes a JSON object with all fields. `None` values
616    /// are serialized as `null`.
617    #[must_use]
618    pub fn to_json(entries: &[LaunchTelemetry]) -> String {
619        let mut out = String::from("[\n");
620        for (i, e) in entries.iter().enumerate() {
621            out.push_str("  {\n");
622            json_field_str(&mut out, "kernel_name", &e.kernel_name);
623            out.push_str(&format!(
624                "    \"grid_dim\": [{}, {}, {}],\n",
625                e.grid_dim.0, e.grid_dim.1, e.grid_dim.2
626            ));
627            out.push_str(&format!(
628                "    \"block_dim\": [{}, {}, {}],\n",
629                e.block_dim.0, e.block_dim.1, e.block_dim.2
630            ));
631            out.push_str(&format!(
632                "    \"shared_memory_bytes\": {},\n",
633                e.shared_memory_bytes
634            ));
635            json_field_opt_u32(&mut out, "register_count", e.register_count);
636            json_field_opt_f64(&mut out, "elapsed_ms", e.elapsed_ms);
637            json_field_opt_f64(&mut out, "achieved_occupancy", e.achieved_occupancy);
638            json_field_opt_f64_last(&mut out, "theoretical_occupancy", e.theoretical_occupancy);
639            out.push_str("  }");
640            if i + 1 < entries.len() {
641                out.push(',');
642            }
643            out.push('\n');
644        }
645        out.push(']');
646        out
647    }
648
649    /// Exports telemetry entries as CSV.
650    ///
651    /// The first line is a header row. Missing values are empty cells.
652    #[must_use]
653    pub fn to_csv(entries: &[LaunchTelemetry]) -> String {
654        let mut out = String::from(
655            "kernel_name,grid_x,grid_y,grid_z,block_x,block_y,block_z,\
656             shared_memory_bytes,register_count,elapsed_ms,\
657             achieved_occupancy,theoretical_occupancy\n",
658        );
659        for e in entries {
660            out.push_str(&csv_escape(&e.kernel_name));
661            out.push(',');
662            out.push_str(&format!(
663                "{},{},{},{},{},{},{},",
664                e.grid_dim.0,
665                e.grid_dim.1,
666                e.grid_dim.2,
667                e.block_dim.0,
668                e.block_dim.1,
669                e.block_dim.2,
670                e.shared_memory_bytes,
671            ));
672            csv_opt_u32(&mut out, e.register_count);
673            out.push(',');
674            csv_opt_f64(&mut out, e.elapsed_ms);
675            out.push(',');
676            csv_opt_f64(&mut out, e.achieved_occupancy);
677            out.push(',');
678            csv_opt_f64(&mut out, e.theoretical_occupancy);
679            out.push('\n');
680        }
681        out
682    }
683
684    /// Exports telemetry entries in Chrome `chrome://tracing` JSON format.
685    ///
686    /// Each kernel launch becomes a duration event (`ph: "X"`).
687    /// Launches without timing data use a duration of 0.
688    #[must_use]
689    pub fn to_chrome_trace(entries: &[LaunchTelemetry]) -> String {
690        let mut out = String::from("{\"traceEvents\":[\n");
691        let mut ts_us = 0.0_f64; // cumulative timestamp in microseconds
692        for (i, e) in entries.iter().enumerate() {
693            let dur_us = e.elapsed_ms.unwrap_or(0.0) * 1000.0;
694            out.push_str(&format!(
695                "  {{\"name\":\"{}\",\"cat\":\"gpu\",\"ph\":\"X\",\
696                 \"ts\":{:.3},\"dur\":{:.3},\"pid\":1,\"tid\":1,\
697                 \"args\":{{\"grid\":\"{},{},{}\",\"block\":\"{},{},{}\",\
698                 \"smem\":{}",
699                json_escape_str(&e.kernel_name),
700                ts_us,
701                dur_us,
702                e.grid_dim.0,
703                e.grid_dim.1,
704                e.grid_dim.2,
705                e.block_dim.0,
706                e.block_dim.1,
707                e.block_dim.2,
708                e.shared_memory_bytes,
709            ));
710            if let Some(regs) = e.register_count {
711                out.push_str(&format!(",\"regs\":{regs}"));
712            }
713            if let Some(occ) = e.achieved_occupancy {
714                out.push_str(&format!(",\"occupancy\":{occ:.4}"));
715            }
716            out.push_str("}}");
717            if i + 1 < entries.len() {
718                out.push(',');
719            }
720            out.push('\n');
721            ts_us += dur_us;
722        }
723        out.push_str("]}\n");
724        out
725    }
726}
727
728// ---------------------------------------------------------------------------
729// JSON / CSV helpers
730// ---------------------------------------------------------------------------
731
732fn json_escape_str(s: &str) -> String {
733    s.replace('\\', "\\\\")
734        .replace('"', "\\\"")
735        .replace('\n', "\\n")
736        .replace('\r', "\\r")
737        .replace('\t', "\\t")
738}
739
740fn json_field_str(out: &mut String, key: &str, val: &str) {
741    out.push_str(&format!("    \"{key}\": \"{}\",\n", json_escape_str(val)));
742}
743
744fn json_field_opt_u32(out: &mut String, key: &str, val: Option<u32>) {
745    match val {
746        Some(v) => out.push_str(&format!("    \"{key}\": {v},\n")),
747        None => out.push_str(&format!("    \"{key}\": null,\n")),
748    }
749}
750
751fn json_field_opt_f64(out: &mut String, key: &str, val: Option<f64>) {
752    match val {
753        Some(v) => out.push_str(&format!("    \"{key}\": {v},\n")),
754        None => out.push_str(&format!("    \"{key}\": null,\n")),
755    }
756}
757
758fn json_field_opt_f64_last(out: &mut String, key: &str, val: Option<f64>) {
759    match val {
760        Some(v) => out.push_str(&format!("    \"{key}\": {v}\n")),
761        None => out.push_str(&format!("    \"{key}\": null\n")),
762    }
763}
764
765fn csv_escape(s: &str) -> String {
766    if s.contains(',') || s.contains('"') || s.contains('\n') {
767        format!("\"{}\"", s.replace('"', "\"\""))
768    } else {
769        s.to_owned()
770    }
771}
772
773fn csv_opt_u32(out: &mut String, val: Option<u32>) {
774    if let Some(v) = val {
775        out.push_str(&v.to_string());
776    }
777}
778
779fn csv_opt_f64(out: &mut String, val: Option<f64>) {
780    if let Some(v) = val {
781        out.push_str(&format!("{v}"));
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Occupancy estimation
787// ---------------------------------------------------------------------------
788
789/// Estimates theoretical occupancy for a kernel launch configuration.
790///
791/// The occupancy is the ratio of active warps to the maximum possible
792/// warps on a streaming multiprocessor. This depends on:
793///
794/// - `block_size`: threads per block
795/// - `registers_per_thread`: registers consumed by each thread
796/// - `shared_mem`: dynamic shared memory per block in bytes
797/// - `sm_version`: target GPU architecture
798///
799/// Returns a value in the range `0.0..=1.0`.
800///
801/// # Example
802///
803/// ```
804/// use oxicuda_launch::telemetry::{estimate_occupancy, SmVersion};
805///
806/// let occ = estimate_occupancy(256, 32, 0, SmVersion::Sm80);
807/// assert!(occ > 0.0 && occ <= 1.0);
808/// ```
809#[must_use]
810pub fn estimate_occupancy(
811    block_size: u32,
812    registers_per_thread: u32,
813    shared_mem: u32,
814    sm_version: SmVersion,
815) -> f64 {
816    if block_size == 0 {
817        return 0.0;
818    }
819
820    let warp_size = sm_version.warp_size();
821    let max_warps = sm_version.max_warps_per_sm();
822    let max_blocks = sm_version.max_blocks_per_sm();
823    let regs_per_sm = sm_version.registers_per_sm();
824    let max_smem = sm_version.max_shared_mem_per_sm();
825    let reg_granularity = sm_version.register_alloc_granularity();
826    let smem_granularity = sm_version.shared_mem_alloc_granularity();
827
828    // Warps per block
829    let warps_per_block = block_size.div_ceil(warp_size);
830
831    // --- Register limit ---
832    let regs_per_thread = if registers_per_thread == 0 {
833        1 // must use at least 1 register
834    } else {
835        registers_per_thread
836    };
837    // Round up to allocation granularity
838    let regs_alloc = regs_per_thread.div_ceil(reg_granularity) * reg_granularity;
839    let regs_per_warp = regs_alloc * warp_size;
840    // Register-derived warp count can never exceed the SM's physical warp
841    // residency limit; capping here (rather than relying on the final
842    // `occupancy.clamp(0.0, 1.0)`) keeps `blocks_by_warps` from reporting
843    // more concurrently-resident blocks than the SM can actually hold,
844    // which would otherwise let a smaller, more-limiting factor (e.g.
845    // shared memory) be masked whenever the erroneously-large
846    // register-derived count still isn't the binding minimum.
847    let warps_limited_by_regs = regs_per_sm
848        .checked_div(regs_per_warp)
849        .unwrap_or(max_warps)
850        .min(max_warps);
851
852    // --- Shared memory limit ---
853    let smem_per_block = if shared_mem == 0 {
854        0
855    } else {
856        shared_mem.div_ceil(smem_granularity) * smem_granularity
857    };
858    let blocks_limited_by_smem = max_smem.checked_div(smem_per_block).unwrap_or(max_blocks);
859
860    // --- Block limit ---
861    let blocks_by_warps = warps_limited_by_regs
862        .checked_div(warps_per_block)
863        .unwrap_or(max_blocks);
864
865    let active_blocks = max_blocks.min(blocks_by_warps).min(blocks_limited_by_smem);
866
867    let active_warps = active_blocks * warps_per_block;
868    let occupancy = active_warps as f64 / max_warps as f64;
869
870    occupancy.clamp(0.0, 1.0)
871}
872
873// ---------------------------------------------------------------------------
874// Tests
875// ---------------------------------------------------------------------------
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    // -- LaunchTelemetry construction and builder --
882
883    #[test]
884    fn telemetry_new_defaults() {
885        let t = LaunchTelemetry::new("kern", (4, 1, 1), (256, 1, 1));
886        assert_eq!(t.kernel_name, "kern");
887        assert_eq!(t.grid_dim, (4, 1, 1));
888        assert_eq!(t.block_dim, (256, 1, 1));
889        assert_eq!(t.shared_memory_bytes, 0);
890        assert!(t.register_count.is_none());
891        assert!(t.elapsed_ms.is_none());
892        assert!(t.achieved_occupancy.is_none());
893        assert!(t.theoretical_occupancy.is_none());
894    }
895
896    #[test]
897    fn telemetry_builder_methods() {
898        let t = LaunchTelemetry::new("kern", (1, 1, 1), (128, 1, 1))
899            .with_shared_memory(4096)
900            .with_register_count(32)
901            .with_elapsed_ms(1.5)
902            .with_achieved_occupancy(0.75)
903            .with_theoretical_occupancy(0.80);
904
905        assert_eq!(t.shared_memory_bytes, 4096);
906        assert_eq!(t.register_count, Some(32));
907        assert!((t.elapsed_ms.unwrap_or(0.0) - 1.5).abs() < f64::EPSILON);
908        assert!((t.achieved_occupancy.unwrap_or(0.0) - 0.75).abs() < f64::EPSILON);
909        assert!((t.theoretical_occupancy.unwrap_or(0.0) - 0.80).abs() < f64::EPSILON);
910    }
911
912    #[test]
913    fn telemetry_total_threads() {
914        let t = LaunchTelemetry::new("k", (4, 2, 1), (16, 16, 1));
915        assert_eq!(t.total_threads(), 4 * 2 * 16 * 16);
916    }
917
918    #[test]
919    fn telemetry_display() {
920        let t = LaunchTelemetry::new("add", (4, 1, 1), (256, 1, 1))
921            .with_elapsed_ms(0.5)
922            .with_register_count(24)
923            .with_achieved_occupancy(0.85);
924        let s = format!("{t}");
925        assert!(s.contains("add"));
926        assert!(s.contains("0.500ms"));
927        assert!(s.contains("regs=24"));
928        assert!(s.contains("85.0%"));
929    }
930
931    // -- TelemetryCollector --
932
933    #[test]
934    fn collector_record_and_len() {
935        let mut c = TelemetryCollector::new(100);
936        assert!(c.is_empty());
937        c.record(LaunchTelemetry::new("k", (1, 1, 1), (64, 1, 1)));
938        assert_eq!(c.len(), 1);
939        assert!(!c.is_empty());
940    }
941
942    #[test]
943    fn collector_enable_disable() {
944        let mut c = TelemetryCollector::new(100);
945        assert!(c.is_enabled());
946
947        c.disable();
948        assert!(!c.is_enabled());
949        c.record(LaunchTelemetry::new("k", (1, 1, 1), (64, 1, 1)));
950        assert_eq!(c.len(), 0); // dropped because disabled
951
952        c.enable();
953        c.record(LaunchTelemetry::new("k", (1, 1, 1), (64, 1, 1)));
954        assert_eq!(c.len(), 1);
955    }
956
957    #[test]
958    fn collector_max_entries_cap() {
959        let mut c = TelemetryCollector::new(3);
960        for _ in 0..10 {
961            c.record(LaunchTelemetry::new("k", (1, 1, 1), (64, 1, 1)));
962        }
963        assert_eq!(c.len(), 3);
964    }
965
966    #[test]
967    fn collector_clear() {
968        let mut c = TelemetryCollector::new(100);
969        c.record(LaunchTelemetry::new("k", (1, 1, 1), (64, 1, 1)));
970        c.clear();
971        assert!(c.is_empty());
972    }
973
974    // -- TelemetrySummary --
975
976    #[test]
977    fn summary_empty() {
978        let c = TelemetryCollector::new(100);
979        let s = c.summary();
980        assert_eq!(s.total_launches, 0);
981        assert!((s.total_gpu_time_ms).abs() < f64::EPSILON);
982        assert!(s.hottest_kernel.is_none());
983        assert!(s.per_kernel_stats.is_empty());
984    }
985
986    #[test]
987    fn summary_single_kernel() {
988        let mut c = TelemetryCollector::new(100);
989        c.record(
990            LaunchTelemetry::new("add", (1, 1, 1), (256, 1, 1))
991                .with_elapsed_ms(1.0)
992                .with_achieved_occupancy(0.8),
993        );
994        c.record(
995            LaunchTelemetry::new("add", (1, 1, 1), (256, 1, 1))
996                .with_elapsed_ms(3.0)
997                .with_achieved_occupancy(0.9),
998        );
999        let s = c.summary();
1000        assert_eq!(s.total_launches, 2);
1001        assert!((s.total_gpu_time_ms - 4.0).abs() < f64::EPSILON);
1002        assert!((s.avg_gpu_time_ms - 2.0).abs() < f64::EPSILON);
1003        assert!((s.min_gpu_time_ms - 1.0).abs() < f64::EPSILON);
1004        assert!((s.max_gpu_time_ms - 3.0).abs() < f64::EPSILON);
1005        assert!((s.avg_occupancy - 0.85).abs() < 1e-9);
1006        assert_eq!(s.hottest_kernel.as_deref(), Some("add"));
1007        assert_eq!(s.per_kernel_stats.len(), 1);
1008        assert_eq!(s.per_kernel_stats[0].launch_count, 2);
1009    }
1010
1011    #[test]
1012    fn summary_per_kernel_aggregation() {
1013        let mut c = TelemetryCollector::new(100);
1014        c.record(LaunchTelemetry::new("matmul", (1, 1, 1), (256, 1, 1)).with_elapsed_ms(10.0));
1015        c.record(LaunchTelemetry::new("add", (1, 1, 1), (128, 1, 1)).with_elapsed_ms(1.0));
1016        c.record(LaunchTelemetry::new("matmul", (1, 1, 1), (256, 1, 1)).with_elapsed_ms(12.0));
1017
1018        let s = c.summary();
1019        assert_eq!(s.total_launches, 3);
1020        // hottest should be matmul (22ms > 1ms)
1021        assert_eq!(s.hottest_kernel.as_deref(), Some("matmul"));
1022        assert_eq!(s.per_kernel_stats.len(), 2);
1023        // First entry should be matmul (sorted by total time desc)
1024        assert_eq!(s.per_kernel_stats[0].kernel_name, "matmul");
1025        assert_eq!(s.per_kernel_stats[0].launch_count, 2);
1026        assert!((s.per_kernel_stats[0].total_time_ms - 22.0).abs() < f64::EPSILON);
1027    }
1028
1029    #[test]
1030    fn summary_display() {
1031        let mut c = TelemetryCollector::new(100);
1032        c.record(
1033            LaunchTelemetry::new("k", (1, 1, 1), (256, 1, 1))
1034                .with_elapsed_ms(2.0)
1035                .with_achieved_occupancy(0.5),
1036        );
1037        let s = c.summary();
1038        let text = format!("{s}");
1039        assert!(text.contains("Telemetry Summary"));
1040        assert!(text.contains("Total launches: 1"));
1041        assert!(text.contains("50.0%"));
1042    }
1043
1044    // -- TelemetryExporter: JSON --
1045
1046    #[test]
1047    fn export_json() {
1048        let entries = vec![
1049            LaunchTelemetry::new("kern", (4, 1, 1), (256, 1, 1))
1050                .with_elapsed_ms(0.5)
1051                .with_register_count(32),
1052        ];
1053        let json = TelemetryExporter::to_json(&entries);
1054        assert!(json.starts_with('['));
1055        assert!(json.contains("\"kernel_name\": \"kern\""));
1056        assert!(json.contains("\"grid_dim\": [4, 1, 1]"));
1057        assert!(json.contains("\"elapsed_ms\": 0.5"));
1058        assert!(json.contains("\"register_count\": 32"));
1059        assert!(json.contains("\"achieved_occupancy\": null"));
1060    }
1061
1062    // -- TelemetryExporter: CSV --
1063
1064    #[test]
1065    fn export_csv() {
1066        let entries =
1067            vec![LaunchTelemetry::new("kern", (2, 1, 1), (128, 1, 1)).with_elapsed_ms(1.0)];
1068        let csv = TelemetryExporter::to_csv(&entries);
1069        let lines: Vec<&str> = csv.lines().collect();
1070        assert_eq!(lines.len(), 2); // header + 1 data row
1071        assert!(lines[0].starts_with("kernel_name,"));
1072        assert!(lines[1].starts_with("kern,"));
1073        assert!(lines[1].contains("128"));
1074    }
1075
1076    // -- TelemetryExporter: Chrome trace --
1077
1078    #[test]
1079    fn export_chrome_trace() {
1080        let entries = vec![
1081            LaunchTelemetry::new("k1", (1, 1, 1), (256, 1, 1)).with_elapsed_ms(1.0),
1082            LaunchTelemetry::new("k2", (2, 1, 1), (128, 1, 1)).with_elapsed_ms(2.0),
1083        ];
1084        let trace = TelemetryExporter::to_chrome_trace(&entries);
1085        assert!(trace.contains("\"traceEvents\""));
1086        assert!(trace.contains("\"name\":\"k1\""));
1087        assert!(trace.contains("\"name\":\"k2\""));
1088        assert!(trace.contains("\"ph\":\"X\""));
1089        assert!(trace.contains("\"cat\":\"gpu\""));
1090    }
1091
1092    // -- Occupancy estimation --
1093
1094    #[test]
1095    fn occupancy_basic() {
1096        let occ = estimate_occupancy(256, 32, 0, SmVersion::Sm80);
1097        assert!(occ > 0.0);
1098        assert!(occ <= 1.0);
1099    }
1100
1101    #[test]
1102    fn occupancy_zero_block() {
1103        let occ = estimate_occupancy(0, 32, 0, SmVersion::Sm80);
1104        assert!((occ).abs() < f64::EPSILON);
1105    }
1106
1107    #[test]
1108    fn occupancy_high_registers_lowers_occupancy() {
1109        let high_reg = estimate_occupancy(256, 128, 0, SmVersion::Sm80);
1110        let low_reg = estimate_occupancy(256, 16, 0, SmVersion::Sm80);
1111        assert!(high_reg < low_reg);
1112    }
1113
1114    #[test]
1115    fn occupancy_large_shared_mem_lowers_occupancy() {
1116        let large_smem = estimate_occupancy(256, 32, 100_000, SmVersion::Sm80);
1117        let small_smem = estimate_occupancy(256, 32, 0, SmVersion::Sm80);
1118        assert!(large_smem <= small_smem);
1119    }
1120
1121    #[test]
1122    fn occupancy_sm_versions() {
1123        for sm in [
1124            SmVersion::Sm75,
1125            SmVersion::Sm80,
1126            SmVersion::Sm86,
1127            SmVersion::Sm89,
1128            SmVersion::Sm90,
1129            SmVersion::Sm100,
1130            SmVersion::Sm120,
1131        ] {
1132            let occ = estimate_occupancy(128, 32, 0, sm);
1133            assert!(occ > 0.0, "occupancy should be positive for {sm}");
1134            assert!(occ <= 1.0, "occupancy should be <= 1.0 for {sm}");
1135        }
1136    }
1137
1138    // -- SmVersion --
1139
1140    #[test]
1141    fn sm_version_display() {
1142        assert_eq!(format!("{}", SmVersion::Sm80), "sm_80");
1143        assert_eq!(format!("{}", SmVersion::Sm90), "sm_90");
1144    }
1145
1146    // ---------------------------------------------------------------------------
1147    // F069 regression tests: hardware constants must match real GPU specs.
1148    // ---------------------------------------------------------------------------
1149
1150    #[test]
1151    fn sm_version_max_warps_matches_real_hardware() {
1152        // Ampere GA10x (sm_86) and Ada Lovelace (sm_89) top out at 1536
1153        // threads/SM (48 warps), not the 2048 threads/SM (64 warps) of the
1154        // datacenter parts (sm_80/sm_90/sm_100). Blackwell consumer
1155        // (sm_120) shares Ada's lower ceiling.
1156        assert_eq!(SmVersion::Sm75.max_warps_per_sm(), 32);
1157        assert_eq!(SmVersion::Sm80.max_warps_per_sm(), 64);
1158        assert_eq!(SmVersion::Sm86.max_warps_per_sm(), 48);
1159        assert_eq!(SmVersion::Sm89.max_warps_per_sm(), 48);
1160        assert_eq!(SmVersion::Sm90.max_warps_per_sm(), 64);
1161        assert_eq!(SmVersion::Sm100.max_warps_per_sm(), 64);
1162        assert_eq!(SmVersion::Sm120.max_warps_per_sm(), 48);
1163    }
1164
1165    #[test]
1166    fn sm_version_max_blocks_matches_real_hardware() {
1167        assert_eq!(SmVersion::Sm75.max_blocks_per_sm(), 16);
1168        assert_eq!(SmVersion::Sm80.max_blocks_per_sm(), 32);
1169        assert_eq!(SmVersion::Sm86.max_blocks_per_sm(), 16);
1170        assert_eq!(SmVersion::Sm89.max_blocks_per_sm(), 24);
1171        assert_eq!(SmVersion::Sm90.max_blocks_per_sm(), 32);
1172        assert_eq!(SmVersion::Sm100.max_blocks_per_sm(), 32);
1173        assert_eq!(SmVersion::Sm120.max_blocks_per_sm(), 24);
1174    }
1175
1176    #[test]
1177    fn sm_version_max_shared_mem_matches_real_hardware() {
1178        // sm_86/sm_89 share the same 100 KiB (102,400 byte) per-SM pool;
1179        // 101,376 is the smaller *per-block* opt-in cap, not the per-SM
1180        // total, and must not be used here.
1181        assert_eq!(SmVersion::Sm75.max_shared_mem_per_sm(), 65_536);
1182        assert_eq!(SmVersion::Sm80.max_shared_mem_per_sm(), 167_936);
1183        assert_eq!(SmVersion::Sm86.max_shared_mem_per_sm(), 102_400);
1184        assert_eq!(SmVersion::Sm89.max_shared_mem_per_sm(), 102_400);
1185        assert_eq!(SmVersion::Sm90.max_shared_mem_per_sm(), 232_448);
1186    }
1187
1188    #[test]
1189    fn estimate_occupancy_caps_register_derived_warps_at_hardware_max() {
1190        // Regression test: `warps_limited_by_regs` was previously
1191        // uncapped, so with very low register pressure (here 1
1192        // register/thread, the architectural minimum) the register-derived
1193        // block count could exceed the SM's true warp-residency limit and
1194        // get masked only by the final `occupancy.clamp(0.0, 1.0)` —
1195        // silently reporting 100% occupancy for a configuration that the
1196        // SM cannot actually realize. With block_size=224 (7 warps/block)
1197        // on Sm86 (48 max warps/SM, which 7 does not evenly divide), full
1198        // occupancy is architecturally impossible: the true ceiling is
1199        // 6 resident blocks * 7 warps = 42/48 = 0.875.
1200        let occ = estimate_occupancy(224, 1, 0, SmVersion::Sm86);
1201        assert!(
1202            occ < 1.0,
1203            "occupancy must reflect the true SM warp-residency ceiling \
1204             instead of a register-derived overcount masked by clamping, got {occ}"
1205        );
1206        assert!(
1207            (occ - 0.875).abs() < 1e-9,
1208            "expected 42/48 = 0.875 (6 blocks * 7 warps / 48 max warps), got {occ}"
1209        );
1210    }
1211
1212    // -- KernelStats Display --
1213
1214    #[test]
1215    fn kernel_stats_display() {
1216        let ks = KernelStats {
1217            kernel_name: "matmul".to_owned(),
1218            launch_count: 5,
1219            total_time_ms: 10.0,
1220            avg_time_ms: 2.0,
1221            min_time_ms: 1.0,
1222            max_time_ms: 4.0,
1223            avg_occupancy: 0.75,
1224        };
1225        let s = format!("{ks}");
1226        assert!(s.contains("matmul"));
1227        assert!(s.contains("5 launches"));
1228        assert!(s.contains("75.0%"));
1229    }
1230}