1use alloc::string::String;
7use alloc::vec::Vec;
8
9use crate::recording::ProfileRecording;
10use crate::timeline::{LeaseTimeline, LeaseTimelineEntry};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum WasteClassification {
15 Overprovisioned,
17 Idle,
19 Fragmented,
21 PremiumWaste,
23 Healthy,
25}
26
27#[derive(Debug, Clone)]
29pub struct WasteEntry {
30 pub lease_id: u128,
32 pub classification: WasteClassification,
34 pub utilization_pct: f64,
36 pub active_pct: f64,
38 pub cost_byte_secs: u64,
40 pub waste_byte_secs: u64,
42 pub recommendation: String,
44}
45
46#[derive(Debug, Clone)]
48pub struct WasteReport {
49 pub entries: Vec<WasteEntry>,
51 pub total_waste_byte_secs: u64,
53 pub total_cost_byte_secs: u64,
55 pub fragmented_groups: usize,
57}
58
59const OVERPROVISIONED_THRESHOLD: f64 = 25.0;
61const IDLE_THRESHOLD: f64 = 10.0;
62
63impl WasteReport {
64 pub fn from_recording(rec: &ProfileRecording) -> Self {
66 let timeline = LeaseTimeline::from_recording(rec);
67 Self::from_timeline(&timeline, rec)
68 }
69
70 pub fn from_timeline(timeline: &LeaseTimeline, rec: &ProfileRecording) -> Self {
72 let mut entries = Vec::new();
73 let total_duration = timeline.end_time.saturating_sub(timeline.start_time);
74
75 for tl_entry in &timeline.entries {
76 let entry = classify_lease(tl_entry, total_duration, rec);
77 entries.push(entry);
78 }
79
80 let fragmented_groups = detect_fragmentation(&timeline.entries);
82
83 if fragmented_groups > 0 {
84 let avg_cost = if entries.is_empty() {
85 0
86 } else {
87 entries.iter().map(|e| e.cost_byte_secs).sum::<u64>() / entries.len() as u64
88 };
89 for entry in &mut entries {
90 if entry.classification == WasteClassification::Healthy
91 && avg_cost > 0
92 && entry.cost_byte_secs < avg_cost / 4
93 {
94 entry.classification = WasteClassification::Fragmented;
95 entry.recommendation = String::from(
96 "use a single lease with arena allocation instead of many small leases",
97 );
98 }
99 }
100 }
101
102 entries.sort_by(|a, b| b.waste_byte_secs.cmp(&a.waste_byte_secs));
103
104 let total_waste_byte_secs = entries.iter().map(|e| e.waste_byte_secs).sum();
105 let total_cost_byte_secs = entries.iter().map(|e| e.cost_byte_secs).sum();
106
107 WasteReport {
108 entries,
109 total_waste_byte_secs,
110 total_cost_byte_secs,
111 fragmented_groups,
112 }
113 }
114
115 #[cfg(feature = "std")]
117 pub fn render_text(&self) -> String {
118 let mut out = String::new();
119 out.push_str("grafOS Waste Report\n");
120 out.push_str(&alloc::format!(
121 "Total cost: {} byte-secs | Total waste: {} byte-secs ({:.1}%)\n\n",
122 self.total_cost_byte_secs,
123 self.total_waste_byte_secs,
124 if self.total_cost_byte_secs > 0 {
125 (self.total_waste_byte_secs as f64 / self.total_cost_byte_secs as f64) * 100.0
126 } else {
127 0.0
128 }
129 ));
130
131 out.push_str(&alloc::format!(
132 "{:<16} {:<18} {:>8} {:>8} {:>12} {}\n",
133 "Lease",
134 "Classification",
135 "Util%",
136 "Active%",
137 "Waste",
138 "Recommendation"
139 ));
140 out.push_str(&alloc::format!("{}\n", "-".repeat(100)));
141
142 let top_n = 10.min(self.entries.len());
143 for entry in &self.entries[..top_n] {
144 let lease_hex = alloc::format!("{:x}", entry.lease_id);
145 let short = if lease_hex.len() > 12 {
146 &lease_hex[..12]
147 } else {
148 &lease_hex
149 };
150 let class_str = match entry.classification {
151 WasteClassification::Overprovisioned => "Overprovisioned",
152 WasteClassification::Idle => "Idle",
153 WasteClassification::Fragmented => "Fragmented",
154 WasteClassification::PremiumWaste => "PremiumWaste",
155 WasteClassification::Healthy => "Healthy",
156 };
157 out.push_str(&alloc::format!(
158 "{:<16} {:<18} {:>7.1} {:>7.1} {:>12} {}\n",
159 short,
160 class_str,
161 entry.utilization_pct,
162 entry.active_pct,
163 entry.waste_byte_secs,
164 entry.recommendation
165 ));
166 }
167
168 out
169 }
170
171 #[cfg(feature = "html")]
173 pub fn render_html(&self) -> String {
174 let mut html = String::new();
175 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
176 html.push_str("<meta charset=\"utf-8\">\n");
177 html.push_str("<title>grafOS Waste Report</title>\n");
178 html.push_str("<style>\n");
179 html.push_str("body { font-family: monospace; margin: 0; padding: 20px; background: #1a1a2e; color: #eee; }\n");
180 html.push_str("h1 { font-size: 18px; }\n");
181 html.push_str("table { border-collapse: collapse; width: 100%; margin-top: 20px; }\n");
182 html.push_str(
183 "th, td { padding: 6px 12px; text-align: left; border-bottom: 1px solid #333; }\n",
184 );
185 html.push_str("th { background: #2a2a4a; }\n");
186 html.push_str("tr:hover { background: #2a2a3a; }\n");
187 html.push_str(".overprov { color: #e74c3c; }\n");
188 html.push_str(".idle { color: #f39c12; }\n");
189 html.push_str(".fragmented { color: #9b59b6; }\n");
190 html.push_str(".premium { color: #e67e22; }\n");
191 html.push_str(".healthy { color: #2ecc71; }\n");
192 html.push_str(".summary { margin: 15px 0; padding: 15px; background: #2a2a4a; border-radius: 6px; }\n");
193 html.push_str("</style>\n</head>\n<body>\n");
194 html.push_str("<h1>grafOS Waste Report</h1>\n");
195
196 let waste_pct = if self.total_cost_byte_secs > 0 {
197 (self.total_waste_byte_secs as f64 / self.total_cost_byte_secs as f64) * 100.0
198 } else {
199 0.0
200 };
201 html.push_str(&alloc::format!(
202 "<div class=\"summary\">Total cost: {} byte-secs | Waste: {} byte-secs ({:.1}%)</div>\n",
203 self.total_cost_byte_secs, self.total_waste_byte_secs, waste_pct
204 ));
205
206 html.push_str("<table>\n<tr><th>Lease</th><th>Classification</th><th>Util%</th><th>Active%</th><th>Waste</th><th>Recommendation</th></tr>\n");
207
208 for entry in &self.entries {
209 let lease_hex = alloc::format!("{:x}", entry.lease_id);
210 let (class_str, class_css) = match entry.classification {
211 WasteClassification::Overprovisioned => ("Overprovisioned", "overprov"),
212 WasteClassification::Idle => ("Idle", "idle"),
213 WasteClassification::Fragmented => ("Fragmented", "fragmented"),
214 WasteClassification::PremiumWaste => ("PremiumWaste", "premium"),
215 WasteClassification::Healthy => ("Healthy", "healthy"),
216 };
217 html.push_str(&alloc::format!(
218 "<tr><td>{}</td><td class=\"{}\">{}</td><td>{:.1}</td><td>{:.1}</td><td>{}</td><td>{}</td></tr>\n",
219 lease_hex, class_css, class_str,
220 entry.utilization_pct, entry.active_pct,
221 entry.waste_byte_secs, entry.recommendation
222 ));
223 }
224
225 html.push_str("</table>\n</body>\n</html>\n");
226 html
227 }
228}
229
230fn classify_lease(
231 entry: &LeaseTimelineEntry,
232 _total_duration: u64,
233 rec: &ProfileRecording,
234) -> WasteEntry {
235 let utilization_pct = entry.utilization_pct();
236 let duration = entry.duration_us();
237 let active_pct = compute_active_pct(entry.lease_id, duration, rec);
238
239 let waste_byte_secs = if entry.capacity_approx > entry.peak_usage {
240 let unused_bytes = entry.capacity_approx - entry.peak_usage;
241 let duration_secs = duration as f64 / 1_000_000.0;
242 (unused_bytes as f64 * duration_secs) as u64
243 } else {
244 0
245 };
246
247 let (classification, recommendation) =
248 if utilization_pct < OVERPROVISIONED_THRESHOLD && entry.capacity_approx > 0 {
249 let recommended = (entry.peak_usage as f64 * 1.2) as u64;
250 (
251 WasteClassification::Overprovisioned,
252 alloc::format!(
253 "reduce capacity to {} (peak {} + 20% headroom)",
254 format_bytes_short(recommended),
255 format_bytes_short(entry.peak_usage)
256 ),
257 )
258 } else if active_pct < IDLE_THRESHOLD && duration > 0 {
259 (
260 WasteClassification::Idle,
261 String::from("consider on-demand lease acquisition instead of long-lived lease"),
262 )
263 } else {
264 (WasteClassification::Healthy, String::new())
265 };
266
267 WasteEntry {
268 lease_id: entry.lease_id,
269 classification,
270 utilization_pct,
271 active_pct,
272 cost_byte_secs: entry.lease_cost_byte_secs,
273 waste_byte_secs,
274 recommendation,
275 }
276}
277
278fn compute_active_pct(lease_id: u128, lease_duration_us: u64, rec: &ProfileRecording) -> f64 {
279 if lease_duration_us == 0 {
280 return 100.0;
281 }
282 let active_us: u64 = rec
283 .spans
284 .iter()
285 .filter(|s| s.lease_ids.contains(&lease_id))
286 .map(|s| s.duration_us())
287 .sum();
288 let pct = (active_us as f64 / lease_duration_us as f64) * 100.0;
289 pct.min(100.0)
290}
291
292fn detect_fragmentation(entries: &[LeaseTimelineEntry]) -> usize {
293 use alloc::collections::BTreeMap;
294
295 let mut by_type: BTreeMap<&str, Vec<&LeaseTimelineEntry>> = BTreeMap::new();
296 for entry in entries {
297 let key = match entry.resource_type {
298 Some(grafos_observe::event::ResourceType::Mem) => "mem",
299 Some(grafos_observe::event::ResourceType::Block) => "block",
300 Some(grafos_observe::event::ResourceType::Gpu) => "gpu",
301 Some(grafos_observe::event::ResourceType::Cpu) => "cpu",
302 Some(grafos_observe::event::ResourceType::Net) => "net",
303 None => "unknown",
304 };
305 by_type.entry(key).or_default().push(entry);
306 }
307
308 let mut groups = 0;
309 for leases in by_type.values() {
310 if leases.len() > 4 {
311 let any_overlap = leases.windows(2).any(|w| {
312 w[0].released_at > w[1].acquired_at && w[0].acquired_at < w[1].released_at
313 });
314 if any_overlap {
315 groups += 1;
316 }
317 }
318 }
319 groups
320}
321
322fn format_bytes_short(bytes: u64) -> String {
323 if bytes >= 1_073_741_824 {
324 alloc::format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
325 } else if bytes >= 1_048_576 {
326 alloc::format!("{:.1} MB", bytes as f64 / 1_048_576.0)
327 } else if bytes >= 1024 {
328 alloc::format!("{:.1} KB", bytes as f64 / 1024.0)
329 } else {
330 alloc::format!("{} B", bytes)
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use grafos_observe::event::{OpType, ResourceType};
338 use grafos_observe::span::ResourceSpan;
339 use grafos_observe::trace::TraceContext;
340
341 fn test_ctx() -> TraceContext {
342 let mut bytes = [0u8; 24];
343 for (i, b) in bytes.iter_mut().enumerate() {
344 *b = (i as u8).wrapping_add(0x42);
345 }
346 TraceContext::new_root(&bytes)
347 }
348
349 #[test]
350 fn empty_recording_no_waste() {
351 let rec = ProfileRecording::from_spans(Vec::new());
352 let report = WasteReport::from_recording(&rec);
353 assert_eq!(report.total_waste_byte_secs, 0);
354 assert!(report.entries.is_empty());
355 }
356
357 #[test]
358 fn overprovisioned_lease() {
359 let ctx = test_ctx();
360
361 let mut span = ResourceSpan::new("big_alloc", ctx);
363 span.start_time_unix_us = 0;
364 span.end_time_unix_us = 10_000_000; span.add_lease_id(1);
366 span.bytes_read = 100; span.lease_cost_byte_secs = 10_000; span.record_op(ResourceType::Mem, OpType::Read, 1);
369
370 let rec = ProfileRecording::from_spans(vec![span]);
371 let report = WasteReport::from_recording(&rec);
372
373 assert_eq!(report.entries.len(), 1);
374 let entry = &report.entries[0];
375 assert_eq!(entry.classification, WasteClassification::Overprovisioned);
376 assert!(entry.utilization_pct < OVERPROVISIONED_THRESHOLD);
377 assert!(entry.recommendation.contains("reduce capacity"));
378 }
379
380 #[test]
381 fn idle_lease_detected() {
382 let ctx = test_ctx();
383 let mut span = ResourceSpan::new("idle_op", ctx);
384 span.start_time_unix_us = 1_000_000;
385 span.end_time_unix_us = 2_000_000;
386 span.lease_cost_byte_secs = 4096;
387 span.add_lease_id(1);
388 let rec = ProfileRecording::from_spans(vec![span]);
393 let report = WasteReport::from_recording(&rec);
394 assert_eq!(report.entries.len(), 1);
397 }
398
399 #[test]
400 fn active_lease_no_waste() {
401 let ctx = test_ctx();
402 let mut span = ResourceSpan::new("active_op", ctx);
403 span.start_time_unix_us = 0;
404 span.end_time_unix_us = 1_000_000;
405 span.add_lease_id(1);
406 span.bytes_read = 5000;
407 span.bytes_written = 5000;
408 span.lease_cost_byte_secs = 10_000;
409 span.record_op(ResourceType::Mem, OpType::Read, 100);
410
411 let rec = ProfileRecording::from_spans(vec![span]);
412 let report = WasteReport::from_recording(&rec);
413
414 assert_eq!(report.entries.len(), 1);
415 assert_eq!(
416 report.entries[0].classification,
417 WasteClassification::Healthy
418 );
419 }
420
421 #[cfg(feature = "std")]
422 #[test]
423 fn text_output() {
424 let ctx = test_ctx();
425 let mut span = ResourceSpan::new("test", ctx);
426 span.start_time_unix_us = 0;
427 span.end_time_unix_us = 1_000_000;
428 span.add_lease_id(1);
429 span.lease_cost_byte_secs = 100;
430 span.record_op(ResourceType::Mem, OpType::Read, 1);
431
432 let rec = ProfileRecording::from_spans(vec![span]);
433 let report = WasteReport::from_recording(&rec);
434 let text = report.render_text();
435
436 assert!(text.contains("Waste Report"));
437 assert!(text.contains("Lease"));
438 }
439
440 #[cfg(feature = "html")]
441 #[test]
442 fn html_output() {
443 let ctx = test_ctx();
444 let mut span = ResourceSpan::new("test", ctx);
445 span.start_time_unix_us = 0;
446 span.end_time_unix_us = 1_000_000;
447 span.add_lease_id(1);
448 span.lease_cost_byte_secs = 100;
449
450 let rec = ProfileRecording::from_spans(vec![span]);
451 let report = WasteReport::from_recording(&rec);
452 let html = report.render_html();
453
454 assert!(html.contains("<!DOCTYPE html>"));
455 assert!(html.contains("Waste Report"));
456 assert!(html.contains("</html>"));
457 }
458}