1use std::collections::{BTreeMap, HashSet, VecDeque};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Mutex, OnceLock};
12use std::thread::ThreadId;
13use std::time::Instant;
14
15use rns_core::msgpack::Value;
16
17use crate::pickle::PickleValue;
18
19pub const MAX_CAPTURES: usize = 10_000;
21
22#[derive(Debug, Clone, Copy)]
23struct Capture {
24 id: u64,
25 started: f64,
26 ended: Option<f64>,
27 thread_id: ThreadId,
28}
29
30impl Capture {
31 fn duration(self) -> Option<f64> {
32 self.ended.map(|ended| ended - self.started)
33 }
34}
35
36#[derive(Debug)]
37struct TagCaptures {
38 parent: Option<String>,
39 max_captures: usize,
40 captures: VecDeque<Capture>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct ProfilingStats {
45 pub count: usize,
46 pub mean: f64,
47 pub median: f64,
48 pub min: f64,
49 pub max: f64,
50 pub stdev: Option<f64>,
51 pub sum: f64,
52 pub threads: Option<usize>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct ProfilingResult {
57 pub name: String,
58 pub parent: Option<String>,
59 pub stats_all: ProfilingStats,
60 pub stats_1m: Option<ProfilingStats>,
61 pub stats_5m: Option<ProfilingStats>,
62 pub stats_30m: Option<ProfilingStats>,
63 pub stats_60m: Option<ProfilingStats>,
64}
65
66static EPOCH: OnceLock<Instant> = OnceLock::new();
67static NEXT_CAPTURE_ID: AtomicU64 = AtomicU64::new(1);
68static CAPTURES: OnceLock<Mutex<BTreeMap<String, TagCaptures>>> = OnceLock::new();
69
70fn elapsed_now() -> f64 {
71 EPOCH.get_or_init(Instant::now).elapsed().as_secs_f64()
72}
73
74fn captures() -> &'static Mutex<BTreeMap<String, TagCaptures>> {
75 CAPTURES.get_or_init(|| Mutex::new(BTreeMap::new()))
76}
77
78#[must_use = "the guard must be retained for the duration being profiled"]
80pub struct ProfileGuard {
81 tag: String,
82 capture_id: Option<u64>,
83}
84
85impl Drop for ProfileGuard {
86 fn drop(&mut self) {
87 if let Some(capture_id) = self.capture_id {
88 complete_capture(&self.tag, capture_id, elapsed_now());
89 }
90 }
91}
92
93fn begin_capture(
94 tag: &str,
95 parent: Option<&str>,
96 max_captures: usize,
97 started: f64,
98 thread_id: ThreadId,
99) -> Option<u64> {
100 if max_captures == 0 {
101 return None;
102 }
103 let capture_id = NEXT_CAPTURE_ID.fetch_add(1, Ordering::Relaxed);
104 let mut registry = captures()
105 .lock()
106 .unwrap_or_else(|poisoned| poisoned.into_inner());
107 let entry = registry
108 .entry(tag.to_owned())
109 .or_insert_with(|| TagCaptures {
110 parent: parent.map(str::to_owned),
111 max_captures,
112 captures: VecDeque::with_capacity(max_captures.min(1024)),
113 });
114 if entry.parent.is_none() {
115 entry.parent = parent.map(str::to_owned);
116 }
117 while entry.captures.len() >= entry.max_captures {
118 entry.captures.pop_front();
119 }
120 entry.captures.push_back(Capture {
121 id: capture_id,
122 started,
123 ended: None,
124 thread_id,
125 });
126 Some(capture_id)
127}
128
129fn complete_capture(tag: &str, capture_id: u64, ended: f64) {
130 let mut registry = captures()
131 .lock()
132 .unwrap_or_else(|poisoned| poisoned.into_inner());
133 let Some(entry) = registry.get_mut(tag) else {
134 return;
135 };
136 if let Some(capture) = entry
137 .captures
138 .iter_mut()
139 .find(|capture| capture.id == capture_id)
140 {
141 capture.ended = Some(ended);
142 }
143}
144
145fn start_profile(tag: String, parent: Option<String>, max_captures: usize) -> ProfileGuard {
146 let capture_id = begin_capture(
147 &tag,
148 parent.as_deref(),
149 max_captures,
150 elapsed_now(),
151 std::thread::current().id(),
152 );
153 ProfileGuard { tag, capture_id }
154}
155
156pub fn profile(tag: impl Into<String>) -> ProfileGuard {
158 start_profile(tag.into(), None, MAX_CAPTURES)
159}
160
161pub fn profile_with_limit(tag: impl Into<String>, max_captures: usize) -> ProfileGuard {
163 start_profile(tag.into(), None, max_captures)
164}
165
166pub fn profile_with_parent(tag: impl Into<String>, parent: impl Into<String>) -> ProfileGuard {
168 start_profile(tag.into(), Some(parent.into()), MAX_CAPTURES)
169}
170
171pub fn profile_with_parent_and_limit(
173 tag: impl Into<String>,
174 parent: impl Into<String>,
175 max_captures: usize,
176) -> ProfileGuard {
177 start_profile(tag.into(), Some(parent.into()), max_captures)
178}
179
180pub fn profile_function<T>(tag: impl Into<String>, function: impl FnOnce() -> T) -> T {
187 let _profile = profile(tag);
188 function()
189}
190
191pub fn profile_function_with_limit<T>(
194 tag: impl Into<String>,
195 max_captures: usize,
196 function: impl FnOnce() -> T,
197) -> T {
198 let _profile = profile_with_limit(tag, max_captures);
199 function()
200}
201
202pub fn ran() -> bool {
204 captures()
205 .lock()
206 .unwrap_or_else(|poisoned| poisoned.into_inner())
207 .values()
208 .any(|entry| entry.captures.iter().any(|capture| capture.ended.is_some()))
209}
210
211fn calculate_stats(captures: &[Capture], include_threads: bool) -> Option<ProfilingStats> {
212 let completed: Vec<(f64, ThreadId)> = captures
213 .iter()
214 .filter_map(|capture| {
215 capture
216 .duration()
217 .map(|duration| (duration, capture.thread_id))
218 })
219 .collect();
220 if completed.is_empty() {
221 return None;
222 }
223 let mut durations: Vec<f64> = completed.iter().map(|(duration, _)| *duration).collect();
224 durations.sort_by(f64::total_cmp);
225 let count = durations.len();
226 let sum = durations.iter().sum::<f64>();
227 let mean = sum / count as f64;
228 let median = if count.is_multiple_of(2) {
229 (durations[count / 2 - 1] + durations[count / 2]) / 2.0
230 } else {
231 durations[count / 2]
232 };
233 let stdev = (count > 1).then(|| {
234 let variance = durations
235 .iter()
236 .map(|duration| (duration - mean).powi(2))
237 .sum::<f64>()
238 / (count - 1) as f64;
239 variance.sqrt()
240 });
241 let threads = include_threads.then(|| {
242 completed
243 .iter()
244 .map(|(_, thread_id)| *thread_id)
245 .collect::<HashSet<_>>()
246 .len()
247 });
248 Some(ProfilingStats {
249 count,
250 mean,
251 median,
252 min: durations[0],
253 max: durations[count - 1],
254 stdev,
255 sum,
256 threads,
257 })
258}
259
260fn window_stats(
261 captures: &[Capture],
262 now: f64,
263 youngest_age: f64,
264 oldest_age: f64,
265) -> Option<ProfilingStats> {
266 let window: Vec<Capture> = captures
267 .iter()
268 .copied()
269 .filter(|capture| {
270 let age = now - capture.started;
271 age >= youngest_age && age < oldest_age
272 })
273 .collect();
274 calculate_stats(&window, false)
275}
276
277pub fn results() -> BTreeMap<String, ProfilingResult> {
279 let now = elapsed_now();
280 captures()
281 .lock()
282 .unwrap_or_else(|poisoned| poisoned.into_inner())
283 .iter()
284 .filter_map(|(tag, entry)| {
285 let mut tag_captures: Vec<Capture> = entry.captures.iter().copied().collect();
286 tag_captures.sort_by(|left, right| left.started.total_cmp(&right.started));
287 let stats_all = calculate_stats(&tag_captures, true)?;
288 Some((
289 tag.clone(),
290 ProfilingResult {
291 name: tag.clone(),
292 parent: entry.parent.clone(),
293 stats_all,
294 stats_1m: window_stats(&tag_captures, now, 0.0, 60.0),
295 stats_5m: window_stats(&tag_captures, now, 60.0, 5.0 * 60.0),
296 stats_30m: window_stats(&tag_captures, now, 5.0 * 60.0, 30.0 * 60.0),
297 stats_60m: window_stats(&tag_captures, now, 30.0 * 60.0, 60.0 * 60.0),
298 },
299 ))
300 })
301 .collect()
302}
303
304fn pickle_key(key: &str) -> PickleValue {
305 PickleValue::String(key.into())
306}
307
308fn stats_to_pickle(stats: Option<&ProfilingStats>) -> PickleValue {
309 let Some(stats) = stats else {
310 return PickleValue::None;
311 };
312 PickleValue::Dict(vec![
313 (pickle_key("count"), PickleValue::Int(stats.count as i64)),
314 (pickle_key("mean"), PickleValue::Float(stats.mean)),
315 (pickle_key("median"), PickleValue::Float(stats.median)),
316 (pickle_key("min"), PickleValue::Float(stats.min)),
317 (pickle_key("max"), PickleValue::Float(stats.max)),
318 (
319 pickle_key("stdev"),
320 stats.stdev.map_or(PickleValue::None, PickleValue::Float),
321 ),
322 (pickle_key("sum"), PickleValue::Float(stats.sum)),
323 (
324 pickle_key("threads"),
325 stats.threads.map_or(PickleValue::None, |threads| {
326 PickleValue::Int(threads as i64)
327 }),
328 ),
329 ])
330}
331
332fn result_to_pickle(result: &ProfilingResult) -> PickleValue {
333 PickleValue::Dict(vec![
334 (pickle_key("name"), PickleValue::String(result.name.clone())),
335 (
336 pickle_key("super"),
337 result.parent.as_ref().map_or(PickleValue::None, |parent| {
338 PickleValue::String(parent.clone())
339 }),
340 ),
341 (
342 pickle_key("stats_all"),
343 stats_to_pickle(Some(&result.stats_all)),
344 ),
345 (
346 pickle_key("stats_1m"),
347 stats_to_pickle(result.stats_1m.as_ref()),
348 ),
349 (
350 pickle_key("stats_5m"),
351 stats_to_pickle(result.stats_5m.as_ref()),
352 ),
353 (
354 pickle_key("stats_30m"),
355 stats_to_pickle(result.stats_30m.as_ref()),
356 ),
357 (
358 pickle_key("stats_60m"),
359 stats_to_pickle(result.stats_60m.as_ref()),
360 ),
361 ])
362}
363
364pub fn results_pickle() -> Option<PickleValue> {
366 let results = results();
367 (!results.is_empty()).then(|| {
368 PickleValue::Dict(
369 results
370 .iter()
371 .map(|(tag, result)| (PickleValue::String(tag.clone()), result_to_pickle(result)))
372 .collect(),
373 )
374 })
375}
376
377fn stats_to_msgpack(stats: Option<&ProfilingStats>) -> Value {
378 let Some(stats) = stats else {
379 return Value::Nil;
380 };
381 Value::Map(vec![
382 (Value::Str("count".into()), Value::UInt(stats.count as u64)),
383 (Value::Str("mean".into()), Value::Float(stats.mean)),
384 (Value::Str("median".into()), Value::Float(stats.median)),
385 (Value::Str("min".into()), Value::Float(stats.min)),
386 (Value::Str("max".into()), Value::Float(stats.max)),
387 (
388 Value::Str("stdev".into()),
389 stats.stdev.map_or(Value::Nil, Value::Float),
390 ),
391 (Value::Str("sum".into()), Value::Float(stats.sum)),
392 (
393 Value::Str("threads".into()),
394 stats
395 .threads
396 .map_or(Value::Nil, |threads| Value::UInt(threads as u64)),
397 ),
398 ])
399}
400
401fn result_to_msgpack(result: &ProfilingResult) -> Value {
402 Value::Map(vec![
403 (Value::Str("name".into()), Value::Str(result.name.clone())),
404 (
405 Value::Str("super".into()),
406 result
407 .parent
408 .as_ref()
409 .map_or(Value::Nil, |parent| Value::Str(parent.clone())),
410 ),
411 (
412 Value::Str("stats_all".into()),
413 stats_to_msgpack(Some(&result.stats_all)),
414 ),
415 (
416 Value::Str("stats_1m".into()),
417 stats_to_msgpack(result.stats_1m.as_ref()),
418 ),
419 (
420 Value::Str("stats_5m".into()),
421 stats_to_msgpack(result.stats_5m.as_ref()),
422 ),
423 (
424 Value::Str("stats_30m".into()),
425 stats_to_msgpack(result.stats_30m.as_ref()),
426 ),
427 (
428 Value::Str("stats_60m".into()),
429 stats_to_msgpack(result.stats_60m.as_ref()),
430 ),
431 ])
432}
433
434pub fn results_msgpack() -> Value {
436 let results = results();
437 if results.is_empty() {
438 Value::Nil
439 } else {
440 Value::Map(
441 results
442 .iter()
443 .map(|(tag, result)| (Value::Str(tag.clone()), result_to_msgpack(result)))
444 .collect(),
445 )
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn record(tag: &str, started: f64, duration: f64, limit: usize) {
454 let id = begin_capture(tag, None, limit, started, std::thread::current().id()).unwrap();
455 complete_capture(tag, id, started + duration);
456 }
457
458 #[test]
459 fn retention_limit_is_shared_across_threads() {
460 let tag = "entry113.shared-limit";
461 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
462 let handles: Vec<_> = (0..2)
463 .map(|thread_index| {
464 let barrier = barrier.clone();
465 std::thread::spawn(move || {
466 for sample in 0..3 {
467 barrier.wait();
468 record(
469 tag,
470 elapsed_now(),
471 (thread_index * 10 + sample + 1) as f64,
472 3,
473 );
474 barrier.wait();
475 }
476 })
477 })
478 .collect();
479 for handle in handles {
480 handle.join().unwrap();
481 }
482
483 let result = &results()[tag];
484 assert_eq!(result.stats_all.count, 3);
485 assert_eq!(result.stats_all.threads, Some(2));
486 }
487
488 #[test]
489 fn reentrant_guards_complete_independent_captures() {
490 let outer = profile("entry113.reentrant");
491 let inner = profile("entry113.reentrant");
492 drop(inner);
493 assert_eq!(results()["entry113.reentrant"].stats_all.count, 1);
494 drop(outer);
495 assert_eq!(results()["entry113.reentrant"].stats_all.count, 2);
496 }
497
498 #[test]
499 fn live_windows_are_non_overlapping() {
500 let tag = "entry113.windows";
501 let now = elapsed_now();
502 record(tag, now - 30.0, 0.001, 10);
503 record(tag, now - 120.0, 0.002, 10);
504 record(tag, now - 600.0, 0.003, 10);
505 record(tag, now - 2_400.0, 0.004, 10);
506
507 let result = &results()[tag];
508 assert_eq!(result.stats_all.count, 4);
509 assert!((result.stats_all.sum - 0.010).abs() < 1e-9);
510 assert_eq!(result.stats_1m.as_ref().unwrap().count, 1);
511 assert!((result.stats_1m.as_ref().unwrap().sum - 0.001).abs() < 1e-9);
512 assert!((result.stats_5m.as_ref().unwrap().sum - 0.002).abs() < 1e-9);
513 assert!((result.stats_30m.as_ref().unwrap().sum - 0.003).abs() < 1e-9);
514 assert!((result.stats_60m.as_ref().unwrap().sum - 0.004).abs() < 1e-9);
515 }
516
517 #[test]
518 fn rpc_shape_moves_counts_into_statistics() {
519 record("entry113.shape", elapsed_now(), 0.005, 10);
520 let pickle = results_pickle().unwrap();
521 let entry = pickle.get("entry113.shape").unwrap();
522 assert!(entry.get("count").is_none());
523 assert!(entry.get("threads").is_none());
524 let all = entry.get("stats_all").unwrap();
525 assert_eq!(all.get("count").and_then(PickleValue::as_int), Some(1));
526 assert_eq!(all.get("threads").and_then(PickleValue::as_int), Some(1));
527 let sum = all.get("sum").and_then(PickleValue::as_float).unwrap();
528 assert!((sum - 0.005).abs() < 1e-9);
529 }
530
531 #[test]
532 fn function_adapter_preserves_result_and_records_unwind() {
533 assert_eq!(profile_function("entry113.function", || 42), 42);
534 let unwind = std::panic::catch_unwind(|| {
535 profile_function("entry113.unwind", || panic!("profiled failure"));
536 });
537 assert!(unwind.is_err());
538 assert_eq!(results()["entry113.unwind"].stats_all.count, 1);
539 }
540
541 #[test]
542 fn merged_live_profiling_pipeline_retains_final_schema() {
543 profile_function("entry114.merge", || ());
544 let Value::Map(tags) = results_msgpack() else {
545 panic!("merged profiler results must be a MessagePack map");
546 };
547 let Value::Map(entry) = tags
548 .iter()
549 .find(|(key, _)| *key == Value::Str("entry114.merge".into()))
550 .map(|(_, value)| value)
551 .unwrap()
552 else {
553 panic!("merged profiler entry must be a map");
554 };
555 for key in [
556 "name",
557 "super",
558 "stats_all",
559 "stats_1m",
560 "stats_5m",
561 "stats_30m",
562 "stats_60m",
563 ] {
564 assert!(entry
565 .iter()
566 .any(|(candidate, _)| *candidate == Value::Str(key.into())));
567 }
568 }
569}