Skip to main content

gam_gpu/
profile.rs

1use std::cell::Cell;
2use std::collections::VecDeque;
3use std::sync::{Mutex, OnceLock};
4
5const MAX_STATS: usize = 1024;
6
7#[derive(Clone, Debug, Default)]
8pub struct KernelStat {
9    pub name: &'static str,
10    pub n: usize,
11    pub p: usize,
12    pub k: usize,
13    pub nnz: usize,
14    pub flops_est: usize,
15    pub bytes_est: usize,
16    pub cpu_ms: f64,
17    pub gpu_ms: Option<f64>,
18}
19
20#[derive(Clone, Debug, Default)]
21pub struct KernelStatsSnapshot {
22    pub stats: Vec<KernelStat>,
23}
24
25/// One thread's dispatch ring: written only by the thread that owns it and read
26/// by [`snapshot`] / [`clear`], so the lock the write path takes is one no other
27/// thread holds while a dispatch is in flight.
28type DispatchRing = Mutex<VecDeque<KernelStat>>;
29
30/// How many rings one arena block holds. An allocation-granularity choice, not
31/// a thread bound: the chain grows a block at a time and never stops.
32const RING_BLOCK: usize = 64;
33
34/// The append-only, process-lifetime home of every thread's ring.
35///
36/// A ring must outlive the thread that writes it — a worker pool's history is
37/// exactly what a snapshot after the pool shuts down needs — and the reference
38/// a thread keeps to its own ring must have no destructor, so that a dispatch
39/// issued while that thread's other locals are being torn down still has
40/// somewhere to go. Both follow from owning the rings in a `static`: a filled
41/// slot is never emptied and a link is never cleared, so `&'static` comes from
42/// the structure rather than from a leak.
43struct RingArena {
44    slots: [OnceLock<DispatchRing>; RING_BLOCK],
45    next: OnceLock<Box<RingArena>>,
46}
47
48impl RingArena {
49    const fn new() -> Self {
50        Self {
51            slots: [const { OnceLock::new() }; RING_BLOCK],
52            next: OnceLock::new(),
53        }
54    }
55
56    /// Take the first free slot, growing the chain when a block is full.
57    ///
58    /// A slot lost to a thread claiming concurrently is simply skipped; the
59    /// walk carries on to the next one, so two threads never share a ring.
60    fn claim(&'static self) -> &'static DispatchRing {
61        let mut block: &'static RingArena = self;
62        loop {
63            for slot in block.slots.iter() {
64                if slot.set(Mutex::new(VecDeque::new())).is_ok() {
65                    return slot.get().expect("the slot was filled just above");
66                }
67            }
68            block = block.next.get_or_init(|| Box::new(RingArena::new())).as_ref();
69        }
70    }
71
72    /// Every ring handed out so far, oldest block first.
73    fn claimed(&'static self) -> Vec<&'static DispatchRing> {
74        let mut rings = Vec::new();
75        let mut block: &'static RingArena = self;
76        loop {
77            for slot in block.slots.iter() {
78                match slot.get() {
79                    Some(ring) => rings.push(ring),
80                    // Slots fill in order, so the first empty one ends the
81                    // occupied prefix of this block.
82                    None => return rings,
83                }
84            }
85            match block.next.get() {
86                Some(next) => block = next.as_ref(),
87                None => return rings,
88            }
89        }
90    }
91}
92
93static RING_ARENA: RingArena = RingArena::new();
94
95thread_local! {
96    /// This thread's ring, claimed on its first recorded dispatch.
97    ///
98    /// A `Cell<Option<&'static _>>` has no destructor, so this local is
99    /// readable for as long as the thread runs — including while its other
100    /// locals are being destroyed — and `with` has no failure case to swallow.
101    static RING: Cell<Option<&'static DispatchRing>> = const { Cell::new(None) };
102}
103
104/// Record one dispatch attempt.
105///
106/// A DIAGNOSTIC MUST NOT SERIALIZE THE COMPUTATION IT OBSERVES. This ring is
107/// written from the dense-product dispatch seam (`try_fast_ab`), which every
108/// `fast_ab` in the workspace passes through — including the small products a
109/// Rayon fan-out issues thousands of times per outer evaluation, and including
110/// the ones the size gate declines before any device is consulted. Behind a
111/// single process-wide `Mutex` those writes were not a diagnostic but a
112/// serialization point: on the #979 rigid marginal-slope arm at 16 threads a
113/// frame-pointer profile put 19.5 % of the whole run inside this function and a
114/// further 14.8 % in `lock_contended` beneath it, and the arm's 40-minute wall
115/// carried 325 minutes of system time against 188 of user time — sixteen
116/// threads taking turns in the kernel to maintain a 1024-entry ring whose
117/// contents at that call rate are the last microsecond of history.
118///
119/// Each thread therefore keeps its own ring and takes only its own lock. The
120/// recorded SET is unchanged: every attempt, device-bound or not, is still
121/// kept, and no ring is discarded when its thread ends. Only the interleaving
122/// of different threads' entries changes, and a ring written by racing threads
123/// never defined one.
124pub fn record(stat: KernelStat) {
125    RING.with(|cell| {
126        let ring = match cell.get() {
127            Some(ring) => ring,
128            None => {
129                let ring = RING_ARENA.claim();
130                cell.set(Some(ring));
131                ring
132            }
133        };
134        if let Ok(mut guard) = ring.lock() {
135            if guard.len() == MAX_STATS {
136                guard.pop_front();
137            }
138            guard.push_back(stat);
139        }
140    });
141}
142
143/// Every recorded dispatch, from every thread that has recorded one.
144pub fn snapshot() -> KernelStatsSnapshot {
145    let mut stats = Vec::new();
146    for ring in RING_ARENA.claimed() {
147        if let Ok(guard) = ring.lock() {
148            stats.extend(guard.iter().cloned());
149        }
150    }
151    KernelStatsSnapshot { stats }
152}
153
154/// Empty every ring.
155pub fn clear() {
156    for ring in RING_ARENA.claimed() {
157        if let Ok(mut guard) = ring.lock() {
158            guard.clear();
159        }
160    }
161}
162
163// ---------------------------------------------------------------------------
164// GPU execution telemetry (issue #1017).
165//
166// The original `used_device: bool` could report `true` while the device had
167// silently declined the workload and the solve ran on the CPU. A boolean
168// cannot expose that: it carries no count of handles created, factorizations
169// run, kernels launched, or — critically — CPU fallbacks taken and why. These
170// per-thread counters make the resident solver's actual device activity
171// auditable, so a silent fallback shows up as `cpu_fallback_count > 0` with a
172// recorded reason rather than a lie. They are observability only and never
173// change any numerical result.
174// ---------------------------------------------------------------------------
175
176use std::cell::RefCell;
177
178/// Monotonic counters describing what the GPU-resident solver actually did on
179/// the current thread. Snapshot with [`telemetry_snapshot`]; reset with
180/// [`telemetry_reset`].
181#[derive(Clone, Debug, Default, PartialEq, Eq)]
182pub struct GpuExecutionTelemetry {
183    /// Bytes uploaded host→device.
184    pub h2d_bytes: usize,
185    /// Bytes read back device→host.
186    pub d2h_bytes: usize,
187    /// Cholesky / Schur factorizations performed on the device.
188    pub factorization_count: usize,
189    /// cuBLAS / cuSOLVER / stream handle creations.
190    pub handle_creation_count: usize,
191    /// Device kernel launches (per-row + border solves).
192    pub kernel_launch_count: usize,
193    /// Times a path that intended to use the device fell back to the CPU.
194    pub cpu_fallback_count: usize,
195    /// Human-readable reasons recorded alongside each CPU fallback.
196    pub cpu_fallback_reasons: Vec<String>,
197    /// Opaque context identifier of the device this thread last touched
198    /// (e.g. the CUDA device ordinal), `0` when no device was used.
199    pub context_id: usize,
200}
201
202thread_local! {
203    static EXECUTION_TELEMETRY: RefCell<GpuExecutionTelemetry> =
204        RefCell::new(GpuExecutionTelemetry::default());
205}
206
207/// Mutate the calling thread's execution telemetry in place.
208#[inline]
209pub fn telemetry_with<R>(f: impl FnOnce(&mut GpuExecutionTelemetry) -> R) -> R {
210    EXECUTION_TELEMETRY.with(|cell| f(&mut cell.borrow_mut()))
211}
212
213/// Record a host→device upload of `bytes`.
214#[inline]
215pub fn telemetry_record_h2d(bytes: usize) {
216    telemetry_with(|t| t.h2d_bytes += bytes);
217}
218
219/// Record a device→host readback of `bytes`.
220#[inline]
221pub fn telemetry_record_d2h(bytes: usize) {
222    telemetry_with(|t| t.d2h_bytes += bytes);
223}
224
225/// Record a device factorization (POTRF / Schur factor).
226#[inline]
227pub fn telemetry_record_factorization() {
228    telemetry_with(|t| t.factorization_count += 1);
229}
230
231/// Record creation of a device handle/stream and the context it bound.
232#[inline]
233pub fn telemetry_record_handle_creation(context_id: usize) {
234    telemetry_with(|t| {
235        t.handle_creation_count += 1;
236        t.context_id = context_id;
237    });
238}
239
240/// Record a device kernel launch.
241#[inline]
242pub fn telemetry_record_kernel_launch() {
243    telemetry_with(|t| t.kernel_launch_count += 1);
244}
245
246/// Record a CPU fallback together with the reason it happened. This is the
247/// counter that would have exposed the original silent-fallback bug.
248#[inline]
249pub fn telemetry_record_cpu_fallback(reason: impl Into<String>) {
250    telemetry_with(|t| {
251        t.cpu_fallback_count += 1;
252        t.cpu_fallback_reasons.push(reason.into());
253    });
254}
255
256/// Snapshot the calling thread's execution telemetry.
257#[must_use]
258pub fn telemetry_snapshot() -> GpuExecutionTelemetry {
259    telemetry_with(|t| t.clone())
260}
261
262/// Reset the calling thread's execution telemetry to zero.
263pub fn telemetry_reset() {
264    telemetry_with(|t| *t = GpuExecutionTelemetry::default());
265}
266
267#[cfg(test)]
268mod dispatch_ring_979_tests {
269    use super::*;
270
271    /// The registry is process-wide, and `cargo test` runs these cases on
272    /// concurrent threads, so each one takes this first: without it a sibling's
273    /// `clear` empties the ring another case is counting
274    /// ([[a test verdict must not depend on which tests share the process]]).
275    static EXCLUSIVE: Mutex<()> = Mutex::new(());
276
277    fn exclusive() -> std::sync::MutexGuard<'static, ()> {
278        EXCLUSIVE
279            .lock()
280            .unwrap_or_else(|poisoned| poisoned.into_inner())
281    }
282
283    fn stat(name: &'static str, n: usize) -> KernelStat {
284        KernelStat {
285            name,
286            n,
287            ..Default::default()
288        }
289    }
290
291    /// The ring is per thread, so the aggregate a consumer reads must still be
292    /// every thread's attempts — the property one global ring gave by
293    /// construction and this design has to provide explicitly.
294    ///
295    /// The threads record CONCURRENTLY and then exit, so this covers both
296    /// registration of a live ring and the survival of a departed thread's
297    /// ring. A design that registered a ring once and then overwrote it, or
298    /// that let a worker pool's history vanish at shutdown, reports a
299    /// truncated run and lands here.
300    #[test]
301    fn every_thread_dispatch_reaches_one_snapshot() {
302        const THREADS: usize = 8;
303        const PER_THREAD: usize = 32;
304        let exclusive = exclusive();
305        clear();
306        // Hold every thread open until all of them have recorded, so the rings
307        // genuinely coexist instead of being visited one after another by a
308        // scheduler that serialises the spawns.
309        let recorded = std::sync::Barrier::new(THREADS);
310        std::thread::scope(|scope| {
311            for thread in 0..THREADS {
312                let recorded = &recorded;
313                scope.spawn(move || {
314                    for index in 0..PER_THREAD {
315                        record(stat("ring_test", thread * PER_THREAD + index));
316                    }
317                    recorded.wait();
318                });
319            }
320        });
321        let recorded = snapshot().stats;
322        assert_eq!(
323            recorded.len(),
324            THREADS * PER_THREAD,
325            "every thread's dispatches must reach the snapshot"
326        );
327        let mut seen: Vec<usize> = recorded.iter().map(|entry| entry.n).collect();
328        seen.sort_unstable();
329        let expected: Vec<usize> = (0..THREADS * PER_THREAD).collect();
330        assert_eq!(
331            seen, expected,
332            "no thread's dispatches may be lost or duplicated"
333        );
334        clear();
335        drop(exclusive);
336    }
337
338    /// `clear` empties every ring, not only the caller's, and a dispatch after
339    /// it is visible again.
340    #[test]
341    fn clear_empties_every_ring_and_recording_resumes() {
342        let exclusive = exclusive();
343        clear();
344        std::thread::scope(|scope| {
345            scope.spawn(|| record(stat("before_clear", 1)));
346        });
347        record(stat("before_clear", 2));
348        assert_eq!(
349            snapshot().stats.len(),
350            2,
351            "a departed thread's attempt and the caller's must both be visible"
352        );
353        clear();
354        assert!(
355            snapshot().stats.is_empty(),
356            "clear must empty other threads' rings too, not only the caller's"
357        );
358        record(stat("after_clear", 3));
359        let after = snapshot().stats;
360        assert_eq!(after.len(), 1, "recording must resume after a clear");
361        assert_eq!(after[0].n, 3);
362        clear();
363        drop(exclusive);
364    }
365
366    /// A ring is bounded: the oldest attempt is dropped, never the newest, so
367    /// the diagnostic is the tail of the run and not a leak.
368    #[test]
369    fn a_rings_capacity_drops_the_oldest_attempt() {
370        let exclusive = exclusive();
371        clear();
372        std::thread::scope(|scope| {
373            scope.spawn(|| {
374                for index in 0..(MAX_STATS + 16) {
375                    record(stat("bounded", index));
376                }
377            });
378        });
379        let recorded = snapshot().stats;
380        assert_eq!(
381            recorded.len(),
382            MAX_STATS,
383            "the ring is capped at its capacity"
384        );
385        assert_eq!(recorded[0].n, 16, "the oldest attempts are the ones dropped");
386        assert_eq!(
387            recorded[MAX_STATS - 1].n,
388            MAX_STATS + 15,
389            "the newest attempt is kept"
390        );
391        clear();
392        drop(exclusive);
393    }
394}