feat(benchmark): end-of-run score reveal + NVIDIA GPU-util overlay (#1082)

Phase 3 of the live benchmark run experience.

- ScoreReveal: replaces the abrupt run-view unmount with a deliberate
  end-of-run "REPORT" card -- animated NOMAD score gauge + odometer
  count-up number, sub-score gauges cascading in, Continue button +
  5s auto-dismiss. Takes score scale as a prop so it survives Score v2.
- GPU-util overlay (NVIDIA): during the AI stage, a ~1Hz nvidia-smi poll
  inside the Ollama container feeds live GPU utilization + VRAM into the
  telemetry frames; shown in the AI hero, hidden when absent (AMD/none).
  Poller is side-effect-only and cleared in a finally -- scored numbers
  unchanged.
- Disk polish: reset in-test buffers on stage transition so the write
  stage no longer briefly shows the carried disk-read value.

Browser-validated on NOMAD3 (RTX 5060): GPU overlay live (1% util,
0.2/8.0 GB VRAM during model load); reveal cascade + count-up; AI-only
score 39.8 (scored path unchanged).

Part of #1082 (tracker stays open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-11 21:53:03 -07:00
parent 965474d0e4
commit 92b161d19d
7 changed files with 322 additions and 1 deletions

View File

@ -11,6 +11,7 @@ import type {
BenchmarkType,
BenchmarkStatus,
BenchmarkProgress,
BenchmarkTelemetry,
HardwareInfo,
DiskType,
SystemScores,
@ -525,10 +526,74 @@ export class BenchmarkService {
}
}
/**
* Probe live NVIDIA GPU stats by exec-ing nvidia-smi inside the Ollama
* container (same approach as SystemService.getNvidiaSmiInfo). Telemetry-only:
* returns null on any failure (no Ollama container, no NVIDIA GPU / AMD,
* unparseable output) and must never affect the scored benchmark numbers.
*/
private async _probeGpuStats(): Promise<NonNullable<BenchmarkTelemetry['gpu']> | null> {
try {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) return null
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
const exec = await container.exec({
Cmd: [
'nvidia-smi',
'--query-gpu=utilization.gpu,memory.used,memory.total',
'--format=csv,noheader,nounits',
],
AttachStdout: true,
AttachStderr: true,
Tty: true,
})
// Read the output stream with a timeout to prevent hanging if nvidia-smi fails
const stream = await exec.start({ Tty: true })
const output = await new Promise<string>((resolve) => {
let data = ''
const timeout = setTimeout(() => resolve(data), 3000)
stream.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
stream.on('end', () => {
clearTimeout(timeout)
resolve(data)
})
})
// Remove any non-printable characters and trim the output
const cleaned = Array.from(output)
.filter((character) => character.charCodeAt(0) > 8)
.join('')
.trim()
if (!cleaned || cleaned.toLowerCase().includes('error') || cleaned.toLowerCase().includes('not found')) {
return null
}
// First GPU only: "<util>, <used>, <total>"
const parts = cleaned.split('\n')[0].split(',').map((s) => s.trim())
if (parts.length < 3) return null
const util = Number.parseInt(parts[0], 10)
const used = Number.parseInt(parts[1], 10)
const total = Number.parseInt(parts[2], 10)
if (!Number.isFinite(util) || !Number.isFinite(used) || !Number.isFinite(total)) return null
return { util, vram_used_mb: used, vram_total_mb: total }
} catch {
return null
}
}
/**
* Run AI benchmark using Ollama
*/
private async _runAIBenchmark(): Promise<AIScores> {
// Live GPU-util overlay poller. Side-effect-only: it feeds telemetry frames
// and is cleared in the finally below so it never outlives the AI stage.
let gpuPollTimer: NodeJS.Timeout | null = null
try {
this._updateStatus('running_ai', 'Running AI benchmark...')
@ -546,6 +611,29 @@ export class BenchmarkService {
throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)
}
// GPU-util overlay (NVIDIA only): probe once; if a GPU answers, poll for the
// duration of the AI stage. If the probe returns null (no GPU / AMD), the
// overlay simply never appears. Best-effort: failures never affect the run.
try {
const initialGpu = await this._probeGpuStats()
if (initialGpu) {
this.telemetry?.setGpuStats(initialGpu)
let gpuProbeInFlight = false
gpuPollTimer = setInterval(() => {
if (gpuProbeInFlight) return
gpuProbeInFlight = true
this._probeGpuStats()
.then((stats) => this.telemetry?.setGpuStats(stats))
.catch(() => this.telemetry?.setGpuStats(null))
.finally(() => {
gpuProbeInFlight = false
})
}, 1000)
}
} catch {
// GPU overlay is cosmetic; ignore probe failures entirely.
}
// Check if the benchmark model is available, pull if not
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)
@ -651,6 +739,10 @@ export class BenchmarkService {
}
} catch (error) {
throw new Error(`AI benchmark failed: ${error.message}`)
} finally {
// Stop the GPU-util poller and clear the overlay from telemetry frames.
if (gpuPollTimer) clearInterval(gpuPollTimer)
this.telemetry?.setGpuStats(null)
}
}

