fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939)

* fix(perf): skip Performance Probe CPU snapshot poll on Windows

Windows has no Rust CPU/RSS sampler, but the probe still invoked
performance_cpu_snapshot every 2s when the modal or overlay pins were
active. Skip the IPC on unsupported platforms and only poll JS-side
metrics; do not start overlay polling for CPU/memory pins alone.

* fix(analysis): park backfill coordinator until Advanced is configured

#881 started run_coordinator_forever at app init with a 2s sleep even when
disabled, waking tokio on every platform for no work. Park on Notify instead;
wake on configure (enable/disable) and library sync-idle. Long sleeps use
select with wake so sync-idle can interrupt COMPLETED_RECHECK waits.

Investigation branch — not for merge until periodic CPU root cause is confirmed.

* fix(perf): stop probe overlay flicker on live poll updates

Publish CPU samples only after a valid jiffies baseline, skip no-op
snapshot emits, and sync sparkline history atomically in the store.
Overlay uses wall-clock sparkline time and auto-scales low CPU values.

* fix(perf): cut idle Rust CPU from probe scan, cover prefetch, and storage poll

Move performance_cpu_snapshot /proc work to spawn_blocking so tokio
workers are not charged with probe sampling. Stop lazy cover strategy
from running route prefetch disk stats every 1.5s, and slow hot-cache
size refresh on Settings → Storage to 15s.

* fix(perf): stabilize probe sparkline clock between live poll ticks

Track sampleAt separately from updatedAt so CPU rate history and overlay
sparklines only advance on real % changes, not FPS re-renders or RSS-only
poll ticks. Hold CPU sparkline Y scale with a peak ref to avoid scale jumps.

* fix(cover): restore lazy route prefetch without idle disk stats poll

Re-enable lazy cover registry warm-up so cached WebP paths reach
diskSrcCache before cells mount. Skip cover_cache_stats on every 1.5s
tick — drain batches via ensure only, poll full disk usage every 30s
when the registry is idle.

* docs: CHANGELOG and credits for PR #939 idle CPU perf fix

* fix(cover): peek before route prefetch ensure to match main responsiveness

