1use std::io;
20use std::time::Duration;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub struct ProcessUsage {
25 pub resident_bytes: u64,
27 pub footprint_bytes: u64,
29 pub cpu_seconds: f64,
31 pub wakeups_total: Option<u64>,
33 pub disk_ops_total: Option<u64>,
35 pub disk_read_bytes: Option<u64>,
37 pub disk_write_bytes: Option<u64>,
39}
40
41#[derive(Clone, Copy, Debug, Default, PartialEq)]
43pub struct UsageRates {
44 pub cpu_percent: f64,
46 pub wakeups_per_second: Option<f64>,
47 pub disk_ops_per_second: Option<f64>,
48 pub disk_read_bytes_per_second: Option<f64>,
49 pub disk_write_bytes_per_second: Option<f64>,
50}
51
52impl ProcessUsage {
53 pub fn rates_since(&self, earlier: &ProcessUsage, elapsed: Duration) -> UsageRates {
56 let secs = elapsed.as_secs_f64();
57 let rate = |now: Option<u64>, then: Option<u64>| match (now, then) {
58 (Some(now), Some(then)) if secs > 0.0 => Some(now.saturating_sub(then) as f64 / secs),
59 (Some(_), Some(_)) => Some(0.0),
60 _ => None,
61 };
62 UsageRates {
63 cpu_percent: if secs > 0.0 {
64 (self.cpu_seconds - earlier.cpu_seconds).max(0.0) / secs * 100.0
65 } else {
66 0.0
67 },
68 wakeups_per_second: rate(self.wakeups_total, earlier.wakeups_total),
69 disk_ops_per_second: rate(self.disk_ops_total, earlier.disk_ops_total),
70 disk_read_bytes_per_second: rate(self.disk_read_bytes, earlier.disk_read_bytes),
71 disk_write_bytes_per_second: rate(self.disk_write_bytes, earlier.disk_write_bytes),
72 }
73 }
74}
75
76pub fn process_usage() -> io::Result<ProcessUsage> {
78 imp::sample()
79}
80
81#[derive(Clone, Copy, Debug)]
83pub struct IdleObservation {
84 pub elapsed: Duration,
85 pub start: ProcessUsage,
86 pub end: ProcessUsage,
87 pub rates: UsageRates,
88}
89
90pub fn observe_idle(duration: Duration) -> io::Result<IdleObservation> {
93 let start = process_usage()?;
94 let began = std::time::Instant::now();
95 std::thread::sleep(duration);
96 let end = process_usage()?;
97 let elapsed = began.elapsed();
98 Ok(IdleObservation {
99 elapsed,
100 start,
101 end,
102 rates: end.rates_since(&start, elapsed),
103 })
104}
105
106#[derive(Clone, Copy, Debug)]
108pub struct BudgetLabels<'a> {
109 pub krate: &'a str,
111 pub version: &'a str,
112 pub scale_unit: &'a str,
114 pub scale: u64,
116 pub what: &'a str,
118 pub scope: &'a str,
120}
121
122impl IdleObservation {
123 pub fn result_lines(&self, warm: &ProcessUsage, labels: &BudgetLabels<'_>) -> String {
138 let BudgetLabels {
139 krate,
140 version,
141 scale_unit,
142 scale,
143 what,
144 scope,
145 } = *labels;
146 let mut out = String::new();
147 let mut line = |text: String| {
148 out.push_str("RESULT ");
149 out.push_str(&text);
150 out.push('\n');
151 };
152 let resident_max = warm.resident_bytes.max(self.end.resident_bytes);
153 line(format!("warm_resident_bytes={} bytes", warm.resident_bytes));
154 line(format!(
155 "idle_resident_bytes={} bytes",
156 self.end.resident_bytes
157 ));
158 if resident_max > 0 {
159 line(format!("resident_memory_bytes={resident_max} bytes"));
160 } else {
161 line("resident_memory_bytes=unavailable host reported a zero resident set".to_string());
162 }
163 line(format!(
164 "footprint_bytes={} bytes",
165 warm.footprint_bytes.max(self.end.footprint_bytes)
166 ));
167 line(format!(
168 "idle_window_seconds={:.1} seconds",
169 self.elapsed.as_secs_f64()
170 ));
171 line(format!(
172 "idle_cpu_percent={:.3} percent",
173 self.rates.cpu_percent
174 ));
175 let opt = |name: &str,
176 value: Option<f64>,
177 unit: &str,
178 reason: &str,
179 line: &mut dyn FnMut(String)| match value {
180 Some(v) => line(format!("{name}={v:.3} {unit}")),
181 None => line(format!("{name}=unavailable {reason}")),
182 };
183 opt(
184 "wakeups_per_second",
185 self.rates.wakeups_per_second,
186 "per_second",
187 "the host exposes no process wake-up counter",
188 &mut line,
189 );
190 opt(
191 "disk_ops_per_second",
192 self.rates.disk_ops_per_second,
193 "per_second",
194 "the host exposes no process disk-operation counter",
195 &mut line,
196 );
197 opt(
198 "disk_read_bytes_per_second",
199 self.rates.disk_read_bytes_per_second,
200 "bytes_per_second",
201 "the host exposes no process disk-byte counter",
202 &mut line,
203 );
204 opt(
205 "disk_write_bytes_per_second",
206 self.rates.disk_write_bytes_per_second,
207 "bytes_per_second",
208 "the host exposes no process disk-byte counter",
209 &mut line,
210 );
211 line(format!("scale={scale} {what}"));
212 line(format!("scope={scope}"));
213 let num =
214 |v: Option<f64>| v.map_or_else(|| "unavailable".to_string(), |v| format!("{v:.3}"));
215 let resident = if resident_max > 0 {
216 resident_max.to_string()
217 } else {
218 "unavailable".to_string()
219 };
220 line(format!(
221 "budget crate={krate} version={version} resident_bytes={resident} idle_cpu_percent={:.3} wakeups_per_s={} disk_ops_per_s={} scale_unit={scale_unit} scale_count={scale}",
222 self.rates.cpu_percent,
223 num(self.rates.wakeups_per_second),
224 num(self.rates.disk_ops_per_second),
225 ));
226 out
227 }
228}
229
230#[cfg(target_os = "macos")]
231mod imp {
232 use super::ProcessUsage;
233 use std::io;
234
235 extern "C" {
236 fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut u8) -> i32;
237 }
238
239 const W_INTERRUPT_WKUPS: usize = 2 + 3;
242 const W_RESIDENT: usize = 2 + 6;
243 const W_FOOTPRINT: usize = 2 + 7;
244 const W_DISK_READ: usize = 2 + 16;
245 const W_DISK_WRITE: usize = 2 + 17;
246
247 pub fn sample() -> io::Result<ProcessUsage> {
248 let mut words = [0u64; 64];
249 let rc = unsafe { proc_pid_rusage(libc::getpid(), 2, words.as_mut_ptr().cast()) };
251 if rc != 0 {
252 return Err(io::Error::last_os_error());
253 }
254 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
255 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
256 return Err(io::Error::last_os_error());
257 }
258 let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
259 Ok(ProcessUsage {
260 resident_bytes: words[W_RESIDENT],
261 footprint_bytes: words[W_FOOTPRINT],
262 cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
263 wakeups_total: Some(words[W_INTERRUPT_WKUPS]),
264 disk_ops_total: None,
265 disk_read_bytes: Some(words[W_DISK_READ]),
266 disk_write_bytes: Some(words[W_DISK_WRITE]),
267 })
268 }
269}
270
271#[cfg(all(
272 unix,
273 not(target_os = "macos"),
274 not(any(target_os = "linux", target_os = "android"))
275))]
276mod imp {
277 use super::ProcessUsage;
278 use std::io;
279
280 pub fn sample() -> io::Result<ProcessUsage> {
283 Err(io::Error::new(
284 io::ErrorKind::Unsupported,
285 "process usage sampling is implemented for macOS, Linux, Android and Windows only",
286 ))
287 }
288}
289
290#[cfg(any(target_os = "linux", target_os = "android"))]
291mod imp {
292 use super::ProcessUsage;
293 use std::io;
294
295 pub fn sample() -> io::Result<ProcessUsage> {
296 let statm = std::fs::read_to_string("/proc/self/statm")?;
297 let pages: u64 = statm
298 .split_whitespace()
299 .nth(1)
300 .and_then(|v| v.parse().ok())
301 .ok_or_else(|| {
302 io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc/self/statm")
303 })?;
304 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1))
305 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "page size out of range"))?;
306 let overflow = || io::Error::new(io::ErrorKind::InvalidData, "memory size overflows u64");
307 let resident = pages.checked_mul(page).ok_or_else(overflow)?;
308 let blocks = |count: libc::c_long| u64::try_from(count.max(0)).unwrap_or(0);
310 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
311 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
312 return Err(io::Error::last_os_error());
313 }
314 let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
315 Ok(ProcessUsage {
316 resident_bytes: resident,
317 footprint_bytes: resident,
318 cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
319 wakeups_total: None,
320 disk_ops_total: blocks(ru.ru_inblock).checked_add(blocks(ru.ru_oublock)),
321 disk_read_bytes: None,
322 disk_write_bytes: None,
323 })
324 }
325}
326
327#[cfg(windows)]
328mod imp {
329 use super::ProcessUsage;
330 use std::io;
331 use windows::Wdk::System::SystemInformation::{
332 NtQuerySystemInformation, SystemProcessInformation,
333 };
334 use windows::Win32::Foundation::{FILETIME, STATUS_INFO_LENGTH_MISMATCH};
335 use windows::Win32::System::ProcessStatus::{
336 GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
337 };
338 use windows::Win32::System::Threading::{
339 GetCurrentProcess, GetCurrentProcessId, GetProcessIoCounters, GetProcessTimes, IO_COUNTERS,
340 };
341 use windows::Win32::System::WindowsProgramming::{
342 SYSTEM_PROCESS_INFORMATION, SYSTEM_THREAD_INFORMATION,
343 };
344
345 fn os(error: windows::core::Error) -> io::Error {
346 io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
347 }
348
349 fn ticks(t: FILETIME) -> u64 {
350 ((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
351 }
352
353 #[cfg(target_pointer_width = "64")]
357 const THREADS_OFFSET: usize = 0x100;
358 #[cfg(target_pointer_width = "64")]
360 const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
361
362 #[cfg(target_pointer_width = "64")]
367 fn wakeups_total() -> Option<u64> {
368 let mut words: Vec<u64> = vec![0; (1 << 20) / 8];
370 let mut length: u32 = 0;
371 let mut attempts = 0;
372 loop {
373 let capacity = words.len() * 8;
374 let status = unsafe {
375 NtQuerySystemInformation(
376 SystemProcessInformation,
377 words.as_mut_ptr().cast(),
378 u32::try_from(capacity).ok()?,
379 &mut length,
380 )
381 };
382 if status.0 >= 0 {
383 break;
384 }
385 attempts += 1;
386 if status != STATUS_INFO_LENGTH_MISMATCH || attempts > 8 {
387 return None;
388 }
389 let want = (length as usize).checked_add(256 * 1024)?;
391 if want <= capacity || want > MAX_QUERY_BYTES {
392 return None;
393 }
394 words = vec![0; want.div_ceil(8)];
395 }
396 let valid = (length as usize).min(words.len() * 8);
397 let base = words.as_ptr().cast::<u8>();
398 let pid = unsafe { GetCurrentProcessId() } as usize;
399 let thread_size = std::mem::size_of::<SYSTEM_THREAD_INFORMATION>();
400 let mut offset = 0usize;
401 loop {
402 if offset.checked_add(THREADS_OFFSET)? > valid {
403 return None;
404 }
405 let entry = unsafe {
407 std::ptr::read_unaligned(base.add(offset).cast::<SYSTEM_PROCESS_INFORMATION>())
408 };
409 if entry.UniqueProcessId.0 as usize == pid {
410 let count = entry.NumberOfThreads as usize;
411 let first = offset + THREADS_OFFSET;
412 let end = first.checked_add(count.checked_mul(thread_size)?)?;
413 if end > valid {
414 return None;
415 }
416 let mut total = 0u64;
417 for i in 0..count {
418 let thread = unsafe {
420 std::ptr::read_unaligned(
421 base.add(first + i * thread_size)
422 .cast::<SYSTEM_THREAD_INFORMATION>(),
423 )
424 };
425 if thread.ClientId.UniqueProcess.0 as usize != pid {
427 return None;
428 }
429 total = total.checked_add(u64::from(thread.Reserved3))?;
430 }
431 return Some(total);
432 }
433 let next = entry.NextEntryOffset as usize;
434 if next == 0 {
435 return None;
436 }
437 offset = offset.checked_add(next)?;
438 }
439 }
440
441 #[cfg(not(target_pointer_width = "64"))]
442 fn wakeups_total() -> Option<u64> {
443 None
444 }
445
446 pub fn sample() -> io::Result<ProcessUsage> {
447 unsafe {
448 let process = GetCurrentProcess();
449 let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
450 GetProcessMemoryInfo(
451 process,
452 (&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
453 std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
454 )
455 .map_err(os)?;
456 let (mut created, mut exited, mut kernel, mut user) = (
457 FILETIME::default(),
458 FILETIME::default(),
459 FILETIME::default(),
460 FILETIME::default(),
461 );
462 GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
463 .map_err(os)?;
464 let mut io_counters = IO_COUNTERS::default();
465 GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
466 Ok(ProcessUsage {
467 resident_bytes: mem.WorkingSetSize as u64,
468 footprint_bytes: mem.PrivateUsage as u64,
469 cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
471 wakeups_total: wakeups_total(),
472 disk_ops_total: Some(
473 io_counters.ReadOperationCount + io_counters.WriteOperationCount,
474 ),
475 disk_read_bytes: Some(io_counters.ReadTransferCount),
476 disk_write_bytes: Some(io_counters.WriteTransferCount),
477 })
478 }
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[cfg(any(
488 target_os = "macos",
489 target_os = "linux",
490 target_os = "android",
491 windows
492 ))]
493 #[test]
494 fn sample_reports_resident_memory_and_cpu() {
495 let first = process_usage().unwrap();
496 assert!(first.resident_bytes > 0);
497 assert!(first.footprint_bytes > 0);
498 let mut sink = 0u64;
499 for i in 0..20_000_000u64 {
500 sink = sink.wrapping_add(i * 31);
501 }
502 std::hint::black_box(sink);
503 let second = process_usage().unwrap();
504 assert!(second.cpu_seconds >= first.cpu_seconds);
505 let rates = second.rates_since(&first, Duration::from_secs(1));
506 assert!(rates.cpu_percent >= 0.0);
507 }
508
509 #[cfg(all(windows, target_pointer_width = "64"))]
512 #[test]
513 fn windows_wakeups_count_thread_context_switches() {
514 use std::sync::{mpsc, Arc, Mutex};
515 let (tx, rx) = mpsc::channel::<()>();
516 let rx = Arc::new(Mutex::new(rx));
517 let (done_tx, done_rx) = mpsc::channel::<()>();
518 let workers: Vec<_> = (0..3)
519 .map(|_| {
520 let rx = rx.clone();
521 let done = done_tx.clone();
522 std::thread::spawn(move || {
523 while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
525 let _ = done.send(());
526 }
527 })
528 })
529 .collect();
530 let mut ok = false;
533 for _ in 0..5 {
534 let first = process_usage()
535 .unwrap()
536 .wakeups_total
537 .expect("windows reports wake-ups");
538 for _ in 0..5 {
539 std::thread::sleep(Duration::from_millis(10));
540 tx.send(()).unwrap();
541 done_rx.recv().unwrap();
542 }
543 let second = process_usage()
544 .unwrap()
545 .wakeups_total
546 .expect("windows reports wake-ups");
547 if second >= first {
548 ok = true;
549 break;
550 }
551 }
552 drop(tx);
553 for worker in workers {
554 let _ = worker.join();
555 }
556 assert!(ok, "wake-up total decreased on every attempt");
557 }
558
559 #[cfg(all(
561 unix,
562 not(target_os = "macos"),
563 not(any(target_os = "linux", target_os = "android"))
564 ))]
565 #[test]
566 fn unsupported_unix_targets_report_unsupported_not_a_sample() {
567 let error = process_usage().expect_err("no sampler exists for this target");
568 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
569 let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
570 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
571 }
572
573 #[test]
574 fn rates_are_none_without_counters_and_zero_for_no_time() {
575 let a = ProcessUsage {
576 wakeups_total: None,
577 ..Default::default()
578 };
579 let b = ProcessUsage {
580 wakeups_total: Some(10),
581 ..Default::default()
582 };
583 assert_eq!(
584 b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
585 None
586 );
587 let c = ProcessUsage {
588 wakeups_total: Some(30),
589 ..Default::default()
590 };
591 assert_eq!(
592 c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
593 Some(10.0)
594 );
595 assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
596 }
597}