Skip to content

Commit bfa3bdb

Browse files
committed
Add energy usage tracking, hardware benchmarking, and Usage Dashboard
power-monitor.ts samples CPU/GPU power (Apple powermetrics, Linux RAPL, nvidia-smi/rocm-smi, or an estimated fallback) per active request; energy-usage-store.ts persists daily aggregates; benchmark-runner.ts measures real tokens/sec per model; UsageDashboard.tsx surfaces cost/energy/carbon and benchmark results.
1 parent 234b153 commit bfa3bdb

10 files changed

Lines changed: 972 additions & 2 deletions

‎app/src/benchmark-runner.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { approximateTokens, buildContextPrompt, contextTestSizes, runBenchmark, type BenchmarkChatExecutor } from "./benchmark-runner";
3+
4+
describe("benchmark helpers", () => {
5+
it("builds bounded power-of-two context steps plus an exact cap", () => {
6+
expect(contextTestSizes(10_000)).toEqual([2048, 4096, 8192, 10_000]);
7+
expect(contextTestSizes(100)).toEqual([2048]);
8+
});
9+
10+
it("creates approximately the requested prompt size", () => {
11+
const prompt = buildContextPrompt(4096);
12+
expect(approximateTokens(prompt)).toBeGreaterThan(4000);
13+
expect(approximateTokens(prompt)).toBeLessThan(5200);
14+
});
15+
16+
it("returns health, timing, comparison and context results", async () => {
17+
const execute: BenchmarkChatExecutor = async (_provider, _model, messages, _options, onToken) => {
18+
if (messages[0].content.startsWith("Context capacity")) return;
19+
onToken({ done: false, message: { role: "assistant", content: "benchmark output" } });
20+
onToken({ done: true, usage: { promptTokens: 20, completionTokens: 4 } });
21+
};
22+
const result = await runBenchmark(execute, {
23+
provider: "ollama",
24+
model: "test",
25+
maxContextLength: 4096,
26+
compareCpuGpu: true,
27+
});
28+
expect(result.health.healthy).toBe(true);
29+
expect(result.primary?.completionTokens).toBe(4);
30+
expect(result.comparison.supported).toBe(true);
31+
expect(result.contextTests).toHaveLength(2);
32+
});
33+
});
34+

