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}