Redesign approach for CPU usage tracking

The previous implementation maintained three exponential moving averages
(1/5/15-minute) of CPU busy-fraction, sampled every 5 seconds. This had
two problems:

1. Wrong semantics: the metric was labelled "load_avg" (a Unix concept
   measuring run-queue depth), but the tracker actually measures CPU
   busy-time as a fraction of FreeRTOS ticks — i.e. utilization, not
   load.  The name was misleading.

2. Poor responsiveness: the 1-minute EMA (DECAY = exp(-5/60) ≈ 0.920)
   has a ~60-second time constant.  On a live web panel, this made the
   metric appear frozen, especially during short bursts of activity
   (e.g. an HTTP request from the panel itself).

This commit replaces the EMA with a 64-sample simple moving average
(SMA) over a compact uint8_t circular buffer. The sample interval is
60 s / 64 = 937,500 µs, so the window covers exactly 60 seconds:

Algorithm:
- Every 937,500 µs (= 60 s / 64), the FreeRTOS tick delta (busy vs
  idle ticks on core 0) is computed and stored as a uint8_t
  (0–255 = 0–100% utilization).
- The circular buffer holds 64 samples, spanning exactly 60 seconds
  (64 × 937,500 µs = 60,000,000 µs). The timer interval is derived
  directly from the window size: 60 000 000 / SMA_WINDOW µs.
- The index wraps with a bitwise AND (& 63) instead of modulo, since
  64 is a power of 2.
- The running sum is a uint16_t (max 64 × 255 = 16 320, fits easily).
- The result _sma_avg is a volatile float written atomically by the
  esp_timer task (core 0) and read from the main loop (core 1).
  No spinlock is needed: on Xtensa LX7, a 32-bit aligned float store
  is a single instruction.

The 60-second window smooths out short bursts (e.g. WiFi/HTTP spikes)
while reacting to sustained load changes within ~10–15 seconds.

Naming:
- The public API is now getCore0Util() returning a float in [0.0, 1.0].
  The name explicitly identifies which core is measured (core 0, which
  runs WiFi, MQTT, HTTP, and LwIP — see task_pinning.c).
- JSON keys: "load_avg" (array) → "core0_util" (scalar float, percent)
- HistorySample field: load_avg1_pct → core0_util_pct
- StatsHistory series key: "cpu_load" → "core0_util"
- Web panel label: "Load Avg" → "Core0 Util"

