feat: add metrics poller with ring buffers and GPU aggregates

This commit is contained in:
2026-08-14 10:41:04 -06:00
parent 28cc34dc59
commit 7dba134863
2 changed files with 179 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import { fetchMetrics, type LlamaSwapConfig } from "./llamaswap";
import { gpuInfos, parseGpuMetrics, type GpuMetricKind, type GpuSample } from "./metrics-parser";
const RING_SIZE = 60;
function avg(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / (values.length || 1);
}
function max(values: number[]): number {
return values.reduce((a, b) => Math.max(a, b), -Infinity);
}
function sum(values: number[]): number {
return values.reduce((a, b) => a + b, 0);
}
const AGGREGATE: Record<GpuMetricKind, (values: number[]) => number> = {
util_percent: avg,
memory_util_percent: avg,
temperature: max,
power: sum,
fan: avg,
};
function key(gpuId: string, kind: GpuMetricKind): string {
return `${gpuId}|${kind}`;
}
type FetchFn = (cfg: LlamaSwapConfig) => Promise<string>;
export class MetricsPoller {
private buffers = new Map<string, number[]>();
private gpuName = new Map<string, string>();
private gpuIds: string[] = [];
private listeners = new Set<() => void>();
private timer?: ReturnType<typeof setInterval>;
private lastError?: string;
constructor(
private cfg: LlamaSwapConfig,
private intervalMs = 5000,
private fetchFn: FetchFn = fetchMetrics,
) {}
start(): void {
void this.tick();
this.timer = setInterval(() => void this.tick(), this.intervalMs);
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
}
on(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async tick(): Promise<void> {
try {
const text = await this.fetchFn(this.cfg);
this.apply(parseGpuMetrics(text));
this.lastError = undefined;
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
} finally {
for (const listener of this.listeners) listener();
}
}
private apply(samples: GpuSample[]): void {
const byKind = new Map<GpuMetricKind, Map<string, number>>();
for (const s of samples) {
if (!byKind.has(s.kind)) byKind.set(s.kind, new Map());
byKind.get(s.kind)!.set(s.id, s.value);
if (!this.gpuName.has(s.id)) this.gpuName.set(s.id, s.name);
}
this.gpuIds = [...this.gpuName.keys()].sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
for (const [kind, values] of byKind) {
for (const [id, value] of values) this.push(key(id, kind), value);
const all = [...values.values()];
if (all.length > 0) this.push(key("all", kind), AGGREGATE[kind](all));
}
}
private push(k: string, value: number): void {
let ring = this.buffers.get(k);
if (!ring) {
ring = [];
this.buffers.set(k, ring);
}
ring.push(value);
if (ring.length > RING_SIZE) ring.shift();
}
getValue(gpuId: string, kind: GpuMetricKind): number | undefined {
const ring = this.buffers.get(key(gpuId, kind));
return ring ? ring[ring.length - 1] : undefined;
}
getHistory(gpuId: string, kind: GpuMetricKind): number[] {
const ring = this.buffers.get(key(gpuId, kind));
return ring ? [...ring] : [];
}
gpus(): { id: string; name: string }[] {
return this.gpuIds.map((id) => ({ id, name: this.gpuName.get(id) ?? "" }));
}
isOffline(): boolean {
return this.lastError !== undefined;
}
}