‎app/src/benchmark-runner.ts‎

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import * as os from "node:os";
2+
import { execFile } from "node:child_process";
3+
import { promisify } from "node:util";
4+
import type { ChatChunk, ChatMessage, ChatOptions, ProviderId } from "./providers/types";
5+
6+
const execFileAsync = promisify(execFile);
7+
const SAMPLE_INTERVAL_MS = 250;
8+
9+
export interface BenchmarkRequest {
10+
provider: ProviderId;
11+
model: string;
12+
maxContextLength?: number;
13+
outputTokens?: number;
14+
compareCpuGpu?: boolean;
15+
}
16+
17+
export interface RuntimeHealthCheck {
18+
healthy: boolean;
19+
latencyMs: number;
20+
error?: string;
21+
}
22+
23+
export interface ResourcePeaks {
24+
peakSystemRamMB: number;
25+
peakAppRamMB: number;
26+
peakVramMB: number | null;
27+
vramMeasurement: "nvidia-smi" | "rocm-smi" | "unavailable";
28+
}
29+
30+
export interface InferenceMeasurement {
31+
mode: "default" | "cpu" | "gpu";
32+
tokensPerSecond: number;
33+
promptTokensPerSecond: number;
34+
timeToFirstTokenMs: number;
35+
totalTimeMs: number;
36+
promptTokens: number;
37+
completionTokens: number;
38+
resources: ResourcePeaks;
39+
}
40+
41+
export interface ContextTestResult {
42+
requestedTokens: number;
43+
accepted: boolean;
44+
elapsedMs: number;
45+
error?: string;
46+
}
47+
48+
export interface BenchmarkResult {
49+
schemaVersion: 1;
50+
id: string;
51+
createdAt: string;
52+
provider: ProviderId;
53+
model: string;
54+
health: RuntimeHealthCheck;
55+
primary: InferenceMeasurement | null;
56+
comparison: { cpu?: InferenceMeasurement; gpu?: InferenceMeasurement; supported: boolean; note?: string };
57+
contextTests: ContextTestResult[];
58+
warnings: string[];
59+
}
60+
61+
export type BenchmarkChatExecutor = (
62+
provider: ProviderId,
63+
model: string,
64+
messages: ChatMessage[],
65+
options: ChatOptions,
66+
onToken: (chunk: ChatChunk) => void,
67+
signal?: AbortSignal
68+
) => Promise<void>;
69+
70+
interface GpuSample {
71+
usedMB: number | null;
72+
source: ResourcePeaks["vramMeasurement"];
73+
}
74+
75+
function round(value: number, digits = 1): number {
76+
const scale = 10 ** digits;
77+
return Math.round(value * scale) / scale;
78+
}
79+
80+
export function approximateTokens(text: string): number {
81+
if (!text) return 0;
82+
return Math.max(1, Math.ceil(text.length / 4));
83+
}
84+
85+
export function contextTestSizes(maximum: number): number[] {
86+
const cap = Math.max(2048, Math.min(131072, Math.floor(maximum)));
87+
const sizes: number[] = [];
88+
for (let size = 2048; size <= cap; size *= 2) sizes.push(size);
89+
if (sizes[sizes.length - 1] !== cap) sizes.push(cap);
90+
return sizes;
91+
}
92+
93+
export function buildContextPrompt(targetTokens: number): string {
94+
// A repeated four-character token-like unit keeps allocation predictable.
95+
// Provider tokenizers differ, so the result remains an approximation.
96+
return `Context capacity test. Return only OK.\n${"test ".repeat(Math.max(1, targetTokens - 12))}`;
97+
}
98+
99+
async function sampleGpuMemory(): Promise<GpuSample> {
100+
try {
101+
const { stdout } = await execFileAsync(
102+
"nvidia-smi",
103+
["--query-compute-apps=used_memory", "--format=csv,noheader,nounits"],
104+
{ timeout: 2000, windowsHide: true }
105+
);
106+
const values = stdout.split(/\r?\n/).map(Number).filter(Number.isFinite);
107+
return { usedMB: values.length ? values.reduce((sum, value) => sum + value, 0) : 0, source: "nvidia-smi" };
108+
} catch {
109+
// Continue with the ROCm probe below.
110+
}
111+
try {
112+
const { stdout } = await execFileAsync("rocm-smi", ["--showmemuse", "--json"], { timeout: 2000, windowsHide: true });
113+
const parsed = JSON.parse(stdout) as Record<string, Record<string, string | number>>;
114+
let usedMB = 0;
115+
let found = false;
116+
for (const card of Object.values(parsed)) {
117+
for (const [key, raw] of Object.entries(card)) {
118+
if (!/memory.*used/i.test(key)) continue;
119+
const value = Number(String(raw).replace(/[^0-9.]/g, ""));
120+
if (Number.isFinite(value)) {
121+
usedMB += value > 1024 * 1024 ? value / 1024 / 1024 : value;
122+
found = true;
123+
}
124+
}
125+
}
126+
return { usedMB: found ? usedMB : null, source: "rocm-smi" };
127+
} catch {
128+
return { usedMB: null, source: "unavailable" };
129+
}
130+
}
131+
132+
async function startResourceSampler(): Promise<{ stop: () => Promise<ResourcePeaks> }> {
133+
let stopped = false;
134+
let peakSystemRamMB = 0;
135+
let peakAppRamMB = 0;
136+
let peakVramMB: number | null = null;
137+
let vramMeasurement: ResourcePeaks["vramMeasurement"] = "unavailable";
138+
let pending = Promise.resolve();
139+
let gpuSampleInFlight = false;
140+
const sample = () => {
141+
const mem = process.memoryUsage();
142+
peakAppRamMB = Math.max(peakAppRamMB, mem.rss / 1024 / 1024);
143+
peakSystemRamMB = Math.max(peakSystemRamMB, (os.totalmem() - os.freemem()) / 1024 / 1024);
144+
if (gpuSampleInFlight) return;
145+
gpuSampleInFlight = true;
146+
pending = (async () => {
147+
const gpu = await sampleGpuMemory();
148+
if (gpu.usedMB !== null) peakVramMB = Math.max(peakVramMB ?? 0, gpu.usedMB);
149+
if (gpu.source !== "unavailable") vramMeasurement = gpu.source;
150+
gpuSampleInFlight = false;
151+
})();
152+
};
153+
sample();
154+
const timer = setInterval(sample, SAMPLE_INTERVAL_MS);
155+
timer.unref();
156+
return {
157+
async stop() {
158+
if (!stopped) {
159+
stopped = true;
160+
clearInterval(timer);
161+
sample();
162+
}
163+
await pending;
164+
return {
165+
peakSystemRamMB: round(peakSystemRamMB),
166+
peakAppRamMB: round(peakAppRamMB),
167+
peakVramMB: peakVramMB === null ? null : round(peakVramMB),
168+
vramMeasurement,
169+
};
170+
},
171+
};
172+
}
173+
174+
async function measureInference(
175+
execute: BenchmarkChatExecutor,
176+
request: BenchmarkRequest,
177+
mode: InferenceMeasurement["mode"],
178+
signal?: AbortSignal
179+
): Promise<InferenceMeasurement> {
180+
const outputTokens = Math.max(8, Math.min(512, request.outputTokens ?? 96));
181+
const prompt = "Explain in concise technical terms why GPU memory bandwidth affects local language-model inference. Use complete sentences.";
182+
const started = performance.now();
183+
let firstTokenAt: number | null = null;
184+
let text = "";
185+
let reportedPromptTokens: number | undefined;
186+
let reportedCompletionTokens: number | undefined;
187+
const sampler = await startResourceSampler();
188+
const gpuLayers = mode === "cpu" ? 0 : mode === "gpu" ? 999 : undefined;
189+
try {
190+
await execute(
191+
request.provider,
192+
request.model,
193+
[{ role: "user", content: prompt }],
194+
{ temperature: 0, maxTokens: outputTokens, seed: 42, gpuLayers },
195+
(chunk) => {
196+
const piece = chunk.message?.content ?? "";
197+
if (piece && firstTokenAt === null) firstTokenAt = performance.now();
198+
text += piece;
199+
reportedPromptTokens = chunk.usage?.promptTokens ?? reportedPromptTokens;
200+
reportedCompletionTokens = chunk.usage?.completionTokens ?? reportedCompletionTokens;
201+
},
202+
signal
203+
);
204+
} catch (error) {
205+
await sampler.stop();
206+
throw error;
207+
}
208+
const finished = performance.now();
209+
const resources = await sampler.stop();
210+
const promptTokens = reportedPromptTokens ?? approximateTokens(prompt);
211+
const completionTokens = reportedCompletionTokens ?? approximateTokens(text);
212+
const ttftMs = Math.max(0.1, (firstTokenAt ?? finished) - started);
213+
const generationMs = Math.max(1, finished - (firstTokenAt ?? finished));
214+
return {
215+
mode,
216+
tokensPerSecond: round((completionTokens * 1000) / generationMs, 2),
217+
promptTokensPerSecond: round((promptTokens * 1000) / ttftMs, 2),
218+
timeToFirstTokenMs: round(ttftMs),
219+
totalTimeMs: round(finished - started),
220+
promptTokens,
221+
completionTokens,
222+
resources,
223+
};
224+
}
225+
226+
async function runContextTests(
227+
execute: BenchmarkChatExecutor,
228+
request: BenchmarkRequest,
229+
signal?: AbortSignal
230+
): Promise<ContextTestResult[]> {
231+
const results: ContextTestResult[] = [];
232+
for (const requestedTokens of contextTestSizes(request.maxContextLength ?? 8192)) {
233+
const started = performance.now();
234+
try {
235+
await execute(
236+
request.provider,
237+
request.model,
238+
[{ role: "user", content: buildContextPrompt(requestedTokens) }],
239+
{ temperature: 0, maxTokens: 1, contextLength: requestedTokens },
240+
() => undefined,
241+
signal
242+
);
243+
results.push({ requestedTokens, accepted: true, elapsedMs: round(performance.now() - started) });
244+
} catch (error) {
245+
results.push({ requestedTokens, accepted: false, elapsedMs: round(performance.now() - started), error: (error as Error).message });
246+
break;
247+
}
248+
}
249+
return results;
250+
}
251+
252+
export async function runBenchmark(
253+
execute: BenchmarkChatExecutor,
254+
request: BenchmarkRequest,
255+
signal?: AbortSignal
256+
): Promise<BenchmarkResult> {
257+
const createdAt = new Date().toISOString();
258+
const result: BenchmarkResult = {
259+
schemaVersion: 1,
260+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
261+
createdAt,
262+
provider: request.provider,
263+
model: request.model,
264+
health: { healthy: false, latencyMs: 0 },
265+
primary: null,
266+
comparison: { supported: false },
267+
contextTests: [],
268+
warnings: [],
269+
};
270+
const healthStarted = performance.now();
271+
try {
272+
result.primary = await measureInference(execute, request, "default", signal);
273+
result.health = { healthy: true, latencyMs: result.primary.timeToFirstTokenMs };
274+
} catch (error) {
275+
result.health = { healthy: false, latencyMs: round(performance.now() - healthStarted), error: (error as Error).message };
276+
return result;
277+
}
278+
279+
if (request.compareCpuGpu) {
280+
if (request.provider === "ollama" || request.provider === "llamacpp") {
281+
result.comparison.supported = true;
282+
result.comparison.cpu = await measureInference(execute, request, "cpu", signal);
283+
result.comparison.gpu = await measureInference(execute, request, "gpu", signal);
284+
} else {
285+
result.comparison.note = "CPU/GPU layer control is available only for Ollama and the built-in llama.cpp runtime.";
286+
}
287+
}
288+
result.contextTests = await runContextTests(execute, request, signal);
289+
if (result.primary.resources.peakVramMB === null) {
290+
result.warnings.push("VRAM usage was unavailable; install nvidia-smi or rocm-smi for measured VRAM peaks.");
291+
}
292+
result.warnings.push("Prompt-processing speed uses time-to-first-token and therefore includes model loading and request latency.");
293+
return result;
294+
}

0 commit comments

Comments
 (0)