View File

@ -23,6 +23,7 @@ export class BenchmarkTelemetrySampler {
private status: BenchmarkStatus = 'starting'
private startedAt = Date.now()
private stageMetric: BenchmarkTelemetry['stage_metric'] | undefined
private gpu: BenchmarkTelemetry['gpu'] | undefined
private sampling = false
constructor(benchmarkId: string | null) {
@ -38,10 +39,11 @@ export class BenchmarkTelemetrySampler {
this.timer = setInterval(() => void this._sample(), SAMPLE_INTERVAL_MS)
}
/** Tag subsequent frames with the current stage; clears any prior in-test metric. */
/** Tag subsequent frames with the current stage; clears any prior in-test state. */
setStage(status: BenchmarkStatus) {
this.status = status
this.stageMetric = undefined
this.gpu = undefined
}
/** Inject an in-test metric (e.g. live AI tokens/sec) into subsequent frames. */
@ -49,6 +51,11 @@ export class BenchmarkTelemetrySampler {
this.stageMetric = { kind, value, ...(ttftMs !== undefined ? { ttft_ms: ttftMs } : {}) }
}
/** Inject NVIDIA GPU stats into subsequent frames (null clears them). */
setGpuStats(gpu: NonNullable<BenchmarkTelemetry['gpu']> | null) {
this.gpu = gpu ?? undefined
}
stop() {
if (this.timer) {
clearInterval(this.timer)
@ -82,6 +89,7 @@ export class BenchmarkTelemetrySampler {
temp_c: tempC,
disk: { read_mb_s: readMb, write_mb_s: writeMb },
...(this.stageMetric ? { stage_metric: this.stageMetric } : {}),
...(this.gpu ? { gpu: this.gpu } : {}),
}
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_TELEMETRY, payload)

View File

@ -33,6 +33,25 @@ function StageHero({ run }: { run: BenchmarkRunHook }) {
<div className="text-desert-green">
<Sparkline data={run.aiTokHistory} height={64} />
</div>
{run.gpuUtil !== null && (
<div className="pt-3 border-t border-desert-stone-light">
<div className="flex items-center gap-2 text-xs font-semibold text-desert-stone-dark uppercase tracking-wide mb-2">
<IconChartBar className="w-4 h-4" /> GPU
</div>
<div className="grid grid-cols-2 gap-3">
<LiveReadout value={run.gpuUtil} unit="%" label="Utilization" />
<LiveReadout
value={run.gpuVramUsedMb !== null ? run.gpuVramUsedMb / 1024 : null}
unit="GB"
label={
run.gpuVramTotalMb !== null
? `VRAM (of ${(run.gpuVramTotalMb / 1024).toFixed(1)} GB)`
: 'VRAM'
}
/>
</div>
</div>
)}
</div>
</div>
)

View File

@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from 'react'
import { IconChartBar, IconCpu, IconDatabase, IconRobot, IconServer } from '@tabler/icons-react'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import StyledButton from '~/components/StyledButton'
import BenchmarkResult from '#models/benchmark_result'
interface ScoreRevealProps {
result: BenchmarkResult
scoreScale?: { max: number; caption: string }
onDone: () => void
}
// Same thresholds as the NOMAD Score section on the benchmark page.
const getScoreColor = (score: number) => {
if (score >= 70) return 'text-green-600'
if (score >= 40) return 'text-yellow-600'
return 'text-red-600'
}
// AI tokens/sec normalized to 0-100 (30 tok/s = 50, 60 tok/s = 100),
// mirroring the benchmark page's getAIScore.
const getAIScore = (tokensPerSecond: number): number =>
Math.min(100, Math.max(0, (tokensPerSecond / 60) * 100))
/** rAF count-up from 0 to `target` with ease-out, for the big score number. */
function useCountUp(target: number, durationMs = 1200): number {
const [value, setValue] = useState(0)
useEffect(() => {
let raf = 0
const start = performance.now()
const tick = (now: number) => {
const t = Math.min(1, (now - start) / durationMs)
const eased = 1 - Math.pow(1 - t, 3)
setValue(target * eased)
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, durationMs])
return value
}
/**
* End-of-run score reveal. Replaces the abrupt unmount of the live run view
* with a deliberate report: big NOMAD score gauge + count-up number, then the
* sub-score gauges cascading in. Dismisses via the Continue button or an
* auto-dismiss timer.
*/
export default function ScoreReveal({
result,
scoreScale = { max: 100, caption: 'out of 100' },
onDone,
}: ScoreRevealProps) {
const displayScore = useCountUp(result.nomad_score)
// Sub-score gauges, mirroring the System Performance / AI Performance grids.
const gauges: {
label: string
value: number
variant: 'cpu' | 'memory' | 'disk'
icon: React.ReactNode
}[] = [
{ label: 'CPU', value: result.cpu_score * 100, variant: 'cpu', icon: <IconCpu className="w-6 h-6" /> },
{ label: 'Memory', value: result.memory_score * 100, variant: 'memory', icon: <IconDatabase className="w-6 h-6" /> },
{ label: 'Disk Read', value: result.disk_read_score * 100, variant: 'disk', icon: <IconServer className="w-6 h-6" /> },
{ label: 'Disk Write', value: result.disk_write_score * 100, variant: 'disk', icon: <IconServer className="w-6 h-6" /> },
]
if (result.ai_tokens_per_second) {
gauges.push({
label: 'AI Score',
value: getAIScore(result.ai_tokens_per_second),
variant: 'cpu',
icon: <IconRobot className="w-6 h-6" />,
})
}
// Cascade: mount one gauge every 150ms; each CircularGauge animates 0→value
// on mount, so staggered mounts produce the cascade.
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
if (visibleCount >= gauges.length) return
const id = setTimeout(() => setVisibleCount((n) => n + 1), 150)
return () => clearTimeout(id)
}, [visibleCount, gauges.length])
// Dismiss exactly once, whether via the button or the auto-dismiss timer.
const doneRef = useRef(false)
const onDoneRef = useRef(onDone)
onDoneRef.current = onDone
const fireDone = () => {
if (doneRef.current) return
doneRef.current = true
onDoneRef.current()
}
useEffect(() => {
const id = setTimeout(fireDone, 5000)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="space-y-6">
<div className="bg-desert-white rounded-lg border border-desert-stone-light overflow-hidden">
<div className="bg-desert-olive px-6 py-2 flex items-center gap-2">
<div className="w-1 h-4 bg-desert-green" />
<span className="text-xs font-semibold text-white uppercase tracking-wide">Report</span>
</div>
<div className="p-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="shrink-0">
<CircularGauge
value={Math.min(100, (result.nomad_score / scoreScale.max) * 100)}
label="NOMAD Score"
size="lg"
variant="cpu"
subtext={scoreScale.caption}
icon={<IconChartBar className="w-8 h-8" />}
/>
</div>
<div className="flex-1 space-y-4">
<div
className={`text-5xl font-bold font-mono tabular-nums ${getScoreColor(result.nomad_score)}`}
>
{displayScore.toFixed(1)}
</div>
<p className="text-desert-stone-dark">
Your NOMAD Score is a weighted composite of all benchmark results.
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6 mt-8">
{gauges.slice(0, visibleCount).map((g) => (
<div
key={g.label}
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light"
>
<CircularGauge value={g.value} label={g.label} size="md" variant={g.variant} icon={g.icon} />
</div>
))}
</div>
<div className="mt-8 flex justify-end">
<StyledButton onClick={fireDone} icon="IconArrowRight">
Continue
</StyledButton>
</div>
</div>
</div>
</div>
)
}

View File

@ -31,6 +31,10 @@ type LiveState = {
/** Authoritative in-test disk throughput (sysbench interim lines), MiB/s. */
diskMibs: number | null
diskMibsHistory: number[]
/** NVIDIA GPU stats during the AI stage (null when no GPU / not sampling). */
gpuUtil: number | null
gpuVramUsedMb: number | null
gpuVramTotalMb: number | null
}
const EMPTY_LIVE: LiveState = {
@ -49,6 +53,9 @@ const EMPTY_LIVE: LiveState = {
cpuEventsHistory: [],
diskMibs: null,
diskMibsHistory: [],
gpuUtil: null,
gpuVramUsedMb: null,
gpuVramTotalMb: null,
}
const pushRing = (arr: number[], v: number) => {
@ -70,11 +77,14 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
const [live, setLive] = useState<LiveState>(EMPTY_LIVE)
const [partials, setPartials] = useState<BenchmarkPartialResult[]>([])
// Last-seen progress status, so stage transitions can reset in-test buffers.
const lastStatusRef = useRef<BenchmarkStatus | null>(null)
const reset = useCallback(() => {
setProgress(null)
setLive(EMPTY_LIVE)
setPartials([])
lastStatusRef.current = null
}, [])
useEffect(() => {
@ -82,6 +92,20 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
BROADCAST_CHANNELS.BENCHMARK_PROGRESS,
(data: BenchmarkProgressWithID) => {
setProgress(data)
// On stage transition, clear the in-test buffers so each stage starts
// clean (e.g. the disk-read throughput doesn't linger into the write
// stage). Always-on host telemetry (perCore, cpuOverall, disk proxy,
// temp) is intentionally left untouched.
if (lastStatusRef.current !== data.status) {
lastStatusRef.current = data.status
setLive((prev) => ({
...prev,
cpuEventsPerSec: null,
cpuEventsHistory: [],
diskMibs: null,
diskMibsHistory: [],
}))
}
if (data.partial_result) {
const incoming = data.partial_result
setPartials((prev) => {
@ -127,6 +151,9 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
diskMibsHistory: isMib
? pushRing(prev.diskMibsHistory, data.stage_metric!.value)
: prev.diskMibsHistory,
gpuUtil: data.gpu ? data.gpu.util : prev.gpuUtil,
gpuVramUsedMb: data.gpu ? data.gpu.vram_used_mb : prev.gpuVramUsedMb,
gpuVramTotalMb: data.gpu ? data.gpu.vram_total_mb : prev.gpuVramTotalMb,
}
})
}

View File

@ -24,6 +24,7 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import { SERVICE_NAMES } from '../../../constants/service_names'
import { useBenchmarkRun } from '~/hooks/useBenchmarkRun'
import BenchmarkRunView from '~/components/benchmark/BenchmarkRunView'
import ScoreReveal from '~/components/benchmark/ScoreReveal'
export default function BenchmarkPage(props: {
benchmark: {
@ -36,6 +37,7 @@ export default function BenchmarkPage(props: {
const queryClient = useQueryClient()
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
const [revealing, setRevealing] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [showHistory, setShowHistory] = useState(false)
@ -64,6 +66,7 @@ export default function BenchmarkPage(props: {
setIsRunning(false)
if (status === 'completed') {
refetchLatest()
setRevealing(true)
} else {
setErrorMsg(message || 'Benchmark failed')
}
@ -222,6 +225,23 @@ export default function BenchmarkPage(props: {
{isRunning ? (
<BenchmarkRunView run={run} />
) : revealing ? (
// Reveal slot: wait for the refetched latest result to be the one
// from this run, then hand it to the score reveal.
(() => {
const ready =
latestResult && latestResult.benchmark_id === run.progress?.benchmark_id
return ready ? (
<ScoreReveal result={latestResult} onDone={() => setRevealing(false)} />
) : (
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
<div className="flex items-center justify-center gap-3 text-desert-green animate-pulse">
<div className="animate-spin h-6 w-6 border-2 border-desert-green border-t-transparent rounded-full" />
<span className="text-lg font-medium">Compiling report...</span>
</div>
</div>
)
})()
) : (
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
<div className="space-y-6">

View File

@ -114,6 +114,8 @@ export type BenchmarkTelemetry = {
value: number
ttft_ms?: number
}
// NVIDIA GPU stats sampled during the AI stage (absent when no NVIDIA GPU)
gpu?: { util: number; vram_used_mb: number; vram_total_mb: number }
}
// API request types