spark_runtime/cuda_backend/alloc_ledger.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The device-allocation ledger: what is live, how big it is, and which call
4//! site asked for it.
5//!
6//! On GB10 every `cuMemAlloc` consumes host RAM, so an allocation outside the
7//! util pledge is how the box ends up in swap — and before this existed the
8//! KV budget inferred "Atlas-own" bytes from a free-memory delta, which counts
9//! a co-tenant's pages as ours. The ledger replaces that inference with a
10//! measurement.
11//!
12//! Split from `cuda_backend.rs` for the 500-LoC cap, which the file crossed
13//! when this landed. Exact piecewise copy, with one correction: the doc
14//! comment above `record_alloc` described the `registry` field, not the
15//! method, on main.
16
17use super::AtlasCudaBackend;
18
19/// One live device allocation: how big it is and who asked for it.
20///
21/// `site` is the CALLER of `GpuBackend::alloc`, not this file, because both
22/// allocating methods are `#[track_caller]`. Costs one pointer per live
23/// allocation and nothing at all per kernel launch — allocation is a
24/// load-time event, not a hot-path one.
25#[derive(Clone, Copy)]
26pub(super) struct AllocRecord {
27 pub(super) bytes: usize,
28 pub(super) site: &'static std::panic::Location<'static>,
29}
30
31impl AtlasCudaBackend {
32 /// Enter an allocation in the ledger. `site` is the CALLER of
33 /// `GpuBackend::alloc` (both allocating methods are `#[track_caller]`),
34 /// which is what makes `alloc_report` name a file rather than this one.
35 pub(crate) fn record_alloc(
36 &self,
37 ptr: crate::gpu::DevicePtr,
38 bytes: usize,
39 site: &'static std::panic::Location<'static>,
40 ) {
41 self.live_allocs
42 .lock()
43 .insert(ptr.0, AllocRecord { bytes, site });
44 }
45
46 pub(crate) fn forget_alloc(&self, ptr: crate::gpu::DevicePtr) {
47 self.live_allocs.lock().remove(&ptr.0);
48 }
49
50 /// Total live device bytes this backend has allocated and not freed.
51 pub fn live_bytes(&self) -> usize {
52 self.live_allocs.lock().values().map(|r| r.bytes).sum()
53 }
54
55 /// Human-readable attribution of live device memory, biggest site first.
56 ///
57 /// Aggregated by allocating call site rather than by pointer: one site
58 /// looping over 48 SSM layers is one line reading 9.7 GB across 48
59 /// allocations, which is the shape that makes an over-sized pool obvious.
60 /// Sites below `min_mb` are folded into a remainder line so the report
61 /// stays readable while still summing to the true total.
62 pub fn alloc_report(&self, top_n: usize, min_mb: usize) -> String {
63 use std::collections::HashMap;
64 let mut by_site: HashMap<String, (usize, usize)> = HashMap::new();
65 let mut total = 0usize;
66 for rec in self.live_allocs.lock().values() {
67 total += rec.bytes;
68 let key = format!("{}:{}", rec.site.file(), rec.site.line());
69 let e = by_site.entry(key).or_insert((0, 0));
70 e.0 += rec.bytes;
71 e.1 += 1;
72 }
73 let mut rows: Vec<(String, usize, usize)> =
74 by_site.into_iter().map(|(k, v)| (k, v.0, v.1)).collect();
75 rows.sort_by(|a, b| b.1.cmp(&a.1));
76
77 let mut out = format!(
78 "GPU allocation ledger: {:.2} GB live across {} sites\n",
79 total as f64 / 1e9,
80 rows.len()
81 );
82 let mut shown = 0usize;
83 let mut folded_bytes = 0usize;
84 let mut folded_sites = 0usize;
85 for (site, bytes, count) in rows {
86 if shown < top_n && bytes >= min_mb * 1024 * 1024 {
87 out.push_str(&format!(
88 " {:>9.1} MB x{:<5} {}\n",
89 bytes as f64 / (1024.0 * 1024.0),
90 count,
91 site
92 ));
93 shown += 1;
94 } else {
95 folded_bytes += bytes;
96 folded_sites += 1;
97 }
98 }
99 if folded_sites > 0 {
100 out.push_str(&format!(
101 " {:>9.1} MB across {} smaller sites\n",
102 folded_bytes as f64 / (1024.0 * 1024.0),
103 folded_sites
104 ));
105 }
106
107 // Per-FILE rollup. The by-site view above has a blind spot: a
108 // subsystem that allocates many distinct buffers from many distinct
109 // lines is split into pieces small enough to fall below the cut and
110 // vanish into the remainder. The vision encoder is exactly that shape
111 // — ~16 buffers (scores, probs, qr/kr/vt, merge, rope, ...) each from
112 // its own line — so ~2.2 GB was invisible in a top-12 by site while
113 // being the fourth-largest consumer in the process. Rolling up by
114 // file costs one more pass over the same map and makes a subsystem
115 // legible as a subsystem.
116 let mut by_file: HashMap<&str, (usize, usize)> = HashMap::new();
117 for rec in self.live_allocs.lock().values() {
118 let e = by_file.entry(rec.site.file()).or_insert((0, 0));
119 e.0 += rec.bytes;
120 e.1 += 1;
121 }
122 let mut frows: Vec<(&str, usize, usize)> =
123 by_file.into_iter().map(|(k, v)| (k, v.0, v.1)).collect();
124 frows.sort_by(|a, b| b.1.cmp(&a.1));
125 out.push_str(" ── by file ──\n");
126 for (file, bytes, count) in frows.into_iter().take(top_n) {
127 out.push_str(&format!(
128 " {:>9.1} MB x{:<5} {}\n",
129 bytes as f64 / (1024.0 * 1024.0),
130 count,
131 file
132 ));
133 }
134 out
135 }
136}