Route prefetch moved batch drain ahead of cover_cache_stats for idle CPU,
which removed the accidental throttle and flooded ensure invoke slots.
Use warmCoverDiskSrcBatch first (cached hits skip ensure), ensure misses
only, and yield while high-priority viewport work is queued.
This commit is contained in:
cucadmuh
2026-06-01 15:50:17 +03:00
committed by GitHub
parent 4ac373a65b
commit 08b6aeeb17
14 changed files with 353 additions and 138 deletions
+6
View File
@@ -0,0 +1,6 @@
import { IS_LINUX, IS_MACOS } from '../platform';
/** Matches Rust `performance_cpu_snapshot` (Linux `/proc`, macOS sysinfo). */
export function perfLiveCpuSnapshotSupported(): boolean {
return IS_LINUX || IS_MACOS;
}
+7 -10
View File
@@ -35,7 +35,7 @@ function appendSample(id: string, value: number, at: number): boolean {
if (!Number.isFinite(value)) return false;
const existing = series.get(id) ?? [];
const last = existing[existing.length - 1];
if (last && last.at === at && last.value === value) return false;
if (last && last.value === value) return false;
const appended =
last && last.at === at
? [...existing.slice(0, -1), { at, value }]
@@ -50,23 +50,20 @@ export function recordPerfLiveHistory(id: string, value: number, at = Date.now()
if (appendSample(id, value, at)) emit();
}
/** Record pinned live samples for one poll tick; returns the new last-recorded timestamp. */
/** Record pinned live samples for one poll tick. */
export function syncPerfLiveHistoryFromPoll(
pins: Iterable<string>,
live: PerfLiveSnapshot,
lastRecordedAt: number,
): number {
if (!live.cpu?.supported || live.updatedAt <= 0 || live.updatedAt === lastRecordedAt) {
return lastRecordedAt;
}
options?: { emit?: boolean },
): void {
if (!live.cpu?.supported || live.sampleAt <= 0) return;
let changed = false;
for (const pin of pins) {
if (!isLiveHistoryPin(pin)) continue;
const value = liveOverlayItemValue(pin, live);
if (value != null && appendSample(pin, value, live.updatedAt)) changed = true;
if (value != null && appendSample(pin, value, live.sampleAt)) changed = true;
}
if (changed) emit();
return live.updatedAt;
if (changed && options?.emit !== false) emit();
}
export function getPerfLiveHistoryClock(ids: Iterable<string>): number {
+167 -52
View File
@@ -1,7 +1,9 @@
import { useSyncExternalStore } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { clearPerfLiveHistory } from './perfLiveHistory';
import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from './perfLiveHistory';
import { getAnalysisTracksPerMinute } from './analysisPerfStore';
import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot';
import { getPerfLiveOverlayPins } from './perfOverlayPins';
import {
buildPerfCpuSnapshotRequest,
getPerfLivePollIntervalMs,
@@ -46,8 +48,10 @@ export type PerfLiveSnapshot = {
diagRates: PerfDiagRates | null;
analysis: PerfAnalysisDiag | null;
collecting: boolean;
/** Wall time of the last CPU poll; shared clock for overlay sparklines. */
/** Wall time of the last displayed sample change (memory / diag / rates). */
updatedAt: number;
/** Wall time of the last CPU rate sample; stable sparkline clock between polls. */
sampleAt: number;
};
type ProcSnapshot = {
@@ -66,6 +70,7 @@ const EMPTY: PerfLiveSnapshot = {
analysis: null,
collecting: false,
updatedAt: 0,
sampleAt: 0,
};
let snapshot: PerfLiveSnapshot = { ...EMPTY };
@@ -86,6 +91,56 @@ function setSnapshot(next: PerfLiveSnapshot): void {
emit();
}
function memoryRowsEqual(a: readonly PerfProcessMemory[], b: readonly PerfProcessMemory[]): boolean {
if (a.length !== b.length) return false;
return a.every((row, index) => row.label === b[index].label && row.rss_kb === b[index].rss_kb);
}
function threadCpuEqual(a: readonly PerfThreadCpu[], b: readonly PerfThreadCpu[]): boolean {
if (a.length !== b.length) return false;
return a.every((row, index) => (
row.label === b[index].label
&& row.pct === b[index].pct
&& row.threadCount === b[index].threadCount
));
}
function diagRatesEqual(a: PerfDiagRates | null, b: PerfDiagRates | null): boolean {
if (a == null || b == null) return a === b;
return a.progress === b.progress && a.waveform === b.waveform && a.home === b.home;
}
function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null): boolean {
if (a == null || b == null) return a === b;
return a.tracksPerMinute === b.tracksPerMinute
&& a.lastTotalMs === b.lastTotalMs
&& a.lastFetchMs === b.lastFetchMs
&& a.lastSeedMs === b.lastSeedMs
&& a.lastBpmMs === b.lastBpmMs;
}
function cpuEqual(a: PerfLiveCpu | null, b: PerfLiveCpu | null): boolean {
if (a == null || b == null) return a === b;
return a.app === b.app
&& a.webkit === b.webkit
&& a.supported === b.supported
&& memoryRowsEqual(a.memory, b.memory)
&& threadCpuEqual(a.threadCpu, b.threadCpu);
}
function publishLiveSnapshot(next: PerfLiveSnapshot): void {
const cpuChanged = !cpuEqual(snapshot.cpu, next.cpu);
const diagChanged = !diagRatesEqual(snapshot.diagRates, next.diagRates);
const analysisChanged = !analysisEqual(snapshot.analysis, next.analysis);
if (!cpuChanged && !diagChanged && !analysisChanged && next.updatedAt === snapshot.updatedAt) {
return;
}
if (next.sampleAt > snapshot.sampleAt && next.cpu?.supported) {
syncPerfLiveHistoryFromPoll(getPerfLiveOverlayPins(), next, { emit: false });
}
setSnapshot(next);
}
function readUiCounters(): { progress: number; waveform: number; home: number } {
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
@@ -119,85 +174,139 @@ function nextDiagRates(
};
}
const UNSUPPORTED_CPU: PerfLiveCpu = {
app: 0,
webkit: 0,
supported: false,
memory: [],
threadCpu: [],
};
function applyJsMetricsSnapshot(now: number): void {
const nextCounters = readUiCounters();
const diagRates = nextDiagRates(nextCounters, now);
prevCounters = nextCounters;
prevCountersAt = now;
publishLiveSnapshot({
cpu: snapshot.cpu ?? UNSUPPORTED_CPU,
diagRates,
analysis: buildAnalysisDiag(),
collecting: false,
updatedAt: now,
sampleAt: snapshot.sampleAt,
});
}
async function pollOnce(): Promise<void> {
const generation = pollGeneration;
const now = Date.now();
if (!perfLiveCpuSnapshotSupported()) {
if (generation !== pollGeneration) return;
applyJsMetricsSnapshot(Date.now());
return;
}
try {
const snap = await invoke<ProcSnapshot>('performance_cpu_snapshot', buildPerfCpuSnapshotRequest());
if (generation !== pollGeneration) return;
const completedAt = Date.now();
const nextCounters = readUiCounters();
const diagRates = nextDiagRates(nextCounters, now);
const diagRates = nextDiagRates(nextCounters, completedAt);
prevCounters = nextCounters;
prevCountersAt = now;
prevCountersAt = completedAt;
if (!snap.supported) {
setSnapshot({
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
publishLiveSnapshot({
cpu: UNSUPPORTED_CPU,
diagRates,
analysis: buildAnalysisDiag(),
collecting: false,
updatedAt: now,
updatedAt: completedAt,
sampleAt: snapshot.sampleAt,
});
return;
}
const memory = snap.memory;
let cpu: PerfLiveCpu = {
app: snapshot.cpu?.app ?? 0,
webkit: snapshot.cpu?.webkit ?? 0,
supported: true,
memory,
threadCpu: snap.thread_cpu_groups.map(g => ({
label: g.label,
threadCount: g.thread_count,
pct: snapshot.cpu?.threadCpu.find(t => t.label === g.label)?.pct ?? 0,
})),
};
const baselineProc = prevProc;
const prevCpu = snapshot.cpu;
let app = prevCpu?.app ?? 0;
let webkit = prevCpu?.webkit ?? 0;
let threadCpu: PerfThreadCpu[] = prevCpu?.threadCpu ?? snap.thread_cpu_groups.map(g => ({
label: g.label,
threadCount: g.thread_count,
pct: 0,
}));
let rateSampleReady = false;
if (prevProc) {
const totalDelta = snap.total_jiffies - prevProc.total_jiffies;
const appDelta = snap.app_jiffies - prevProc.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prevProc.webkit_jiffies;
if (baselineProc) {
const totalDelta = snap.total_jiffies - baselineProc.total_jiffies;
const appDelta = snap.app_jiffies - baselineProc.app_jiffies;
const webkitDelta = snap.webkit_jiffies - baselineProc.webkit_jiffies;
if (totalDelta > 0) {
rateSampleReady = true;
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const prevThreadByLabel = new Map(
prevProc.thread_cpu_groups.map(g => [g.label, g.jiffies]),
baselineProc.thread_cpu_groups.map(g => [g.label, g.jiffies]),
);
cpu = {
app: clampPct((appDelta / totalDelta) * cpuScale),
webkit: clampPct((webkitDelta / totalDelta) * cpuScale),
supported: true,
memory,
threadCpu: snap.thread_cpu_groups.map(g => {
const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies;
const delta = g.jiffies - prevJiffies;
return {
label: g.label,
threadCount: g.thread_count,
pct: clampPct((delta / totalDelta) * cpuScale),
};
}),
};
app = clampPct((appDelta / totalDelta) * cpuScale);
webkit = clampPct((webkitDelta / totalDelta) * cpuScale);
threadCpu = snap.thread_cpu_groups.map(g => {
const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies;
const delta = g.jiffies - prevJiffies;
return {
label: g.label,
threadCount: g.thread_count,
pct: clampPct((delta / totalDelta) * cpuScale),
};
});
}
}
const memoryChanged = !memoryRowsEqual(prevCpu?.memory ?? [], memory);
const diagChanged = !diagRatesEqual(snapshot.diagRates, diagRates);
const ratesChanged = rateSampleReady && (
app !== (prevCpu?.app ?? 0)
|| webkit !== (prevCpu?.webkit ?? 0)
|| !threadCpuEqual(prevCpu?.threadCpu ?? [], threadCpu)
);
prevProc = snap;
setSnapshot({
cpu,
if (!rateSampleReady) {
if (baselineProc == null) return;
if (!memoryChanged && !diagChanged) return;
}
const nextUpdatedAt = (ratesChanged || memoryChanged || diagChanged)
? completedAt
: snapshot.updatedAt;
const nextSampleAt = ratesChanged ? completedAt : snapshot.sampleAt;
publishLiveSnapshot({
cpu: {
app,
webkit,
supported: true,
memory,
threadCpu,
},
diagRates,
analysis: buildAnalysisDiag(),
collecting: false,
updatedAt: now,
updatedAt: nextUpdatedAt,
sampleAt: nextSampleAt,
});
} catch {
if (generation !== pollGeneration) return;
setSnapshot({
publishLiveSnapshot({
...snapshot,
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
collecting: false,
updatedAt: Date.now(),
sampleAt: 0,
});
}
}
@@ -209,7 +318,6 @@ function clampPct(value: number): number {
function schedulePoll(): void {
if (pollTimer != null) return;
setSnapshot({ ...snapshot, collecting: snapshot.cpu == null });
const intervalMs = getPerfLivePollIntervalMs();
const tick = () => {
pollTimer = null;
@@ -265,16 +373,23 @@ export function acquirePerfLivePoll(_reason: string): () => void {
};
}
/** True while the first CPU baseline sample is still pending. */
export function isPerfLivePollWaitingForCpu(): boolean {
return pollRefCount > 0 && snapshot.cpu == null && perfLiveCpuSnapshotSupported();
}
export function patchPerfLiveAnalysis(partial: Partial<PerfAnalysisDiag>): void {
setSnapshot({
const nextAnalysis: PerfAnalysisDiag = {
tracksPerMinute: partial.tracksPerMinute ?? snapshot.analysis?.tracksPerMinute ?? 0,
lastTotalMs: partial.lastTotalMs ?? snapshot.analysis?.lastTotalMs ?? null,
lastFetchMs: partial.lastFetchMs ?? snapshot.analysis?.lastFetchMs ?? null,
lastSeedMs: partial.lastSeedMs ?? snapshot.analysis?.lastSeedMs ?? null,
lastBpmMs: partial.lastBpmMs ?? snapshot.analysis?.lastBpmMs ?? null,
};
if (analysisEqual(snapshot.analysis, nextAnalysis)) return;
publishLiveSnapshot({
...snapshot,
analysis: {
tracksPerMinute: partial.tracksPerMinute ?? snapshot.analysis?.tracksPerMinute ?? 0,
lastTotalMs: partial.lastTotalMs ?? snapshot.analysis?.lastTotalMs ?? null,
lastFetchMs: partial.lastFetchMs ?? snapshot.analysis?.lastFetchMs ?? null,
lastSeedMs: partial.lastSeedMs ?? snapshot.analysis?.lastSeedMs ?? null,
lastBpmMs: partial.lastBpmMs ?? snapshot.analysis?.lastBpmMs ?? null,
},
analysis: nextAnalysis,
});
}
+11 -1
View File
@@ -1,5 +1,6 @@
import { useSyncExternalStore } from 'react';
import { clearPerfLiveHistory } from './perfLiveHistory';
import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot';
import { getPerfOverlayMode } from './perfOverlayMode';
import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from './perfFlags';
@@ -126,7 +127,16 @@ export function hasAnyPerfOverlayVisible(): boolean {
);
}
function livePinsNeedJsPoll(pins: ReadonlySet<string>): boolean {
for (const id of pins) {
if (id.startsWith('rate:') || id.startsWith('analysis:')) return true;
}
return false;
}
export function hasAnyLiveMetricPollNeed(): boolean {
if (getPerfOverlayMode() !== 'pinned') return false;
return livePins.size > 0;
if (livePins.size === 0) return false;
if (perfLiveCpuSnapshotSupported()) return true;
return livePinsNeedJsPoll(livePins);
}