Skip to main content

keyhog_profile/
hardware.rs

1//! CPU hardware evidence: perf counters, scheduler activity, per-thread
2//! utilization, and CPU topology, each with explicit capability gaps.
3//!
4//! Every collector follows the [`SnapshotCollector`] + [`CollectorCapability`]
5//! pattern. A field that cannot be measured on this host records an
6//! [`Evidence`] gap with its attempted [`HardwareFieldSourceV2`]; nothing is
7//! fabricated. The span hot path stores only raw `u64` counter readings in
8//! fixed slots; all joins and ratios run cold at drain time.
9
10use crate::collector::{CollectorAvailability, CollectorCapability, SnapshotCollector};
11use crate::schema_v2::{Evidence, EvidenceGap, SpanRecordV2};
12use serde::{Deserialize, Serialize};
13
14#[cfg(all(feature = "hardware-counters", target_os = "linux"))]
15mod linux;
16#[cfg(all(feature = "hardware-counters", target_os = "macos"))]
17mod macos;
18#[cfg(all(feature = "hardware-counters", windows))]
19mod windows;
20
21#[cfg(all(feature = "hardware-counters", target_os = "linux"))]
22use linux as platform;
23#[cfg(all(feature = "hardware-counters", target_os = "macos"))]
24use macos as platform;
25#[cfg(any(
26    not(feature = "hardware-counters"),
27    all(
28        feature = "hardware-counters",
29        not(any(target_os = "linux", target_os = "macos", windows))
30    )
31))]
32use stubs as platform;
33#[cfg(all(feature = "hardware-counters", windows))]
34use windows as platform;
35
36pub const HARDWARE_EVIDENCE_V2_VERSION: u16 = 1;
37pub const SPAN_HARDWARE_V2_VERSION: u16 = 1;
38/// Maximum retained utilization samples per session; excess is counted, never stored.
39pub const MAX_UTILIZATION_SAMPLES: usize = 256;
40/// Maximum retained threads per utilization sample; excess is counted.
41pub const MAX_SAMPLE_THREADS: usize = 1024;
42
43fn gap<T>(reason: EvidenceGap) -> Evidence<T> {
44    Evidence::unavailable(reason)
45}
46
47const fn legacy_component_version() -> u16 {
48    1
49}
50
51/// Exact host facility that produced (or was asked for) one hardware field.
52#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum HardwareFieldSourceV2 {
55    PerfEventOpen,
56    ProcSelfSched,
57    ProcSelfSchedstat,
58    ProcSelfTaskStat,
59    ProcSelfStatus,
60    ProcSelfStat,
61    ProcSelfIo,
62    ProcPressure,
63    SysfsCpu,
64    SysfsCgroup,
65    SysfsThermal,
66    SystemCall,
67    WindowsApi,
68    MacOsApi,
69}
70
71/// One measured field plus the facility that produced or was asked for it.
72#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
73pub struct SourcedEvidenceV2<T> {
74    pub value: Evidence<T>,
75    pub source: HardwareFieldSourceV2,
76}
77
78impl<T> SourcedEvidenceV2<T> {
79    pub fn recorded(value: T, source: HardwareFieldSourceV2) -> Self {
80        Self {
81            value: Evidence::recorded(value),
82            source,
83        }
84    }
85
86    pub const fn gapped(source: HardwareFieldSourceV2, reason: EvidenceGap) -> Self {
87        Self {
88            value: Evidence::unavailable(reason),
89            source,
90        }
91    }
92}
93
94pub(crate) fn sourced_delta(
95    end: &SourcedEvidenceV2<u64>,
96    start: &SourcedEvidenceV2<u64>,
97) -> SourcedEvidenceV2<u64> {
98    match (&end.value, &start.value) {
99        (Evidence::Recorded { value: end_value }, Evidence::Recorded { value: start_value }) => {
100            SourcedEvidenceV2::recorded(end_value.saturating_sub(*start_value), end.source)
101        }
102        (Evidence::Unavailable { reason }, _) => SourcedEvidenceV2::gapped(end.source, *reason),
103        (_, Evidence::Unavailable { reason }) => SourcedEvidenceV2::gapped(end.source, *reason),
104    }
105}
106
107/// Exact integer ratio in thousandths; `None` when the denominator is zero.
108pub fn milli_ratio(numerator: u64, denominator: u64) -> Option<u64> {
109    if denominator == 0 {
110        return None;
111    }
112    Some(u64::try_from(u128::from(numerator) * 1_000 / u128::from(denominator)).unwrap_or(u64::MAX))
113}
114
115fn milli_ratio_evidence(numerator: &Evidence<u64>, denominator: &Evidence<u64>) -> Evidence<u64> {
116    match (numerator, denominator) {
117        (Evidence::Recorded { value: top }, Evidence::Recorded { value: bottom }) => {
118            milli_ratio(*top, *bottom)
119                .map_or_else(|| gap(EvidenceGap::Unavailable), Evidence::recorded)
120        }
121        (Evidence::Unavailable { reason }, _) => gap(*reason),
122        (_, Evidence::Unavailable { reason }) => gap(*reason),
123    }
124}
125
126/// Raw per-span cycle and instruction readings attached at span begin and end.
127#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
128pub struct SpanHardwareV2 {
129    #[serde(default = "legacy_component_version")]
130    pub version: u16,
131    pub cycles_begin: Evidence<u64>,
132    pub cycles_end: Evidence<u64>,
133    pub instructions_begin: Evidence<u64>,
134    pub instructions_end: Evidence<u64>,
135}
136
137impl SpanHardwareV2 {
138    /// Retired cycles inside this span, including nested children.
139    pub fn cycles(&self) -> Evidence<u64> {
140        match (&self.cycles_end, &self.cycles_begin) {
141            (Evidence::Recorded { value: end }, Evidence::Recorded { value: begin }) => {
142                Evidence::recorded(end.saturating_sub(*begin))
143            }
144            (Evidence::Unavailable { reason }, _) => gap(*reason),
145            (_, Evidence::Unavailable { reason }) => gap(*reason),
146        }
147    }
148
149    /// Retired instructions inside this span, including nested children.
150    pub fn instructions(&self) -> Evidence<u64> {
151        match (&self.instructions_end, &self.instructions_begin) {
152            (Evidence::Recorded { value: end }, Evidence::Recorded { value: begin }) => {
153                Evidence::recorded(end.saturating_sub(*begin))
154            }
155            (Evidence::Unavailable { reason }, _) => gap(*reason),
156            (_, Evidence::Unavailable { reason }) => gap(*reason),
157        }
158    }
159
160    /// Cycles per instruction in thousandths for this span.
161    pub fn cpi_milli(&self) -> Evidence<u64> {
162        milli_ratio_evidence(&self.cycles(), &self.instructions())
163    }
164}
165
166/// One absolute hardware-counter reading taken by [`HardwareCounterCollector`].
167#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
168pub struct HardwareCounterSampleV2 {
169    #[serde(default = "legacy_component_version")]
170    pub version: u16,
171    pub elapsed_ns: u64,
172    pub cycles: SourcedEvidenceV2<u64>,
173    pub instructions: SourcedEvidenceV2<u64>,
174    pub cache_references: SourcedEvidenceV2<u64>,
175    pub cache_misses: SourcedEvidenceV2<u64>,
176    pub branch_instructions: SourcedEvidenceV2<u64>,
177    pub branch_misses: SourcedEvidenceV2<u64>,
178    pub stalled_cycles_frontend: SourcedEvidenceV2<u64>,
179    pub stalled_cycles_backend: SourcedEvidenceV2<u64>,
180    pub stalled_cycles_memory: SourcedEvidenceV2<u64>,
181}
182
183/// Counter deltas and derived ratios across one run (session-thread scope).
184#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub struct HardwareCounterSetV2 {
186    #[serde(default = "legacy_component_version")]
187    pub version: u16,
188    pub cycles: SourcedEvidenceV2<u64>,
189    pub instructions: SourcedEvidenceV2<u64>,
190    pub cache_references: SourcedEvidenceV2<u64>,
191    pub cache_misses: SourcedEvidenceV2<u64>,
192    pub branch_instructions: SourcedEvidenceV2<u64>,
193    pub branch_misses: SourcedEvidenceV2<u64>,
194    pub stalled_cycles_frontend: SourcedEvidenceV2<u64>,
195    pub stalled_cycles_backend: SourcedEvidenceV2<u64>,
196    pub stalled_cycles_memory: SourcedEvidenceV2<u64>,
197    /// Cycles per instruction in thousandths.
198    pub cpi_milli: Evidence<u64>,
199    /// Cache misses per reference in thousandths.
200    pub cache_miss_ratio_milli: Evidence<u64>,
201    /// Branch mispredictions per branch instruction in thousandths.
202    pub branch_miss_ratio_milli: Evidence<u64>,
203}
204
205impl HardwareCounterSetV2 {
206    /// Delta between two absolute samples; gaps propagate from either side.
207    pub fn between(start: &HardwareCounterSampleV2, end: &HardwareCounterSampleV2) -> Self {
208        let counters = Self {
209            version: HARDWARE_EVIDENCE_V2_VERSION,
210            cycles: sourced_delta(&end.cycles, &start.cycles),
211            instructions: sourced_delta(&end.instructions, &start.instructions),
212            cache_references: sourced_delta(&end.cache_references, &start.cache_references),
213            cache_misses: sourced_delta(&end.cache_misses, &start.cache_misses),
214            branch_instructions: sourced_delta(
215                &end.branch_instructions,
216                &start.branch_instructions,
217            ),
218            branch_misses: sourced_delta(&end.branch_misses, &start.branch_misses),
219            stalled_cycles_frontend: sourced_delta(
220                &end.stalled_cycles_frontend,
221                &start.stalled_cycles_frontend,
222            ),
223            stalled_cycles_backend: sourced_delta(
224                &end.stalled_cycles_backend,
225                &start.stalled_cycles_backend,
226            ),
227            stalled_cycles_memory: sourced_delta(
228                &end.stalled_cycles_memory,
229                &start.stalled_cycles_memory,
230            ),
231            cpi_milli: gap(EvidenceGap::Unavailable),
232            cache_miss_ratio_milli: gap(EvidenceGap::Unavailable),
233            branch_miss_ratio_milli: gap(EvidenceGap::Unavailable),
234        };
235        Self {
236            cpi_milli: milli_ratio_evidence(&counters.cycles.value, &counters.instructions.value),
237            cache_miss_ratio_milli: milli_ratio_evidence(
238                &counters.cache_misses.value,
239                &counters.cache_references.value,
240            ),
241            branch_miss_ratio_milli: milli_ratio_evidence(
242                &counters.branch_misses.value,
243                &counters.branch_instructions.value,
244            ),
245            ..counters
246        }
247    }
248
249    fn all_gapped(reason: EvidenceGap) -> Self {
250        let source = platform::COUNTER_SOURCE;
251        Self {
252            version: HARDWARE_EVIDENCE_V2_VERSION,
253            cycles: SourcedEvidenceV2::gapped(source, reason),
254            instructions: SourcedEvidenceV2::gapped(source, reason),
255            cache_references: SourcedEvidenceV2::gapped(source, reason),
256            cache_misses: SourcedEvidenceV2::gapped(source, reason),
257            branch_instructions: SourcedEvidenceV2::gapped(source, reason),
258            branch_misses: SourcedEvidenceV2::gapped(source, reason),
259            stalled_cycles_frontend: SourcedEvidenceV2::gapped(source, reason),
260            stalled_cycles_backend: SourcedEvidenceV2::gapped(source, reason),
261            stalled_cycles_memory: SourcedEvidenceV2::gapped(
262                HardwareFieldSourceV2::PerfEventOpen,
263                memory_stall_gap(),
264            ),
265            cpi_milli: gap(reason),
266            cache_miss_ratio_milli: gap(reason),
267            branch_miss_ratio_milli: gap(reason),
268        }
269    }
270}
271
272fn memory_stall_gap() -> EvidenceGap {
273    platform::MEMORY_STALL_GAP
274}
275
276/// One absolute scheduler-activity reading from procfs and perf software events.
277#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
278pub struct SchedulerSampleV2 {
279    #[serde(default = "legacy_component_version")]
280    pub version: u16,
281    pub voluntary_context_switches: SourcedEvidenceV2<u64>,
282    pub involuntary_context_switches: SourcedEvidenceV2<u64>,
283    pub total_context_switches: SourcedEvidenceV2<u64>,
284    pub cpu_migrations: SourcedEvidenceV2<u64>,
285    pub runqueue_delay_ns: SourcedEvidenceV2<u64>,
286    pub timeslices: SourcedEvidenceV2<u64>,
287}
288
289/// Scheduler activity deltas across one run with an explicit source per field.
290#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
291pub struct SchedulerEvidenceV2 {
292    #[serde(default = "legacy_component_version")]
293    pub version: u16,
294    pub voluntary_context_switches: SourcedEvidenceV2<u64>,
295    pub involuntary_context_switches: SourcedEvidenceV2<u64>,
296    pub total_context_switches: SourcedEvidenceV2<u64>,
297    pub cpu_migrations: SourcedEvidenceV2<u64>,
298    pub scheduler_delay_ns: SourcedEvidenceV2<u64>,
299    pub timeslices: SourcedEvidenceV2<u64>,
300}
301
302impl SchedulerEvidenceV2 {
303    /// Delta between two absolute scheduler samples; gaps propagate.
304    pub fn between(start: &SchedulerSampleV2, end: &SchedulerSampleV2) -> Self {
305        Self {
306            version: HARDWARE_EVIDENCE_V2_VERSION,
307            voluntary_context_switches: sourced_delta(
308                &end.voluntary_context_switches,
309                &start.voluntary_context_switches,
310            ),
311            involuntary_context_switches: sourced_delta(
312                &end.involuntary_context_switches,
313                &start.involuntary_context_switches,
314            ),
315            total_context_switches: sourced_delta(
316                &end.total_context_switches,
317                &start.total_context_switches,
318            ),
319            cpu_migrations: sourced_delta(&end.cpu_migrations, &start.cpu_migrations),
320            scheduler_delay_ns: sourced_delta(&end.runqueue_delay_ns, &start.runqueue_delay_ns),
321            timeslices: sourced_delta(&end.timeslices, &start.timeslices),
322        }
323    }
324}
325
326/// One thread's cumulative CPU consumption at a sample instant.
327#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
328pub struct ThreadCpuV2 {
329    #[serde(default = "legacy_component_version")]
330    pub version: u16,
331    /// Operating-system thread identity (Linux tid, Windows tid, mach port).
332    pub thread_id: u64,
333    pub cpu_time_ns: u64,
334}
335
336/// Per-thread CPU census at one instant, bounded with explicit loss.
337#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
338pub struct ThreadUtilizationSampleV2 {
339    #[serde(default = "legacy_component_version")]
340    pub version: u16,
341    pub elapsed_ns: u64,
342    pub threads: Vec<ThreadCpuV2>,
343    pub dropped_threads: u64,
344}
345
346/// Per-thread CPU consumption and utilization across one run.
347#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
348pub struct ThreadUtilizationV2 {
349    #[serde(default = "legacy_component_version")]
350    pub version: u16,
351    pub thread_id: u64,
352    pub cpu_time_ns: u64,
353    /// Share of wall time this thread ran, in thousandths of one CPU.
354    pub utilization_milli: Evidence<u64>,
355}
356
357/// Aggregate CPU frequency across all CPUs at one sample instant.
358#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
359pub struct CpuFrequencySampleV2 {
360    #[serde(default = "legacy_component_version")]
361    pub version: u16,
362    pub elapsed_ns: u64,
363    pub min_khz: u64,
364    pub max_khz: u64,
365    pub mean_khz: u64,
366    pub cpu_count: u32,
367}
368
369/// Per-thread utilization, effective parallelism, and frequency series.
370#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
371pub struct UtilizationEvidenceV2 {
372    #[serde(default = "legacy_component_version")]
373    pub version: u16,
374    pub wall_ns: u64,
375    pub logical_cpus: u32,
376    /// Sum of per-thread CPU deltas for threads alive at both session ends.
377    pub total_thread_cpu_ns: u64,
378    /// Average concurrent CPUs: total thread CPU over wall time, in thousandths.
379    pub effective_parallelism_milli: Evidence<u64>,
380    /// Total thread CPU over wall CPU capacity (wall times logical CPUs), in thousandths.
381    pub capacity_utilization_milli: Evidence<u64>,
382    pub threads: Vec<ThreadUtilizationV2>,
383    /// Threads present at session start but gone at finish; their CPU is excluded.
384    pub exited_threads: u64,
385    /// Threads created after the first sample; only their sampled work counts.
386    pub joined_threads: u64,
387    pub samples_retained: u64,
388    pub dropped_samples: u64,
389    pub frequency_samples: Vec<CpuFrequencySampleV2>,
390    /// Where frequency came from, or why it could not be sampled.
391    pub frequency_availability: Evidence<HardwareFieldSourceV2>,
392    pub dropped_frequency_samples: u64,
393}
394
395/// Static CPU topology, affinity, NUMA, and cgroup CPU limits for one run.
396#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
397pub struct TopologyEvidenceV2 {
398    #[serde(default = "legacy_component_version")]
399    pub version: u16,
400    pub logical_cpus: u32,
401    pub physical_cores: SourcedEvidenceV2<u32>,
402    pub packages: SourcedEvidenceV2<u32>,
403    pub numa_nodes: SourcedEvidenceV2<u32>,
404    /// CPUs the process is allowed to run on.
405    pub affinity_cpus: SourcedEvidenceV2<u32>,
406    /// Cgroup CPU quota in thousandths of one CPU; unavailable means unbounded.
407    pub cpu_quota_milli: SourcedEvidenceV2<u64>,
408}
409
410/// Per-stage cycle and instruction totals joined from span records.
411#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
412pub struct StageHardwareV2 {
413    #[serde(default = "legacy_component_version")]
414    pub version: u16,
415    pub metric_id: crate::MetricId,
416    /// Spans of this stage that carried hardware readings.
417    pub span_count: u64,
418    pub cycles: u64,
419    pub instructions: u64,
420    /// Cycles per instruction in thousandths.
421    pub cpi_milli: Evidence<u64>,
422}
423
424/// Per-thread cycle and instruction totals joined from span records.
425#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
426pub struct ThreadHardwareV2 {
427    #[serde(default = "legacy_component_version")]
428    pub version: u16,
429    pub thread_id: u64,
430    pub span_count: u64,
431    pub cycles: u64,
432    pub instructions: u64,
433    pub cpi_milli: Evidence<u64>,
434}
435
436/// Run-level cycle and instruction totals joined from span records.
437#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
438pub struct RunSpanHardwareV2 {
439    #[serde(default = "legacy_component_version")]
440    pub version: u16,
441    pub span_count: u64,
442    pub spans_with_counters: u64,
443    pub cycles: u64,
444    pub instructions: u64,
445    pub cpi_milli: Evidence<u64>,
446}
447
448/// Cold-path CPI aggregation over one drained span set.
449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
450pub struct SpanHardwareAggregationV2 {
451    #[serde(default = "legacy_component_version")]
452    pub version: u16,
453    pub run: RunSpanHardwareV2,
454    pub stages: Vec<StageHardwareV2>,
455    pub threads: Vec<ThreadHardwareV2>,
456}
457
458#[derive(Default)]
459struct HardwareSum {
460    spans: u64,
461    cycles: u64,
462    instructions: u64,
463}
464
465impl HardwareSum {
466    fn add(&mut self, hardware: &SpanHardwareV2) {
467        let mut counted = false;
468        if let Evidence::Recorded { value } = hardware.cycles() {
469            self.cycles = self.cycles.saturating_add(value);
470            counted = true;
471        }
472        if let Evidence::Recorded { value } = hardware.instructions() {
473            self.instructions = self.instructions.saturating_add(value);
474            counted = true;
475        }
476        if counted {
477            self.spans = self.spans.saturating_add(1);
478        }
479    }
480
481    fn cpi_milli(&self) -> Evidence<u64> {
482        milli_ratio(self.cycles, self.instructions)
483            .map_or_else(|| gap(EvidenceGap::Unavailable), Evidence::recorded)
484    }
485}
486
487/// Join span-attached counter readings into per-stage, per-thread, and run CPI.
488///
489/// Nested spans contribute inclusive readings, so stage sums double count
490/// nesting exactly as inclusive stage time does; compare like with like.
491pub fn aggregate_span_hardware(spans: &[SpanRecordV2]) -> SpanHardwareAggregationV2 {
492    let mut stages: std::collections::BTreeMap<crate::MetricId, HardwareSum> =
493        std::collections::BTreeMap::new();
494    let mut threads: std::collections::BTreeMap<u64, HardwareSum> =
495        std::collections::BTreeMap::new();
496    let mut run = HardwareSum::default();
497    for span in spans {
498        let Evidence::Recorded { value: hardware } = &span.hardware else {
499            continue;
500        };
501        stages.entry(span.metric_id).or_default().add(hardware);
502        threads.entry(span.thread_id).or_default().add(hardware);
503        run.add(hardware);
504    }
505    SpanHardwareAggregationV2 {
506        version: HARDWARE_EVIDENCE_V2_VERSION,
507        run: RunSpanHardwareV2 {
508            version: HARDWARE_EVIDENCE_V2_VERSION,
509            span_count: spans.len() as u64,
510            spans_with_counters: run.spans,
511            cycles: run.cycles,
512            instructions: run.instructions,
513            cpi_milli: run.cpi_milli(),
514        },
515        stages: stages
516            .into_iter()
517            .map(|(metric_id, sum)| StageHardwareV2 {
518                version: HARDWARE_EVIDENCE_V2_VERSION,
519                metric_id,
520                span_count: sum.spans,
521                cycles: sum.cycles,
522                instructions: sum.instructions,
523                cpi_milli: sum.cpi_milli(),
524            })
525            .collect(),
526        threads: threads
527            .into_iter()
528            .map(|(thread_id, sum)| ThreadHardwareV2 {
529                version: HARDWARE_EVIDENCE_V2_VERSION,
530                thread_id,
531                span_count: sum.spans,
532                cycles: sum.cycles,
533                instructions: sum.instructions,
534                cpi_milli: sum.cpi_milli(),
535            })
536            .collect(),
537    }
538}
539
540/// Complete CPU hardware evidence for one run.
541#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
542pub struct HardwareRunEvidenceV2 {
543    #[serde(default = "legacy_component_version")]
544    pub version: u16,
545    /// Session-thread counter totals; other threads are covered per span.
546    pub counters: HardwareCounterSetV2,
547    pub scheduler: SchedulerEvidenceV2,
548    pub utilization: UtilizationEvidenceV2,
549    pub topology: TopologyEvidenceV2,
550    /// CPI joined from drained span records; attach via [`Self::with_span_aggregation`].
551    pub span_aggregation: Evidence<SpanHardwareAggregationV2>,
552}
553
554impl HardwareRunEvidenceV2 {
555    /// Attach CPI aggregation computed from the session's drained span records.
556    pub fn with_span_aggregation(mut self, spans: &[SpanRecordV2]) -> Self {
557        self.span_aggregation = Evidence::recorded(aggregate_span_hardware(spans));
558        self
559    }
560}
561
562/// Linux perf, Windows cycle-time, or stub collector for hardware counters.
563pub struct HardwareCounterCollector {
564    capability: CollectorCapability,
565    state: platform::CounterState,
566    started: std::time::Instant,
567}
568
569impl HardwareCounterCollector {
570    pub fn new() -> Self {
571        let platform = platform::platform_collectors();
572        Self {
573            capability: platform.counter_capability,
574            state: platform.counters,
575            started: std::time::Instant::now(),
576        }
577    }
578}
579
580impl Default for HardwareCounterCollector {
581    fn default() -> Self {
582        Self::new()
583    }
584}
585
586impl SnapshotCollector for HardwareCounterCollector {
587    type Snapshot = HardwareCounterSampleV2;
588
589    fn capability(&self) -> CollectorCapability {
590        self.capability.clone()
591    }
592
593    fn sample(&mut self) -> Self::Snapshot {
594        let elapsed_ns = u64::try_from(self.started.elapsed().as_nanos()).unwrap_or(u64::MAX);
595        platform::sample_counters(&mut self.state, elapsed_ns)
596    }
597}
598
599/// Context-switch, migration, and runqueue-delay collector.
600pub struct SchedulerCollector {
601    capability: CollectorCapability,
602    state: platform::SchedulerState,
603}
604
605impl SchedulerCollector {
606    pub fn new() -> Self {
607        let platform = platform::platform_collectors();
608        Self {
609            capability: platform.scheduler_capability,
610            state: platform.scheduler,
611        }
612    }
613}
614
615impl Default for SchedulerCollector {
616    fn default() -> Self {
617        Self::new()
618    }
619}
620
621impl SnapshotCollector for SchedulerCollector {
622    type Snapshot = SchedulerSampleV2;
623
624    fn capability(&self) -> CollectorCapability {
625        self.capability.clone()
626    }
627
628    fn sample(&mut self) -> Self::Snapshot {
629        platform::sample_scheduler(&mut self.state)
630    }
631}
632
633/// Per-thread CPU utilization and frequency sampler.
634pub struct ThreadUtilizationCollector {
635    capability: CollectorCapability,
636    started: std::time::Instant,
637}
638
639impl ThreadUtilizationCollector {
640    pub fn new() -> Self {
641        let platform = platform::platform_collectors();
642        Self {
643            capability: platform.utilization_capability,
644            started: std::time::Instant::now(),
645        }
646    }
647}
648
649impl Default for ThreadUtilizationCollector {
650    fn default() -> Self {
651        Self::new()
652    }
653}
654
655impl SnapshotCollector for ThreadUtilizationCollector {
656    type Snapshot = ThreadUtilizationSampleV2;
657
658    fn capability(&self) -> CollectorCapability {
659        self.capability.clone()
660    }
661
662    fn sample(&mut self) -> Self::Snapshot {
663        let elapsed_ns = u64::try_from(self.started.elapsed().as_nanos()).unwrap_or(u64::MAX);
664        let (threads, dropped) = platform::sample_thread_utilization();
665        let dropped_threads =
666            dropped.saturating_add(threads.len().saturating_sub(MAX_SAMPLE_THREADS) as u64);
667        ThreadUtilizationSampleV2 {
668            version: HARDWARE_EVIDENCE_V2_VERSION,
669            elapsed_ns,
670            threads: threads.into_iter().take(MAX_SAMPLE_THREADS).collect(),
671            dropped_threads,
672        }
673    }
674}
675
676/// Static CPU topology, affinity, NUMA, and cgroup limit collector.
677pub struct TopologyCollector {
678    capability: CollectorCapability,
679}
680
681impl TopologyCollector {
682    pub fn new() -> Self {
683        let platform = platform::platform_collectors();
684        Self {
685            capability: platform.topology_capability,
686        }
687    }
688}
689
690impl Default for TopologyCollector {
691    fn default() -> Self {
692        Self::new()
693    }
694}
695
696impl SnapshotCollector for TopologyCollector {
697    type Snapshot = TopologyEvidenceV2;
698
699    fn capability(&self) -> CollectorCapability {
700        self.capability.clone()
701    }
702
703    fn sample(&mut self) -> Self::Snapshot {
704        platform::capture_topology()
705    }
706}
707
708/// Raw per-span counter reading captured on the span hot path.
709#[derive(Clone, Copy, Default)]
710pub(crate) struct SpanCounterReading {
711    pub cycles: Option<u64>,
712    pub instructions: Option<u64>,
713}
714
715/// Read this thread's raw counters for one span edge; `None` when unsupported.
716///
717/// Costs one relaxed atomic load when the feature is disabled or perf is
718/// restricted, and two counter reads per edge otherwise. Never allocates.
719#[inline]
720pub(crate) fn span_counter_reading() -> Option<SpanCounterReading> {
721    platform::span_counter_reading()
722}
723
724/// Session-scoped hardware sampling state owned by `Session`.
725pub(crate) struct HardwareSession {
726    counter_collector: HardwareCounterCollector,
727    scheduler_collector: SchedulerCollector,
728    utilization_collector: ThreadUtilizationCollector,
729    topology_collector: TopologyCollector,
730    counters_start: Option<HardwareCounterSampleV2>,
731    scheduler_start: Option<SchedulerSampleV2>,
732    utilization_samples: Vec<ThreadUtilizationSampleV2>,
733    dropped_utilization_samples: u64,
734    frequency_samples: Vec<CpuFrequencySampleV2>,
735    dropped_frequency_samples: u64,
736    topology: Option<TopologyEvidenceV2>,
737    disabled_reason: Option<EvidenceGap>,
738}
739
740impl HardwareSession {
741    pub(crate) fn new() -> Self {
742        let mut counter_collector = HardwareCounterCollector::new();
743        let mut scheduler_collector = SchedulerCollector::new();
744        let utilization_collector = ThreadUtilizationCollector::new();
745        let mut topology_collector = TopologyCollector::new();
746        let disabled_reason = match counter_collector.capability.availability {
747            CollectorAvailability::Disabled => Some(EvidenceGap::CollectorDisabled),
748            _ => None,
749        };
750        let counters_start = (disabled_reason.is_none()).then(|| counter_collector.sample());
751        let scheduler_start = (disabled_reason.is_none()).then(|| scheduler_collector.sample());
752        let topology = (disabled_reason.is_none()).then(|| topology_collector.sample());
753        let mut session = Self {
754            counter_collector,
755            scheduler_collector,
756            utilization_collector,
757            topology_collector,
758            counters_start,
759            scheduler_start,
760            utilization_samples: Vec::new(),
761            dropped_utilization_samples: 0,
762            frequency_samples: Vec::new(),
763            dropped_frequency_samples: 0,
764            topology,
765            disabled_reason,
766        };
767        if session.disabled_reason.is_none() {
768            session.transition_sample();
769        }
770        session
771    }
772
773    /// Sample per-thread CPU and frequency at one macro-state boundary.
774    pub(crate) fn transition_sample(&mut self) {
775        if self.disabled_reason.is_some() {
776            return;
777        }
778        if self.utilization_samples.len() == MAX_UTILIZATION_SAMPLES {
779            self.dropped_utilization_samples = self.dropped_utilization_samples.saturating_add(1);
780        } else {
781            self.utilization_samples
782                .push(self.utilization_collector.sample());
783        }
784        if self.frequency_samples.len() == MAX_UTILIZATION_SAMPLES {
785            self.dropped_frequency_samples = self.dropped_frequency_samples.saturating_add(1);
786        } else if let Some(sample) = platform::sample_frequency(self.utilization_elapsed_ns()) {
787            self.frequency_samples.push(sample);
788        }
789    }
790
791    fn utilization_elapsed_ns(&self) -> u64 {
792        self.utilization_samples
793            .last()
794            .map_or(0, |sample| sample.elapsed_ns)
795    }
796
797    pub(crate) fn capabilities(&self) -> Vec<CollectorCapability> {
798        vec![
799            self.counter_collector.capability(),
800            self.scheduler_collector.capability(),
801            self.utilization_collector.capability(),
802            self.topology_collector.capability(),
803        ]
804    }
805
806    /// Compute final evidence, recording run totals as typed session counters.
807    pub(crate) fn finish_evidence(
808        mut self,
809        wall_ns: u64,
810        runtime: &crate::Runtime,
811    ) -> Evidence<HardwareRunEvidenceV2> {
812        if let Some(reason) = self.disabled_reason {
813            return Evidence::unavailable(reason);
814        }
815        self.transition_sample();
816        let counters = match &self.counters_start {
817            Some(start) => {
818                let end = self.counter_collector.sample();
819                HardwareCounterSetV2::between(start, &end)
820            }
821            None => HardwareCounterSetV2::all_gapped(EvidenceGap::Unavailable),
822        };
823        let scheduler = match &self.scheduler_start {
824            Some(start) => {
825                let end = self.scheduler_collector.sample();
826                SchedulerEvidenceV2::between(start, &end)
827            }
828            None => SchedulerEvidenceV2::between(
829                &platform::empty_scheduler_sample(),
830                &platform::empty_scheduler_sample(),
831            ),
832        };
833        let utilization = compute_utilization(
834            &self.utilization_samples,
835            self.dropped_utilization_samples,
836            wall_ns,
837            self.topology.as_ref().map_or_else(
838                || std::thread::available_parallelism().map_or(1, |n| n.get() as u32),
839                |t| t.logical_cpus,
840            ),
841            std::mem::take(&mut self.frequency_samples),
842            self.dropped_frequency_samples,
843        );
844        record_hardware_counters(runtime, &counters, &scheduler);
845        Evidence::recorded(HardwareRunEvidenceV2 {
846            version: HARDWARE_EVIDENCE_V2_VERSION,
847            counters,
848            scheduler,
849            utilization,
850            topology: self
851                .topology
852                .take()
853                .unwrap_or_else(platform::capture_topology),
854            span_aggregation: gap(EvidenceGap::Unavailable),
855        })
856    }
857}
858
859fn record_hardware_counters(
860    runtime: &crate::Runtime,
861    counters: &HardwareCounterSetV2,
862    scheduler: &SchedulerEvidenceV2,
863) {
864    let pairs: [(&SourcedEvidenceV2<u64>, crate::CounterId); 12] = [
865        (&counters.cycles, crate::CounterId::HardwareCycles),
866        (
867            &counters.instructions,
868            crate::CounterId::HardwareInstructions,
869        ),
870        (
871            &counters.cache_references,
872            crate::CounterId::HardwareCacheReferences,
873        ),
874        (
875            &counters.cache_misses,
876            crate::CounterId::HardwareCacheMisses,
877        ),
878        (
879            &counters.branch_instructions,
880            crate::CounterId::HardwareBranchInstructions,
881        ),
882        (
883            &counters.branch_misses,
884            crate::CounterId::HardwareBranchMisses,
885        ),
886        (
887            &counters.stalled_cycles_frontend,
888            crate::CounterId::HardwareStalledCyclesFrontend,
889        ),
890        (
891            &counters.stalled_cycles_backend,
892            crate::CounterId::HardwareStalledCyclesBackend,
893        ),
894        (
895            &scheduler.voluntary_context_switches,
896            crate::CounterId::SchedulerVoluntaryContextSwitches,
897        ),
898        (
899            &scheduler.involuntary_context_switches,
900            crate::CounterId::SchedulerInvoluntaryContextSwitches,
901        ),
902        (
903            &scheduler.cpu_migrations,
904            crate::CounterId::SchedulerCpuMigrations,
905        ),
906        (
907            &scheduler.scheduler_delay_ns,
908            crate::CounterId::SchedulerDelayNs,
909        ),
910    ];
911    for (field, counter) in pairs {
912        if let Evidence::Recorded { value } = field.value {
913            if value > 0 {
914                runtime.add_counter(counter, value);
915            }
916        }
917    }
918}
919
920fn compute_utilization(
921    samples: &[ThreadUtilizationSampleV2],
922    dropped_samples: u64,
923    wall_ns: u64,
924    logical_cpus: u32,
925    frequency_samples: Vec<CpuFrequencySampleV2>,
926    dropped_frequency_samples: u64,
927) -> UtilizationEvidenceV2 {
928    let frequency_availability = platform::frequency_availability();
929    let (Some(first), Some(last)) = (samples.first(), samples.last()) else {
930        return UtilizationEvidenceV2 {
931            version: HARDWARE_EVIDENCE_V2_VERSION,
932            wall_ns,
933            logical_cpus,
934            total_thread_cpu_ns: 0,
935            effective_parallelism_milli: gap(EvidenceGap::Unavailable),
936            capacity_utilization_milli: gap(EvidenceGap::Unavailable),
937            threads: Vec::new(),
938            exited_threads: 0,
939            joined_threads: 0,
940            samples_retained: samples.len() as u64,
941            dropped_samples,
942            frequency_samples,
943            frequency_availability,
944            dropped_frequency_samples,
945        };
946    };
947    let last_by_id: std::collections::BTreeMap<u64, u64> = last
948        .threads
949        .iter()
950        .map(|thread| (thread.thread_id, thread.cpu_time_ns))
951        .collect();
952    let first_ids: std::collections::BTreeSet<u64> = first
953        .threads
954        .iter()
955        .map(|thread| thread.thread_id)
956        .collect();
957    let mut threads = Vec::new();
958    let mut exited_threads = 0_u64;
959    let mut total = 0_u64;
960    for thread in &first.threads {
961        match last_by_id.get(&thread.thread_id) {
962            Some(end) => {
963                let cpu_time_ns = end.saturating_sub(thread.cpu_time_ns);
964                total = total.saturating_add(cpu_time_ns);
965                threads.push(ThreadUtilizationV2 {
966                    version: HARDWARE_EVIDENCE_V2_VERSION,
967                    thread_id: thread.thread_id,
968                    cpu_time_ns,
969                    utilization_milli: milli_ratio(cpu_time_ns, wall_ns)
970                        .map_or_else(|| gap(EvidenceGap::Unavailable), Evidence::recorded),
971                });
972            }
973            None => exited_threads = exited_threads.saturating_add(1),
974        }
975    }
976    let joined_threads = last
977        .threads
978        .iter()
979        .filter(|thread| !first_ids.contains(&thread.thread_id))
980        .count() as u64;
981    let capacity_ns = u128::from(wall_ns).saturating_mul(u128::from(logical_cpus));
982    UtilizationEvidenceV2 {
983        version: HARDWARE_EVIDENCE_V2_VERSION,
984        wall_ns,
985        logical_cpus,
986        total_thread_cpu_ns: total,
987        effective_parallelism_milli: milli_ratio(total, wall_ns)
988            .map_or_else(|| gap(EvidenceGap::Unavailable), Evidence::recorded),
989        capacity_utilization_milli: if capacity_ns == 0 {
990            gap(EvidenceGap::Unavailable)
991        } else {
992            Evidence::recorded(
993                u64::try_from(u128::from(total) * 1_000 / capacity_ns).unwrap_or(u64::MAX),
994            )
995        },
996        threads,
997        exited_threads,
998        joined_threads,
999        samples_retained: samples.len() as u64,
1000        dropped_samples,
1001        frequency_samples,
1002        frequency_availability,
1003        dropped_frequency_samples,
1004    }
1005}
1006
1007/// Stubs for builds without `hardware-counters` or on unhandled platforms:
1008/// every collector reports Disabled (feature off) or Unsupported (platform).
1009#[cfg(any(
1010    not(feature = "hardware-counters"),
1011    all(
1012        feature = "hardware-counters",
1013        not(any(target_os = "linux", target_os = "macos", windows))
1014    )
1015))]
1016mod stubs {
1017    use super::*;
1018    use crate::collector::CollectorId;
1019
1020    pub(super) const COUNTER_SOURCE: HardwareFieldSourceV2 = HardwareFieldSourceV2::SystemCall;
1021    pub(super) const MEMORY_STALL_GAP: EvidenceGap = EvidenceGap::Unsupported;
1022
1023    pub(super) struct CounterState;
1024    pub(super) struct SchedulerState;
1025
1026    pub(super) struct PlatformCollectors {
1027        pub counter_capability: CollectorCapability,
1028        pub scheduler_capability: CollectorCapability,
1029        pub utilization_capability: CollectorCapability,
1030        pub topology_capability: CollectorCapability,
1031        pub counters: CounterState,
1032        pub scheduler: SchedulerState,
1033    }
1034
1035    fn stub_availability() -> (CollectorAvailability, &'static str) {
1036        #[cfg(not(feature = "hardware-counters"))]
1037        {
1038            (
1039                CollectorAvailability::Disabled,
1040                "enable the keyhog-profile hardware-counters feature",
1041            )
1042        }
1043        #[cfg(feature = "hardware-counters")]
1044        {
1045            (
1046                CollectorAvailability::Unsupported,
1047                "CPU hardware evidence is implemented for Linux, Windows, and macOS only",
1048            )
1049        }
1050    }
1051
1052    pub(super) fn platform_collectors() -> PlatformCollectors {
1053        let (availability, detail) = stub_availability();
1054        let capability = |collector: CollectorId| {
1055            CollectorCapability::unavailable(collector, availability, detail)
1056        };
1057        PlatformCollectors {
1058            counter_capability: capability(CollectorId::HardwareCounters),
1059            scheduler_capability: capability(CollectorId::SchedulerActivity),
1060            utilization_capability: capability(CollectorId::ThreadUtilization),
1061            topology_capability: capability(CollectorId::CpuTopology),
1062            counters: CounterState,
1063            scheduler: SchedulerState,
1064        }
1065    }
1066
1067    fn gap_sample(reason: EvidenceGap) -> SourcedEvidenceV2<u64> {
1068        SourcedEvidenceV2::gapped(COUNTER_SOURCE, reason)
1069    }
1070
1071    fn stub_reason() -> EvidenceGap {
1072        #[cfg(not(feature = "hardware-counters"))]
1073        {
1074            EvidenceGap::CollectorDisabled
1075        }
1076        #[cfg(feature = "hardware-counters")]
1077        {
1078            EvidenceGap::Unsupported
1079        }
1080    }
1081
1082    pub(super) fn sample_counters(
1083        _state: &mut CounterState,
1084        elapsed_ns: u64,
1085    ) -> HardwareCounterSampleV2 {
1086        let reason = stub_reason();
1087        HardwareCounterSampleV2 {
1088            version: HARDWARE_EVIDENCE_V2_VERSION,
1089            elapsed_ns,
1090            cycles: gap_sample(reason),
1091            instructions: gap_sample(reason),
1092            cache_references: gap_sample(reason),
1093            cache_misses: gap_sample(reason),
1094            branch_instructions: gap_sample(reason),
1095            branch_misses: gap_sample(reason),
1096            stalled_cycles_frontend: gap_sample(reason),
1097            stalled_cycles_backend: gap_sample(reason),
1098            stalled_cycles_memory: SourcedEvidenceV2::gapped(
1099                HardwareFieldSourceV2::PerfEventOpen,
1100                EvidenceGap::Unsupported,
1101            ),
1102        }
1103    }
1104
1105    pub(super) fn empty_scheduler_sample() -> SchedulerSampleV2 {
1106        let reason = stub_reason();
1107        SchedulerSampleV2 {
1108            version: HARDWARE_EVIDENCE_V2_VERSION,
1109            voluntary_context_switches: gap_sample(reason),
1110            involuntary_context_switches: gap_sample(reason),
1111            total_context_switches: gap_sample(reason),
1112            cpu_migrations: gap_sample(reason),
1113            runqueue_delay_ns: gap_sample(reason),
1114            timeslices: gap_sample(reason),
1115        }
1116    }
1117
1118    pub(super) fn sample_scheduler(_state: &mut SchedulerState) -> SchedulerSampleV2 {
1119        empty_scheduler_sample()
1120    }
1121
1122    pub(super) fn sample_thread_utilization() -> (Vec<ThreadCpuV2>, u64) {
1123        (Vec::new(), 0)
1124    }
1125
1126    pub(super) fn sample_frequency(_elapsed_ns: u64) -> Option<CpuFrequencySampleV2> {
1127        None
1128    }
1129
1130    pub(super) fn frequency_availability() -> Evidence<HardwareFieldSourceV2> {
1131        Evidence::unavailable(stub_reason())
1132    }
1133
1134    pub(super) fn capture_topology() -> TopologyEvidenceV2 {
1135        let reason = stub_reason();
1136        let logical_cpus =
1137            std::thread::available_parallelism().map_or(1, |count| count.get() as u32);
1138        TopologyEvidenceV2 {
1139            version: HARDWARE_EVIDENCE_V2_VERSION,
1140            logical_cpus,
1141            physical_cores: SourcedEvidenceV2::gapped(HardwareFieldSourceV2::SysfsCpu, reason),
1142            packages: SourcedEvidenceV2::gapped(HardwareFieldSourceV2::SysfsCpu, reason),
1143            numa_nodes: SourcedEvidenceV2::gapped(HardwareFieldSourceV2::SysfsCpu, reason),
1144            affinity_cpus: SourcedEvidenceV2::gapped(HardwareFieldSourceV2::SystemCall, reason),
1145            cpu_quota_milli: SourcedEvidenceV2::gapped(HardwareFieldSourceV2::SysfsCgroup, reason),
1146        }
1147    }
1148
1149    #[inline]
1150    pub(crate) fn span_counter_reading() -> Option<SpanCounterReading> {
1151        None
1152    }
1153}