josie / alder-tools

TUI: per-core freq bars (HT-pair max) + temp number, per-core turbo ceiling scale

a4b120f828b8d548e3acbdc550df7dba61ef1184
josie <administrator@josie-c.com> · 2026-08-29T12:30 · browse files at this commit

parents: 3b726d9

diff --git a/src/sensors.rs b/src/sensors.rs
index 73e67b8..1b89878 100644
--- a/src/sensors.rs
+++ b/src/sensors.rs
@@ -122,6 +122,16 @@ pub fn cpu_frequencies() -> Vec<(usize, u64)> {
     out
 }
 
+/// Max frequency (kHz) advertised for a logical cpu (its turbo ceiling).
+pub fn cpu_max_freq(cpu: usize) -> Option<u64> {
+    read_int(
+        &Path::new("/sys/devices/system/cpu")
+            .join(format!("cpu{cpu}"))
+            .join("cpufreq/cpuinfo_max_freq"),
+    )
+    .map(|v| v as u64)
+}
+
 /// A RAPL power domain with an `energy_uj` counter, e.g. package or core.
 pub struct RaplDomain {
     pub id: String,   // sysfs dir name, e.g. "intel-rapl:0"
diff --git a/src/ui.rs b/src/ui.rs
index 4891f2d..0e47b15 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -35,12 +35,21 @@ pub struct App {
     peak_ring: Vec<(Instant, f64)>,
     package_watts: Option<f64>,
     package_temp: Option<f64>,
-    p_temps: Vec<(String, f64)>,
-    e_temps: Vec<(String, f64)>,
-    freq_max_khz: u64,
+    /// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz
+    /// (that core's cpuinfo_max_freq), temperature °C.
+    cores: Vec<CoreRow>,
     last_pkg_energy: Option<(u64, Instant)>,
 }
 
+/// One physical core's row in the per-core section.
+struct CoreRow {
+    label: String,
+    e_core: bool,
+    freq_khz: Option<u64>,
+    freq_max_khz: u64,
+    temp: Option<f64>,
+}
+
 impl App {
     pub fn new() -> App {
         App {
@@ -49,9 +58,7 @@ impl App {
             peak_ring: Vec::new(),
             package_watts: None,
             package_temp: None,
-            p_temps: Vec::new(),
-            e_temps: Vec::new(),
-            freq_max_khz: 0,
+            cores: Vec::new(),
             last_pkg_energy: None,
         }
     }
@@ -80,24 +87,47 @@ impl App {
                 .iter()
                 .find(|(l, _)| l.contains("Package"))
                 .map(|(_, v)| *v);
-            self.p_temps = temps
-                .iter()
-                .filter(|(l, _)| !l.contains("Package") && !is_e_core_label(l))
-                .cloned()
-                .collect();
-            self.e_temps = temps
+
+            // Per physical core: coretemp label "Core N" N == topology
+            // core_id; cpufreq is per logical cpu, so a core's speed is the
+            // max across its HT siblings. Verified mapping for the 12600KF:
+            // P core_ids 0/4/8/12/16/20 → cpus (0,1)..(10,11), E 28..31 →
+            // cpu12..15 (no HT).
+            let freqs = sensors::cpu_frequencies();
+            self.cores = temps
                 .iter()
-                .filter(|(l, _)| is_e_core_label(l))
-                .cloned()
+                .filter(|(l, _)| l.starts_with("Core "))
+                .map(|(label, t)| {
+                    let core_id: u64 = label
+                        .strip_prefix("Core ")
+                        .and_then(|d| d.parse().ok())
+                        .unwrap_or(u64::MAX);
+                    let cpus = core_to_cpus(core_id);
+                    let e_core = cpus.first().map_or(false, |c| *c >= 12);
+                    let freq_khz = cpus
+                        .iter()
+                        .filter_map(|c| {
+                            freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k)
+                        })
+                        .max();
+                    let freq_max_khz = cpus
+                        .iter()
+                        .filter_map(|c| sensors::cpu_max_freq(*c))
+                        .max()
+                        .unwrap_or(0);
+                    CoreRow {
+                        label: label.clone(),
+                        e_core,
+                        freq_khz,
+                        freq_max_khz,
+                        temp: Some(*t),
+                    }
+                })
                 .collect();
+            self.cores
+                .sort_by_key(|c| (c.e_core, c.label.clone()));
         }
 
-        self.freq_max_khz = sensors::cpu_frequencies()
-            .iter()
-            .map(|(_, khz)| *khz)
-            .max()
-            .unwrap_or(0);
-
         // Watts = energy delta over the tick (poll cadence IS the sample
         // interval; no extra sleep like the dump's fixed 500 ms window).
         if let Some(d) = sensors::rapl_domains()
@@ -121,10 +151,20 @@ impl App {
     }
 }
 
-/// Verified coretemp mapping for the 12600KF: E-cores are Core 28..=31,
-/// P-cores are Core 0/4/8/12/16/20 (plus "Package id 0").
-fn is_e_core_label(label: &str) -> bool {
-    matches!(label, "Core 28" | "Core 29" | "Core 30" | "Core 31")
+/// Verified topology mapping for the 12600KF: physical core N with HT has
+/// logical cpus (2k, 2k+1); P core_ids 0/4/8/12/16/20 map to pairs starting
+/// at core_id/2·2. E-cores 28..31 map 1:1 to cpu12..15.
+fn core_to_cpus(core_id: u64) -> Vec<usize> {
+    match core_id {
+        0 => vec![0, 1],
+        4 => vec![2, 3],
+        8 => vec![4, 5],
+        12 => vec![6, 7],
+        16 => vec![8, 9],
+        20 => vec![10, 11],
+        28..=31 => vec![core_id as usize - 16],
+        _ => Vec::new(),
+    }
 }
 
 fn temp_color(t: f64) -> Color {
@@ -266,46 +306,47 @@ pub fn draw(f: &mut Frame, app: &App) {
     ));
     lines.push(Line::from(""));
 
-    // ---- P cores ----
-    lines.push(Line::from(Span::styled(
-        "P-cores",
-        Style::default().fg(Color::Cyan),
-    )));
-    for (label, t) in &app.p_temps {
-        lines.push(meter_line(
-            label,
-            &format!("{:.0} °C", t),
-            Some(t / TEMP_BAR_MAX),
-            temp_color(*t),
-            w,
-            None,
-        ));
-    }
-    lines.push(Line::from(""));
-
-    // ---- E cores ----
-    lines.push(Line::from(Span::styled(
-        "E-cores",
-        Style::default().fg(Color::Cyan),
-    )));
-    for (label, t) in &app.e_temps {
-        lines.push(meter_line(
-            label,
-            &format!("{:.0} °C", t),
-            Some(t / TEMP_BAR_MAX),
-            temp_color(*t),
-            w,
-            None,
-        ));
+    // ---- cores: freq bar + temp number ----
+    for (section, pred) in [("P-cores", false), ("E-cores", true)] {
+        lines.push(Line::from(Span::styled(
+            section,
+            Style::default().fg(Color::Cyan),
+        )));
+        for core in app.cores.iter().filter(|c| c.e_core == pred) {
+            let text = match (core.freq_khz, core.temp) {
+                (Some(f), Some(t)) => format!(
+                    "{:.2}GHz  {:.0}°C",
+                    f as f64 / 1_000_000.0,
+                    t
+                ),
+                (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
+                (None, Some(t)) => format!("{:.0}°C", t),
+                (None, None) => "n/a".to_string(),
+            };
+            let ratio = match (core.freq_khz, core.freq_max_khz) {
+                (Some(f), max) if max > 0 => Some(f as f64 / max as f64),
+                _ => None,
+            };
+            let color = match core.temp {
+                Some(t) => temp_color(t),
+                None => Color::DarkGray,
+            };
+        lines.push(meter_line(&core.label, &text, ratio, color, w, None));
+        }
+        lines.push(Line::from(""));
     }
-    lines.push(Line::from(""));
 
     // ---- footer ----
     lines.push(Line::from(vec![
         Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
         Span::raw(format!(
             "{:.2} GHz",
-            app.freq_max_khz as f64 / 1_000_000.0
+            app.cores
+                .iter()
+                .filter_map(|c| c.freq_khz)
+                .max()
+                .unwrap_or(0) as f64
+                / 1_000_000.0
         )),
         Span::styled("    q: quit", Style::default().fg(Color::DarkGray)),
     ]));