Skip to main content

dial9_perf_self_profile/
process_resource.rs

1//! Process resource usage sampled from `getrusage(RUSAGE_SELF)`, exposed as a
2//! dial9 [`Source`](dial9_core::source::Source).
3
4use std::time::Duration;
5
6const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_millis(100);
7
8/// Wire-format event for process resource usage sampled from `getrusage(RUSAGE_SELF)`.
9#[derive(Debug, dial9_trace_format::TraceEvent)]
10#[traceevent(wire_slot)]
11#[cfg_attr(not(feature = "unstable-events"), non_exhaustive)]
12pub struct ProcessResourceUsageEvent {
13    /// Monotonic timestamp in nanoseconds.
14    #[traceevent(timestamp)]
15    pub timestamp_ns: u64,
16    /// Cumulative user CPU time used by this process.
17    #[traceevent(unit = "ns", kind = "counter")]
18    pub user_cpu_ns: u64,
19    /// Cumulative system CPU time used by this process.
20    #[traceevent(unit = "ns", kind = "counter")]
21    pub system_cpu_ns: u64,
22    /// Maximum resident set size in bytes.
23    #[traceevent(unit = "bytes", kind = "gauge")]
24    pub max_rss_bytes: u64,
25    /// Page faults serviced without disk I/O.
26    #[traceevent(kind = "counter")]
27    pub minor_faults: u64,
28    /// Page faults serviced with disk I/O.
29    #[traceevent(kind = "counter")]
30    pub major_faults: u64,
31    /// Block input operations performed by the process.
32    #[traceevent(kind = "counter")]
33    pub block_input_ops: u64,
34    /// Block output operations performed by the process.
35    #[traceevent(kind = "counter")]
36    pub block_output_ops: u64,
37    /// Voluntary context switches performed by the process.
38    #[traceevent(kind = "counter")]
39    pub voluntary_context_switches: u64,
40    /// Involuntary context switches performed by the process.
41    #[traceevent(kind = "counter")]
42    pub involuntary_context_switches: u64,
43}
44
45/// Configuration for process resource usage sampling.
46///
47/// Build via `ProcessResourceUsageConfig::builder()...build()`, then plug the
48/// [`ProcessResourceUsageSource`] into a dial9 recorder.
49#[derive(Debug, Clone, bon::Builder)]
50pub struct ProcessResourceUsageConfig {
51    /// Minimum time between samples.
52    #[builder(default = DEFAULT_SAMPLE_INTERVAL)]
53    sample_interval: Duration,
54}
55
56impl Default for ProcessResourceUsageConfig {
57    fn default() -> Self {
58        Self::builder().build()
59    }
60}
61
62impl ProcessResourceUsageConfig {
63    /// Minimum time between samples.
64    pub fn sample_interval(&self) -> Duration {
65        self.sample_interval
66    }
67}
68
69#[cfg(unix)]
70mod unix {
71    use super::{ProcessResourceUsageConfig, ProcessResourceUsageEvent};
72    use dial9_core::clock::clock_monotonic_ns;
73    use dial9_core::rate_limited;
74    use dial9_core::source::{FlushContext, Source};
75    use std::io;
76    use std::mem::MaybeUninit;
77    use std::time::{Duration, Instant};
78
79    #[cfg(target_vendor = "apple")]
80    const RU_MAXRSS_MULTIPLIER: u64 = 1;
81    #[cfg(not(target_vendor = "apple"))]
82    const RU_MAXRSS_MULTIPLIER: u64 = 1024;
83
84    /// Flush-thread source that samples process `getrusage(RUSAGE_SELF)`.
85    #[derive(Debug)]
86    pub struct ProcessResourceUsageSource {
87        config: ProcessResourceUsageConfig,
88        last_sample: Option<Instant>,
89    }
90
91    #[derive(Debug, Clone, Copy)]
92    struct ProcessResourceUsageSnapshot {
93        user_cpu_ns: u64,
94        system_cpu_ns: u64,
95        max_rss_bytes: u64,
96        minor_faults: u64,
97        major_faults: u64,
98        block_input_ops: u64,
99        block_output_ops: u64,
100        voluntary_context_switches: u64,
101        involuntary_context_switches: u64,
102    }
103
104    impl ProcessResourceUsageSource {
105        /// Build a source that samples at the config's interval.
106        pub fn new(config: ProcessResourceUsageConfig) -> Self {
107            Self {
108                config,
109                last_sample: None,
110            }
111        }
112    }
113
114    impl Source for ProcessResourceUsageSource {
115        fn flush(&mut self, ctx: &FlushContext<'_>) {
116            let now = Instant::now();
117            if let Some(last_sample) = self.last_sample
118                && now.duration_since(last_sample) < self.config.sample_interval
119            {
120                return;
121            }
122            self.last_sample = Some(now);
123
124            match read_process_resource_usage() {
125                Ok(snapshot) => {
126                    let event = snapshot.into_event(clock_monotonic_ns());
127                    ctx.record_event(&event);
128                }
129                Err(e) => rate_limited!(Duration::from_secs(60), {
130                    tracing::warn!("failed to read process resource usage via getrusage: {e}");
131                }),
132            }
133        }
134
135        fn name(&self) -> &'static str {
136            "process_resource_usage"
137        }
138    }
139
140    impl ProcessResourceUsageSnapshot {
141        fn into_event(self, timestamp_ns: u64) -> ProcessResourceUsageEvent {
142            ProcessResourceUsageEvent {
143                timestamp_ns,
144                user_cpu_ns: self.user_cpu_ns,
145                system_cpu_ns: self.system_cpu_ns,
146                max_rss_bytes: self.max_rss_bytes,
147                minor_faults: self.minor_faults,
148                major_faults: self.major_faults,
149                block_input_ops: self.block_input_ops,
150                block_output_ops: self.block_output_ops,
151                voluntary_context_switches: self.voluntary_context_switches,
152                involuntary_context_switches: self.involuntary_context_switches,
153            }
154        }
155    }
156
157    fn read_process_resource_usage() -> io::Result<ProcessResourceUsageSnapshot> {
158        snapshot_from_rusage(read_rusage()?)
159    }
160
161    fn read_rusage() -> io::Result<libc::rusage> {
162        let mut usage = MaybeUninit::<libc::rusage>::uninit();
163
164        // SAFETY: `usage.as_mut_ptr()` points to valid stack memory for libc
165        // to initialize, and `RUSAGE_SELF` asks for this process only.
166        let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
167        if rc != 0 {
168            return Err(io::Error::last_os_error());
169        }
170
171        // SAFETY: getrusage returned success, so it initialized `usage`.
172        Ok(unsafe { usage.assume_init() })
173    }
174
175    fn snapshot_from_rusage(usage: libc::rusage) -> io::Result<ProcessResourceUsageSnapshot> {
176        Ok(ProcessResourceUsageSnapshot {
177            user_cpu_ns: timeval_to_ns(usage.ru_utime, "ru_utime")?,
178            system_cpu_ns: timeval_to_ns(usage.ru_stime, "ru_stime")?,
179            max_rss_bytes: max_rss_to_bytes(usage.ru_maxrss)?,
180            minor_faults: nonnegative(usage.ru_minflt, "ru_minflt")?,
181            major_faults: nonnegative(usage.ru_majflt, "ru_majflt")?,
182            block_input_ops: nonnegative(usage.ru_inblock, "ru_inblock")?,
183            block_output_ops: nonnegative(usage.ru_oublock, "ru_oublock")?,
184            voluntary_context_switches: nonnegative(usage.ru_nvcsw, "ru_nvcsw")?,
185            involuntary_context_switches: nonnegative(usage.ru_nivcsw, "ru_nivcsw")?,
186        })
187    }
188
189    fn timeval_to_ns(tv: libc::timeval, field: &'static str) -> io::Result<u64> {
190        let seconds = nonnegative(tv.tv_sec, field)?;
191        let micros = nonnegative(tv.tv_usec, field)?;
192        let second_ns = checked_mul(seconds, 1_000_000_000, field)?;
193        let micro_ns = checked_mul(micros, 1_000, field)?;
194        second_ns.checked_add(micro_ns).ok_or_else(|| {
195            io::Error::new(
196                io::ErrorKind::InvalidData,
197                format!("{field} overflowed u64"),
198            )
199        })
200    }
201
202    fn max_rss_to_bytes(max_rss: libc::c_long) -> io::Result<u64> {
203        let value = nonnegative(max_rss, "ru_maxrss")?;
204        checked_mul(value, RU_MAXRSS_MULTIPLIER, "ru_maxrss")
205    }
206
207    fn checked_mul(value: u64, multiplier: u64, field: &'static str) -> io::Result<u64> {
208        value.checked_mul(multiplier).ok_or_else(|| {
209            io::Error::new(
210                io::ErrorKind::InvalidData,
211                format!("{field} overflowed u64"),
212            )
213        })
214    }
215
216    fn nonnegative<T>(value: T, field: &'static str) -> io::Result<u64>
217    where
218        T: TryInto<u64>,
219    {
220        value.try_into().map_err(|_| {
221            io::Error::new(
222                io::ErrorKind::InvalidData,
223                format!("getrusage returned negative {field}"),
224            )
225        })
226    }
227
228    #[cfg(all(test, feature = "test-util"))]
229    mod tests {
230        use super::*;
231        use dial9_core::shared_state::SharedState;
232        use dial9_core::test_util;
233        use serde::Deserialize;
234
235        #[derive(Debug, Deserialize)]
236        #[serde(tag = "event")]
237        enum DecodedEvent {
238            ProcessResourceUsageEvent(DecodedProcessResourceUsageEvent),
239            #[serde(other)]
240            Other,
241        }
242
243        #[derive(Debug, Deserialize)]
244        #[allow(dead_code)]
245        struct DecodedProcessResourceUsageEvent {
246            timestamp_ns: u64,
247            user_cpu_ns: u64,
248            system_cpu_ns: u64,
249            max_rss_bytes: u64,
250            minor_faults: u64,
251            major_faults: u64,
252            block_input_ops: u64,
253            block_output_ops: u64,
254            voluntary_context_switches: u64,
255            involuntary_context_switches: u64,
256        }
257
258        fn decode_process_resource_usage_events(
259            bytes: &[u8],
260        ) -> Vec<DecodedProcessResourceUsageEvent> {
261            let mut decoder = dial9_trace_format::decoder::Decoder::new(bytes)
262                .expect("encoded process resource usage batch should have a valid trace header");
263            let mut events = Vec::new();
264            decoder
265                .for_each_event(|raw| {
266                    match raw
267                        .deserialize()
268                        .expect("encoded process resource usage event should deserialize")
269                    {
270                        DecodedEvent::ProcessResourceUsageEvent(event) => events.push(event),
271                        DecodedEvent::Other => {}
272                    }
273                })
274                .expect("encoded process resource usage batch should decode");
275            events
276        }
277
278        #[test]
279        fn read_process_resource_usage_returns_metrics() {
280            let snapshot = read_process_resource_usage()
281                .expect("getrusage should succeed for the current process");
282            assert!(snapshot.max_rss_bytes > 0);
283        }
284
285        #[test]
286        fn source_emits_process_resource_usage_event() {
287            let shared = SharedState::new(0);
288            let ctx = shared.flush_context();
289            let mut source = ProcessResourceUsageSource::new(ProcessResourceUsageConfig::default());
290
291            source.flush(&ctx);
292            let events: Vec<_> = test_util::drain_encoded_batches(&shared)
293                .iter()
294                .flat_map(|b| decode_process_resource_usage_events(b))
295                .collect();
296
297            assert_eq!(events.len(), 1);
298            let event = &events[0];
299            assert!(event.timestamp_ns > 0);
300            assert!(event.max_rss_bytes > 0);
301        }
302
303        #[test]
304        fn source_respects_sample_interval() {
305            let shared = SharedState::new(0);
306            let ctx = shared.flush_context();
307            let config = ProcessResourceUsageConfig::builder()
308                .sample_interval(Duration::from_secs(60))
309                .build();
310            let mut source = ProcessResourceUsageSource::new(config);
311
312            source.flush(&ctx);
313            source.flush(&ctx);
314            let events: Vec<_> = test_util::drain_encoded_batches(&shared)
315                .iter()
316                .flat_map(|b| decode_process_resource_usage_events(b))
317                .collect();
318
319            assert_eq!(events.len(), 1);
320        }
321    }
322}
323
324#[cfg(unix)]
325pub use unix::ProcessResourceUsageSource;
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn default_sample_interval_is_100ms() {
333        assert_eq!(
334            ProcessResourceUsageConfig::default().sample_interval(),
335            DEFAULT_SAMPLE_INTERVAL
336        );
337    }
338
339    #[test]
340    fn process_resource_usage_annotations() {
341        use dial9_trace_format::TraceEvent;
342        let entry = ProcessResourceUsageEvent::schema_entry();
343        let annotations = |key| {
344            entry
345                .annotations()
346                .iter()
347                .filter(|annotation| annotation.key() == key)
348                .map(|annotation| {
349                    (
350                        entry.fields()[annotation.field_index() as usize].name(),
351                        annotation.value(),
352                    )
353                })
354                .collect::<Vec<_>>()
355        };
356        assert_eq!(
357            annotations("unit"),
358            vec![
359                ("user_cpu_ns", "ns"),
360                ("system_cpu_ns", "ns"),
361                ("max_rss_bytes", "bytes"),
362            ]
363        );
364        assert_eq!(
365            annotations("kind"),
366            vec![
367                ("user_cpu_ns", "counter"),
368                ("system_cpu_ns", "counter"),
369                ("max_rss_bytes", "gauge"),
370                ("minor_faults", "counter"),
371                ("major_faults", "counter"),
372                ("block_input_ops", "counter"),
373                ("block_output_ops", "counter"),
374                ("voluntary_context_switches", "counter"),
375                ("involuntary_context_switches", "counter"),
376            ]
377        );
378    }
379}