using System.Diagnostics; namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics; /// /// One periodic observation of process resource usage. /// /// Seconds since sampling started. /// Process working set (RSS). /// without forcing a collection. /// Mean CPU utilization since the previous sample, as a percentage of ONE core. /// OS threads in the process. /// Cumulative gen-2 collections. public sealed record ResourceSample( double ElapsedSeconds, double WorkingSetMb, double ManagedHeapMb, double CpuPercent, int ThreadCount, int Gen2Collections); /// /// Samples working set, managed heap, CPU and thread count on a fixed cadence for the /// life of a run. CPU is differential (processor time delta / wall delta) so a sample /// reflects the interval it covers rather than the whole process lifetime. /// public sealed class ResourceSampler : IAsyncDisposable { private readonly List _samples = new(); private readonly object _lock = new(); private readonly CancellationTokenSource _cts = new(); private readonly Task _loop; private readonly Stopwatch _wall = Stopwatch.StartNew(); private TimeSpan _lastCpu; private double _lastElapsedSeconds; private ResourceSampler(TimeSpan interval) { _lastCpu = Process.GetCurrentProcess().TotalProcessorTime; _loop = Task.Run(() => SampleLoopAsync(interval, _cts.Token)); } /// Starts sampling at the given cadence. /// Sampling interval. /// The running sampler. public static ResourceSampler Start(TimeSpan interval) => new(interval); /// All samples collected so far, oldest first. /// A snapshot copy of the sample list. public IReadOnlyList Snapshot() { lock (_lock) return _samples.ToList(); } private async Task SampleLoopAsync(TimeSpan interval, CancellationToken cancellationToken) { using var timer = new PeriodicTimer(interval); try { while (await timer.WaitForNextTickAsync(cancellationToken)) Capture(); } catch (OperationCanceledException) { // Normal teardown. } } private void Capture() { using var process = Process.GetCurrentProcess(); process.Refresh(); var elapsedSeconds = _wall.Elapsed.TotalSeconds; var cpu = process.TotalProcessorTime; var wallDelta = elapsedSeconds - _lastElapsedSeconds; var cpuPercent = wallDelta > 0 ? (cpu - _lastCpu).TotalSeconds / wallDelta * 100.0 : 0.0; _lastCpu = cpu; _lastElapsedSeconds = elapsedSeconds; var sample = new ResourceSample( elapsedSeconds, process.WorkingSet64 / 1024.0 / 1024.0, GC.GetTotalMemory(forceFullCollection: false) / 1024.0 / 1024.0, cpuPercent, process.Threads.Count, GC.CollectionCount(2)); lock (_lock) _samples.Add(sample); } /// /// Summarizes the samples falling inside a window, expressed as seconds since /// sampling started. Memory growth is reported both as an absolute delta and as a /// least-squares slope, because a run that sawtooths around a stable mean and a /// run that climbs monotonically can share the same endpoint delta. /// /// Window start (inclusive), seconds since start. /// Window end (inclusive), seconds since start. /// The window summary, or null when fewer than two samples fall inside it. public ResourceWindowSummary? Summarize(double fromSeconds, double toSeconds) { var window = Snapshot() .Where(s => s.ElapsedSeconds >= fromSeconds && s.ElapsedSeconds <= toSeconds) .ToList(); if (window.Count < 2) return null; var first = window[0]; var last = window[^1]; return new ResourceWindowSummary( SampleCount: window.Count, DurationSeconds: last.ElapsedSeconds - first.ElapsedSeconds, WorkingSetStartMb: first.WorkingSetMb, WorkingSetEndMb: last.WorkingSetMb, WorkingSetPeakMb: window.Max(s => s.WorkingSetMb), WorkingSetSlopeMbPerMinute: Slope(window, s => s.WorkingSetMb) * 60.0, ManagedHeapStartMb: first.ManagedHeapMb, ManagedHeapEndMb: last.ManagedHeapMb, ManagedHeapPeakMb: window.Max(s => s.ManagedHeapMb), ManagedHeapSlopeMbPerMinute: Slope(window, s => s.ManagedHeapMb) * 60.0, MeanCpuPercentOfOneCore: window.Average(s => s.CpuPercent), PeakCpuPercentOfOneCore: window.Max(s => s.CpuPercent), MeanThreadCount: window.Average(s => s.ThreadCount), Gen2Collections: last.Gen2Collections - first.Gen2Collections); } private static double Slope(IReadOnlyList samples, Func selector) { var n = samples.Count; var meanX = samples.Average(s => s.ElapsedSeconds); var meanY = samples.Average(selector); double numerator = 0, denominator = 0; for (var i = 0; i < n; i++) { var dx = samples[i].ElapsedSeconds - meanX; numerator += dx * (selector(samples[i]) - meanY); denominator += dx * dx; } return denominator == 0 ? 0 : numerator / denominator; } private int _disposed; /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; await _cts.CancelAsync(); try { await _loop; } catch (OperationCanceledException) { // Expected. } _cts.Dispose(); } } /// Aggregate resource behaviour over a measurement window. /// Samples in the window. /// Window length. /// Working set at window start. /// Working set at window end. /// Peak working set in the window. /// Least-squares working-set growth rate. /// Managed heap at window start. /// Managed heap at window end. /// Peak managed heap in the window. /// Least-squares managed-heap growth rate. /// Mean CPU as a percentage of one core (1400% = 14 cores saturated). /// Peak single-sample CPU as a percentage of one core. /// Mean OS thread count. /// Gen-2 collections during the window. public sealed record ResourceWindowSummary( int SampleCount, double DurationSeconds, double WorkingSetStartMb, double WorkingSetEndMb, double WorkingSetPeakMb, double WorkingSetSlopeMbPerMinute, double ManagedHeapStartMb, double ManagedHeapEndMb, double ManagedHeapPeakMb, double ManagedHeapSlopeMbPerMinute, double MeanCpuPercentOfOneCore, double PeakCpuPercentOfOneCore, double MeanThreadCount, int Gen2Collections);