mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(perf): cover pipeline throughput (cpm) in performance probe (#945)
* feat(perf): cover pipeline throughput (cpm) in performance probe Mirror the analysis pipeline's tpm for covers. A new coverPerfStore samples the backfill `done` progress from cover:library-progress events and derives a rolling one-minute covers-per-minute rate. Surfaced as a live diag in perfLiveStore, a pinnable "Cover backfill" throughput card in the Monitor tab, and a cpm row in the Cover pipeline overlay block. * docs(changelog): note cover pipeline cpm metric (PR #945) Add CHANGELOG entry and credits line for the cover-pipeline covers-per-minute throughput metric in the Performance Probe.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getCoverCachedPerMinute,
|
||||
getCoverPerfState,
|
||||
recordCoverProgress,
|
||||
resetCoverPerfStateForTest,
|
||||
} from './coverPerfStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetCoverPerfStateForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('coverPerfStore', () => {
|
||||
it('derives covers-per-minute from done deltas over time', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
recordCoverProgress({ done: 100, total: 1000, pending: 900 });
|
||||
vi.advanceTimersByTime(30_000);
|
||||
recordCoverProgress({ done: 130, total: 1000, pending: 870 });
|
||||
// +30 covers over 30s ≈ 60 cpm.
|
||||
expect(getCoverCachedPerMinute()).toBeCloseTo(60, 0);
|
||||
expect(getCoverPerfState().done).toBe(130);
|
||||
});
|
||||
|
||||
it('returns 0 with a single sample and prunes the window after a minute', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_000_000);
|
||||
recordCoverProgress({ done: 10 });
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
vi.advanceTimersByTime(20_000);
|
||||
recordCoverProgress({ done: 20 });
|
||||
expect(getCoverCachedPerMinute()).toBeGreaterThan(0);
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
});
|
||||
|
||||
it('resets the window on a backwards jump (server switch / cache clear)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(3_000_000);
|
||||
recordCoverProgress({ done: 500 });
|
||||
vi.advanceTimersByTime(5_000);
|
||||
recordCoverProgress({ done: 5 });
|
||||
// Only the new baseline remains → no rate yet.
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
expect(getCoverPerfState().done).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Cover-pipeline throughput store — the cover analogue of `analysisPerfStore`.
|
||||
*
|
||||
* The backfill worker emits cumulative `done` (covers cached) on the
|
||||
* `cover:library-progress` event. We sample `done` over a rolling one-minute
|
||||
* window and derive covers-per-minute (cpm), mirroring analysis tpm.
|
||||
*/
|
||||
export type CoverProgressSample = {
|
||||
at: number;
|
||||
done: number;
|
||||
};
|
||||
|
||||
type CoverPerfState = {
|
||||
samples: CoverProgressSample[];
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
let state: CoverPerfState = { samples: [], done: 0, total: 0, pending: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
function pruneSamples(now: number, samples: readonly CoverProgressSample[]): CoverProgressSample[] {
|
||||
const cutoff = now - WINDOW_MS;
|
||||
return samples.filter(s => s.at >= cutoff);
|
||||
}
|
||||
|
||||
export function recordCoverProgress(payload: {
|
||||
done: number;
|
||||
total?: number;
|
||||
pending?: number;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
const done = Math.max(0, Math.floor(payload.done));
|
||||
let samples = pruneSamples(now, state.samples);
|
||||
// A backwards jump means a different pass (server switch / cache clear) — start
|
||||
// a fresh window so the old baseline doesn't inflate or zero out the rate.
|
||||
if (samples.length > 0 && done < samples[samples.length - 1].done) {
|
||||
samples = [];
|
||||
}
|
||||
samples = [...samples, { at: now, done }];
|
||||
state = {
|
||||
samples,
|
||||
done,
|
||||
total: payload.total ?? state.total,
|
||||
pending: payload.pending ?? state.pending,
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Covers cached per minute over the rolling window (0 when idle). */
|
||||
export function getCoverCachedPerMinute(now = Date.now()): number {
|
||||
const samples = pruneSamples(now, state.samples);
|
||||
if (samples.length < 2) return 0;
|
||||
const first = samples[0];
|
||||
const last = samples[samples.length - 1];
|
||||
const delta = Math.max(0, last.done - first.done);
|
||||
if (delta === 0) return 0;
|
||||
const spanMs = Math.max(1, Math.min(WINDOW_MS, now - first.at));
|
||||
return (delta / spanMs) * WINDOW_MS;
|
||||
}
|
||||
|
||||
export function getCoverPerfState(): CoverPerfState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeCoverPerf(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function useCoverPerfState(): CoverPerfState {
|
||||
return useSyncExternalStore(subscribeCoverPerf, getCoverPerfState, () => state);
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function resetCoverPerfStateForTest(): void {
|
||||
state = { samples: [], done: 0, total: 0, pending: 0 };
|
||||
emit();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PerfLiveSnapshot } from './perfLiveStore';
|
||||
|
||||
export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis';
|
||||
export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis' | 'cover';
|
||||
|
||||
export type LiveOverlayItem = {
|
||||
id: string;
|
||||
@@ -89,6 +89,13 @@ export function buildLiveOverlayItems(
|
||||
kind: 'analysis',
|
||||
sparkline: false,
|
||||
});
|
||||
} else if (pin === 'cover:cpm' && live.cover) {
|
||||
items.push({
|
||||
id: pin,
|
||||
line: `cover ${live.cover.cachedPerMinute.toFixed(1)} cpm`,
|
||||
kind: 'cover',
|
||||
sparkline: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from './perfLiveHistory';
|
||||
import { getAnalysisTracksPerMinute } from './analysisPerfStore';
|
||||
import { getCoverCachedPerMinute, getCoverPerfState } from './coverPerfStore';
|
||||
import { perfLiveCpuSnapshotSupported } from './perfLiveCpuSnapshot';
|
||||
import { getPerfLiveOverlayPins } from './perfOverlayPins';
|
||||
import {
|
||||
@@ -43,10 +44,18 @@ export type PerfAnalysisDiag = {
|
||||
lastBpmMs: number | null;
|
||||
};
|
||||
|
||||
export type PerfCoverDiag = {
|
||||
cachedPerMinute: number;
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PerfLiveSnapshot = {
|
||||
cpu: PerfLiveCpu | null;
|
||||
diagRates: PerfDiagRates | null;
|
||||
analysis: PerfAnalysisDiag | null;
|
||||
cover: PerfCoverDiag | null;
|
||||
collecting: boolean;
|
||||
/** Wall time of the last displayed sample change (memory / diag / rates). */
|
||||
updatedAt: number;
|
||||
@@ -68,6 +77,7 @@ const EMPTY: PerfLiveSnapshot = {
|
||||
cpu: null,
|
||||
diagRates: null,
|
||||
analysis: null,
|
||||
cover: null,
|
||||
collecting: false,
|
||||
updatedAt: 0,
|
||||
sampleAt: 0,
|
||||
@@ -119,6 +129,14 @@ function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null):
|
||||
&& a.lastBpmMs === b.lastBpmMs;
|
||||
}
|
||||
|
||||
function coverEqual(a: PerfCoverDiag | null, b: PerfCoverDiag | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.cachedPerMinute === b.cachedPerMinute
|
||||
&& a.done === b.done
|
||||
&& a.total === b.total
|
||||
&& a.pending === b.pending;
|
||||
}
|
||||
|
||||
function cpuEqual(a: PerfLiveCpu | null, b: PerfLiveCpu | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.app === b.app
|
||||
@@ -132,7 +150,8 @@ 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) {
|
||||
const coverChanged = !coverEqual(snapshot.cover, next.cover);
|
||||
if (!cpuChanged && !diagChanged && !analysisChanged && !coverChanged && next.updatedAt === snapshot.updatedAt) {
|
||||
return;
|
||||
}
|
||||
if (next.sampleAt > snapshot.sampleAt && next.cpu?.supported) {
|
||||
@@ -161,6 +180,16 @@ function buildAnalysisDiag(): PerfAnalysisDiag {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoverDiag(): PerfCoverDiag {
|
||||
const cover = getCoverPerfState();
|
||||
return {
|
||||
cachedPerMinute: getCoverCachedPerMinute(),
|
||||
done: cover.done,
|
||||
total: cover.total,
|
||||
pending: cover.pending,
|
||||
};
|
||||
}
|
||||
|
||||
function nextDiagRates(
|
||||
nextCounters: { progress: number; waveform: number; home: number },
|
||||
now: number,
|
||||
@@ -191,6 +220,7 @@ function applyJsMetricsSnapshot(now: number): void {
|
||||
cpu: snapshot.cpu ?? UNSUPPORTED_CPU,
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: now,
|
||||
sampleAt: snapshot.sampleAt,
|
||||
@@ -221,6 +251,7 @@ async function pollOnce(): Promise<void> {
|
||||
cpu: UNSUPPORTED_CPU,
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: completedAt,
|
||||
sampleAt: snapshot.sampleAt,
|
||||
@@ -295,6 +326,7 @@ async function pollOnce(): Promise<void> {
|
||||
},
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: nextUpdatedAt,
|
||||
sampleAt: nextSampleAt,
|
||||
|
||||
@@ -16,7 +16,8 @@ export type PerfLiveOverlayPinId =
|
||||
| 'rate:waveform'
|
||||
| 'rate:home'
|
||||
| 'analysis:tpm'
|
||||
| 'analysis:last';
|
||||
| 'analysis:last'
|
||||
| 'cover:cpm';
|
||||
|
||||
const PIPELINE_PIN_TO_FLAG = {
|
||||
'pipeline:fps': 'showFpsOverlay',
|
||||
@@ -129,7 +130,7 @@ export function hasAnyPerfOverlayVisible(): boolean {
|
||||
|
||||
function livePinsNeedJsPoll(pins: ReadonlySet<string>): boolean {
|
||||
for (const id of pins) {
|
||||
if (id.startsWith('rate:') || id.startsWith('analysis:')) return true;
|
||||
if (id.startsWith('rate:') || id.startsWith('analysis:') || id.startsWith('cover:')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user