The history snapshot (once per minute) reads the same _sma_avg value,
which at that point represents the rolling average of the last 60
seconds — exactly one history interval.
Этот коммит содержится в:
Valentin V. Bartenev
2026-05-10 06:26:08 +03:00
родитель 5bf017be1b
Коммит a9273f02bb
6 изменённых файлов: 47 добавлений и 53 удалений
+16 -13
Просмотреть файл
@@ -6,7 +6,7 @@ volatile uint32_t CPUUsageTracker::s_idle_ticks = 0;
volatile uint32_t CPUUsageTracker::s_busy_ticks = 0;
TaskHandle_t CPUUsageTracker::s_idle_handle = nullptr;
void IRAM_ATTR CPUUsageTracker::s_tick_hook() { // ← was CPUsageTracker (typo)
void IRAM_ATTR CPUUsageTracker::s_tick_hook() {
if (xTaskGetCurrentTaskHandle() == s_idle_handle) {
s_idle_ticks++;
} else {
@@ -15,23 +15,26 @@ void IRAM_ATTR CPUUsageTracker::s_tick_hook() { // ← was CPUsageTracker (typ
}
void CPUUsageTracker::s_sample_cb(void* arg) {
static_cast<CPUUsageTracker*>(arg)->_onSample(); // ← was CpuUsageTracker (old name)
static_cast<CPUUsageTracker*>(arg)->_onSample();
}
void CPUUsageTracker::_onSample() {
uint32_t busy = s_busy_ticks;
uint32_t idle = s_idle_ticks;
uint32_t db = busy - _last_busy;
uint32_t di = idle - _last_idle;
const uint32_t busy = s_busy_ticks;
const uint32_t idle = s_idle_ticks;
const uint32_t db = busy - _last_busy;
const uint32_t di = idle - _last_idle;
_last_busy = busy;
_last_idle = idle;
uint32_t total = db + di;
float sample = (total > 0) ? (float)db / total : 0.0f;
const uint32_t total = db + di;
const float sample = (total > 0) ? (float)db / (float)total : 0.0f;
_avg1 = DECAY1 * _avg1 + (1.0f - DECAY1) * sample;
_avg5 = DECAY5 * _avg5 + (1.0f - DECAY5) * sample;
_avg15 = DECAY15 * _avg15 + (1.0f - DECAY15) * sample;
const uint8_t s8 = (uint8_t)(sample * 255.0f + 0.5f);
_sma_sum -= _sma_buf[_sma_idx];
_sma_buf[_sma_idx] = s8;
_sma_sum += s8;
_sma_idx = (_sma_idx + 1) & (SMA_WINDOW - 1);
_sma_avg = (float)_sma_sum * (1.0f / (SMA_WINDOW * 255.0f));
}
void CPUUsageTracker::begin() {
@@ -42,10 +45,10 @@ void CPUUsageTracker::begin() {
.callback = s_sample_cb,
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "cpu_avg"
.name = "cpu_sample"
};
esp_timer_create(&args, &_timer);
esp_timer_start_periodic(_timer, 5000000ULL); // 5 seconds
esp_timer_start_periodic(_timer, 60000000ULL / SMA_WINDOW); // SMA_WINDOW samples per minute
}
#endif
+11 -11
Просмотреть файл
@@ -11,14 +11,10 @@ class CPUUsageTracker {
public:
void begin();
float getLoadAvg1() const { return _avg1; }
float getLoadAvg5() const { return _avg5; }
float getLoadAvg15() const { return _avg15; }
float getCore0Util() const { return _sma_avg; }
private:
static constexpr float DECAY1 = 0.920044415f; // exp(-5/60)
static constexpr float DECAY5 = 0.983471454f; // exp(-5/300)
static constexpr float DECAY15 = 0.994459848f; // exp(-5/900)
static constexpr uint8_t SMA_WINDOW = 64; // power of 2 — enables & mask
static volatile uint32_t s_idle_ticks;
static volatile uint32_t s_busy_ticks;
@@ -27,11 +23,15 @@ private:
static void IRAM_ATTR s_tick_hook();
static void s_sample_cb(void* arg);
uint32_t _last_idle = 0;
uint32_t _last_busy = 0;
float _avg1 = 0.0f;
float _avg5 = 0.0f;
float _avg15 = 0.0f;
uint32_t _last_idle = 0;
uint32_t _last_busy = 0;
uint8_t _sma_buf[SMA_WINDOW] = {};
uint16_t _sma_sum = 0;
uint8_t _sma_idx = 0;
volatile float _sma_avg = 0.0f;
esp_timer_handle_t _timer = nullptr;
void _onSample();
+5 -5
Просмотреть файл
@@ -1532,10 +1532,10 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) {
void MyMesh::formatStatsReply(char *reply, size_t reply_size) {
snprintf(reply,
reply_size,
"{\"battery_mv\":%u,\"uptime_secs\":%u,\"load_avg\":[%.2f,%.2f,%.2f],\"errors\":%u,\"queue_len\":%u}",
"{\"battery_mv\":%u,\"uptime_secs\":%u,\"core0_util\":%.1f,\"errors\":%u,\"queue_len\":%u}",
getBatteryMilliVolts(true),
_ms->getMillis() / 1000,
_cpu_tracker.getLoadAvg1(), _cpu_tracker.getLoadAvg5(), _cpu_tracker.getLoadAvg15(),
_cpu_tracker.getCore0Util() * 100.0f,
_err_flags,
_mgr->getOutboundTotal());
}
@@ -1941,7 +1941,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) {
sample.heap_min = ESP.getMinFreeHeap();
sample.psram_free = ESP.getFreePsram();
sample.psram_min = ESP.getMinFreePsram();
sample.load_avg1_pct = (uint8_t)(_cpu_tracker.getLoadAvg1() * 100.0f + 0.5f);
sample.core0_util_pct = (uint8_t)(_cpu_tracker.getCore0Util() * 100.0f + 0.5f);
#endif
if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER;
if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING;
@@ -2532,7 +2532,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
"\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\","
"\"total_bytes\":%llu,\"used_bytes\":%llu},"
"\"core\":{\"battery_mv\":%u,\"battery_pct\":%d,\"battery_display_pct\":%d,\"battery_min_mv\":%u,\"battery_max_mv\":%u,"
"\"uptime_secs\":%lu,\"load_avg\":[%.2f,%.2f,%.2f],\"errors\":%u,\"queue_len\":%u,"
"\"uptime_secs\":%lu,\"core0_util\":%.1f,\"errors\":%u,\"queue_len\":%u,"
"\"external_power\":%s,\"charging\":%s,\"vbus\":%s},"
"\"radio\":{\"noise_floor\":%d,\"last_rssi\":%.2f,\"last_snr\":%.2f,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu},"
"\"packets\":{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,"
@@ -2565,7 +2565,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
battery_min_mv,
battery_max_mv,
static_cast<unsigned long>(uptime_millis / 1000),
_cpu_tracker.getLoadAvg1(), _cpu_tracker.getLoadAvg5(), _cpu_tracker.getLoadAvg15(),
_cpu_tracker.getCore0Util() * 100.0f,
_err_flags,
static_cast<unsigned>(_mgr->getOutboundTotal()),
board.isExternalPowered() ? "true" : "false",
+9 -9
Просмотреть файл
@@ -211,8 +211,8 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous,
value = static_cast<int>(sample.battery_mv);
return true;
}
if (strcmp(series, "cpu_load") == 0) {
value = static_cast<int>(sample.load_avg1_pct);
if (strcmp(series, "core0_util") == 0) {
value = static_cast<int>(sample.core0_util_pct);
return true;
}
if (strcmp(series, "memory") == 0) {
@@ -313,8 +313,8 @@ const char* seriesTitle(const char* series) {
if (strcmp(series, "battery") == 0) {
return "Battery";
}
if (strcmp(series, "cpu_load") == 0) {
return "CPU Load";
if (strcmp(series, "core0_util") == 0) {
return "Core0 Util";
}
if (strcmp(series, "memory") == 0) {
return "Heap Free";
@@ -362,7 +362,7 @@ const char* seriesUnit(const char* series) {
if (strcmp(series, "battery") == 0) {
return "mV";
}
if (strcmp(series, "cpu_load") == 0) {
if (strcmp(series, "core0_util") == 0) {
return "pct";
}
if (strcmp(series, "memory") == 0) {
@@ -649,7 +649,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
long gps_lon_e6 = 0;
unsigned sensor_flags = 0;
unsigned gps_satellites = 0;
unsigned load_avg1_pct = 0;
unsigned core0_util_pct = 0;
int parsed = sscanf(line,
"%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u,%u",
@@ -681,7 +681,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
&gps_lon_e6,
&sensor_flags,
&gps_satellites,
&load_avg1_pct);
&core0_util_pct);
if (parsed == 29 || parsed == 28) {
memset(&sample, 0, sizeof(sample));
sample.epoch_secs = static_cast<uint32_t>(epoch_secs);
@@ -715,7 +715,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
sample.gps_satellites = static_cast<uint8_t>(gps_satellites);
sample.flags = static_cast<uint8_t>(flags);
sample.battery_pct = -1;
sample.load_avg1_pct = (parsed == 29) ? static_cast<uint8_t>(load_avg1_pct) : 0;
sample.core0_util_pct = (parsed == 29) ? static_cast<uint8_t>(core0_util_pct) : 0;
return true;
}
@@ -1129,7 +1129,7 @@ void StatsHistory::flushSummaryLog() {
static_cast<long>(latest.gps_lon_e6),
static_cast<unsigned>(latest.sensor_flags),
static_cast<unsigned>(latest.gps_satellites),
static_cast<unsigned>(latest.load_avg1_pct));
static_cast<unsigned>(latest.core0_util_pct));
File latest_file = openArchiveWriteWithRecovery(_archive, kSummaryLatestPath);
if (!latest_file) {
+1 -1
Просмотреть файл
@@ -37,7 +37,7 @@ struct HistorySample {
uint8_t gps_satellites;
uint8_t flags;
int8_t battery_pct;
uint8_t load_avg1_pct;
uint8_t core0_util_pct;
};
struct HistoryEvent {
+5 -14
Просмотреть файл
@@ -531,8 +531,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
:root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
.metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
.metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
.metric-spread { display:flex; justify-content:space-between; margin-top:4px; }
.metric-spread span { font-size:16px; font-weight:700; color:var(--text); }
.events-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; }
.events-table { width:100%; border-collapse:collapse; }
.events-table th, .events-table td { padding:10px 12px; text-align:left; font-size:13px; }
@@ -1490,14 +1488,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
<div class="metric-grid core-metrics">
${renderMetric("Uptime", formatDuration(core.uptime_secs))}
${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
${Array.isArray(core.load_avg) ? `<div class="metric">
<div class="metric-label">Load Avg</div>
<div class="metric-spread">
<span>${core.load_avg[0].toFixed(2)}</span>
<span>${core.load_avg[1].toFixed(2)}</span>
<span>${core.load_avg[2].toFixed(2)}</span>
</div>
</div>` : ""}
${renderMetric("Core0 Util", (core.core0_util || 0) + " %")}
${renderMetric("Queue", String(core.queue_len ?? 0))}
${renderMetric("Errors", String(core.errors ?? 0))}
</div>
@@ -1779,7 +1770,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const kTrendFmt = {
battery: [v => Math.round(v), "mV"],
voltage: [v => (v/100).toFixed(2), "V"],
cpu_load: [v => Math.round(v), "%"],
core0_util: [v => v, "%"],
memory: [v => formatBytes(v), ""],
packets: [v => Math.round(v), "pkts"],
error_rate: [v => (v/10).toFixed(1), "%"],
@@ -1827,7 +1818,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
if (recent >= 750) return "#d7a531"; // high
return "#2f8f4e"; // normal
}
if (key === "cpu_load") return "#e07b39";
if (key === "core0_util") return "#e07b39";
if (key === "error_rate") return "#ef4444";
if (key === "gps_satellites") return "#2f8f4e";
if (key === "signal") return "#3b82f6";
@@ -1847,7 +1838,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
return baseColor || "#2f8f4e";
}
function sparkValueRange(key, values) {
if (key === "cpu_load") {
if (key === "core0_util") {
return { min: 0, max: Math.max(10, ...values) };
}
if (key === "packets" || key === "gps_satellites") {
@@ -2110,7 +2101,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
const sensors = summaryPayload && summaryPayload.sensors ? summaryPayload.sensors : null;
const gpsEnabled = !!(sensors && sensors.gps_enabled === true);
const mcuTempPresent = !!(sensors && Number.isFinite(sensors.mcu_temp_c));
const order = ["battery", "cpu_load", "memory", "packets", "error_rate", "signal", "noise_floor"];
const order = ["battery", "core0_util", "memory", "packets", "error_rate", "signal", "noise_floor"];
if (mcuTempPresent) {
order.splice(1, 0, "mcu_temp");
}