feat(benchmark): Score v2 app client — raws, uncapped score, v2 payload + UI

Phase 4 of the NOMAD Score v2 effort (stacked on #1089). Captures the raw
channel values the leaderboard scores from, computes the uncapped v2 score
in-app (byte-matching the leaderboard's server-side recompute), submits the
v2 payload, and surfaces it in the benchmark UI.

- Capture raws: per-channel sysbench values + O_DIRECT disk (W4), single/multi
  thread CPU + memory, W6 consistency companions, and run-environment metadata
  (run_environment, storage_path_type, gpu_compute_detected, #1016).
- AI hardening: reference model llama3.1:8b, num_predict=256, VRAM eviction
  before the run + unload own model after, pre-flight disk check (~6.5GB, only
  when the model is uncached), median-of-3 run (W7).
- Score: _calculateNomadScoreV2 (uncapped, reference->1000) byte-matches the
  leaderboard recompute. nomad_score_v2 stays null unless a full run with AI.
- Payload: submitToRepository sends the v2 raws + score alongside legacy v1.
- UI: v2 headline score, legacy v1 as secondary, raws/env in details.
- Migration: 13 nullable columns; double (not knex float(8,2)) so millions-scale
  raws keep full precision and don't break the leaderboard byte-match.

Reference constant REFERENCE_SCORES_V2.ai_tokens_per_second is still the stale
40.5 placeholder (TODO in source); locking it to the measured 13.2 is a
follow-up gated on the AMD iGPU fix (#1074). Live prod v2 submit is separately
blocked by the leaderboard TTFT floor (tracked in the leaderboard follow-up).

Verified on NOMAD3 (RTX 5060) dev env: migration applies, raws recorded,
v2 score computed, backend + benchmark.tsx typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-13 10:40:24 -07:00
parent ac58141530
commit 651df80dc8
5 changed files with 787 additions and 93 deletions

View File

@ -63,6 +63,60 @@ export default class BenchmarkResult extends BaseModel {
@column()
declare ollama_version: string | null
// NOMAD Score v2 raw channels (nullable — added in Score v2 Phase 4). Populated
// on full benchmarks under benchmark_version >= 2.0.0; the leaderboard recomputes
// the score from these on submit. cpu_events_multi is measured at
// cpu_benchmark_threads; memory_ops_per_sec at memory_threads. Disk figures are
// O_DIRECT MB/s. cpu_total_events/cpu_total_time are the W6 consistency companions.
@column()
declare cpu_events_single: number | null
@column()
declare cpu_events_multi: number | null
@column()
declare cpu_benchmark_threads: number | null
@column()
declare cpu_total_events: number | null
@column()
declare cpu_total_time: number | null
@column()
declare memory_ops_per_sec: number | null
@column()
declare memory_threads: number | null
@column()
declare disk_read_mb_per_sec: number | null
@column()
declare disk_write_mb_per_sec: number | null
// Uncapped NOMAD Score v2 (null for pre-v2 rows and system-only runs). The
// legacy nomad_score below is retained in parallel for display continuity.
// columnName + serializeAs pinned: the snake_case strategy would otherwise map
// this property to `nomad_score_v_2` (splitting the digit) for both the DB column
// (which the migration doesn't create) and the JSON key (which the frontend reads
// as nomad_score_v2). Pin both so DB, API, and UI all agree.
@column({ columnName: 'nomad_score_v2', serializeAs: 'nomad_score_v2' })
declare nomad_score_v2: number | null
// Best-effort run environment metadata (issue #1016)
@column()
declare run_environment: string | null
@column()
declare storage_path_type: string | null
@column({
// Nullable tri-state; coerce the SQLite 0/1 int to a real boolean when present.
consume: (value: number | null) => (value === null || value === undefined ? null : Boolean(value)),
})
declare gpu_compute_detected: boolean | null
// Composite NOMAD score (0-100)
@column()
declare nomad_score: number

View File

@ -18,10 +18,16 @@ import type {
SysbenchCpuResult,
SysbenchMemoryResult,
SysbenchDiskResult,
RepositorySubmission,
RepositorySubmissionV2,
RepositorySubmitResponse,
RepositoryStats,
SystemBenchmarkOutput,
SystemBenchmarkRawsV2,
RunEnvironmentInfo,
} from '../../types/benchmark.js'
import KVStore from '#models/kv_store'
import { getFreeBytes } from '../utils/image_disk_preflight.js'
import { readFile } from 'node:fs/promises'
import { randomUUID, createHmac } from 'node:crypto'
import { DockerService } from './docker_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
@ -44,15 +50,87 @@ const SCORE_WEIGHTS = {
disk_write: 0.10,
}
// The benchmark_version this client emits. Server dispatches on the major:
// >= 2 → raw-channel v2 ingest (score recomputed server-side). Bumped 1.0.0 -> 2.0.0
// for the NOMAD Score v2 rollout (uncapped index vs a frozen Reference Build).
const BENCHMARK_VERSION = '2.0.0'
// ---------------------------------------------------------------------------
// NOMAD Score v2 — the uncapped index.
//
// Score = 1000 × Π( (raw_i / ref_i) ^ w_i ) (weighted geometric mean, log domain).
//
// These constants + the computation below MUST stay byte-identical to the
// leaderboard's score_service.ts (computeNomadScoreV2): the client shows this
// score immediately after a run, and the server recomputes the same value from
// the raw channels on submit. Any drift makes the displayed score disagree with
// the leaderboard. FROZEN under benchmark_version 2.0.0 — Reference Build =
// NOMAD6 (Ryzen 9 PRO 8945HS / 780M / 64GB, kernel 6.17), measured median-of-N
// with direct-I/O disk 2026-07-12. There are NO clamps here; outlier control is
// the server's rejection gates + quarantine, not the score math.
const REFERENCE_SCORES_V2 = {
// TODO(score-v2): STALE — 40.5 was measured on llama3.2:1b. The reference model
// is now llama3.1:8b; re-measure this on the Reference Build (NOMAD6) with the
// v2 harness (unload + num_predict + median-of-3) and update here AND in the
// leaderboard's score_service.ts (must byte-match) before v2 ships.
ai_tokens_per_second: 40.5,
cpu_events_multi: 19231, // all-threads on the reference box (16T)
cpu_events_single: 2351, // 1T
memory_ops_per_sec: 26862960, // 4T, 1K block (bandwidth peak)
disk_read_mb_per_sec: 1072, // O_DIRECT
disk_write_mb_per_sec: 316, // O_DIRECT
} as const
const SCORE_WEIGHTS_V2 = {
ai_tokens_per_second: 0.3,
cpu_events_multi: 0.25,
cpu_events_single: 0.1,
memory_ops_per_sec: 0.1,
disk_read_mb_per_sec: 0.125,
disk_write_mb_per_sec: 0.125,
} as const
type ScoreChannelV2 = keyof typeof REFERENCE_SCORES_V2
type ScoreRawsV2 = Record<ScoreChannelV2, number>
const SCORE_CHANNELS_V2 = Object.keys(REFERENCE_SCORES_V2) as ScoreChannelV2[]
// Weights sum to 1.000; guard against a future typo silently rescaling scores.
const WEIGHT_SUM_V2 = SCORE_CHANNELS_V2.reduce((s, k) => s + SCORE_WEIGHTS_V2[k], 0)
if (Math.abs(WEIGHT_SUM_V2 - 1) > 1e-9) {
throw new Error(`SCORE_WEIGHTS_V2 must sum to 1.0 (got ${WEIGHT_SUM_V2})`)
}
// Benchmark configuration constants
// Pinned by digest (was severalnines/sysbench:latest) so a latest-tag format change can't silently break the parsers fleet-wide. Digest validated on the NOMAD6 reference build 2026-07-12.
const SYSBENCH_IMAGE = 'severalnines/sysbench@sha256:64cd003bfa21eaab22f985e7b95f90d21a970229f5f628718657dd1bae669abd'
const SYSBENCH_DIGEST = 'sha256:64cd003bfa21eaab22f985e7b95f90d21a970229f5f628718657dd1bae669abd'
const SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench'
// Reference model for AI benchmark - small but meaningful
const AI_BENCHMARK_MODEL = 'llama3.2:1b'
// Reference model for AI benchmark. v2 uses an 8B (was llama3.2:1b): a 1B is so
// light it runs overhead-bound on any GPU (~240 tok/s), saturating the score and
// failing to differentiate hardware. An 8B is compute/bandwidth-bound and scales
// with real GPU capability. Non-thinking (deterministic workload). Changing this
// re-baselines REFERENCE_SCORES_V2.ai_tokens_per_second — re-measure on the
// Reference Build (NOMAD6) before shipping.
const AI_BENCHMARK_MODEL = 'llama3.1:8b'
const AI_BENCHMARK_PROMPT = 'Explain recursion in programming in exactly 100 words.'
// Cap generation so the workload is bounded and comparable across machines rather
// than relying on the model honoring "100 words". ~100-150 tokens is plenty for a
// stable tok/s rate; the cap only trims pathological long outputs.
const AI_BENCHMARK_NUM_PREDICT = 256
// Memory test is frozen at 4 threads for the v2 Reference Build (the bandwidth
// peak on the reference box). Do not vary by core count — the reference number
// (REFERENCE_SCORES_V2.memory_ops_per_sec) was measured at exactly this value.
const MEMORY_BENCHMARK_THREADS = 4
// AI is measured as the median of N runs (W7) to damp per-run inference jitter.
const AI_BENCHMARK_RUNS = 3
// Minimum free disk required before pulling the AI model on first run. llama3.1:8b
// (Q4) is ~4.9 GB; this covers the model plus headroom for the transient sysbench
// disk-test file and normal slack. Only enforced when the model isn't already cached.
const AI_BENCHMARK_MODEL_MIN_FREE_BYTES = 6.5 * 1024 * 1024 * 1024
// Reference scores for normalization (calibrated to 0-100 scale)
// These represent "expected" scores for a mid-range system (score ~50)
@ -139,24 +217,9 @@ export class BenchmarkService {
throw new Error('Benchmark result has already been submitted')
}
const submission: RepositorySubmission = {
cpu_model: result.cpu_model,
cpu_cores: result.cpu_cores,
cpu_threads: result.cpu_threads,
ram_gb: Math.round(result.ram_bytes / (1024 * 1024 * 1024)),
disk_type: result.disk_type,
gpu_model: result.gpu_model,
cpu_score: result.cpu_score,
memory_score: result.memory_score,
disk_read_score: result.disk_read_score,
disk_write_score: result.disk_write_score,
ai_tokens_per_second: result.ai_tokens_per_second,
ai_time_to_first_token: result.ai_time_to_first_token,
nomad_score: result.nomad_score,
nomad_version: SystemService.getAppVersion(),
benchmark_version: '1.0.0',
builder_tag: anonymous ? null : result.builder_tag,
}
// Build the v2 payload (raw channels; server recomputes the score). Throws a
// user-facing error if the row predates v2 and lacks the raw channels.
const submission = this._buildV2Submission(result, anonymous ?? false)
try {
// Generate HMAC signature for submission verification
@ -200,6 +263,85 @@ export class BenchmarkService {
}
}
/**
* Build the NOMAD Score v2 submission payload from a stored result, validating
* that every required raw channel + companion + provenance field is present.
*
* A row produced before this benchmark_version (or an interrupted run) won't
* carry the v2 raws rather than send a payload the leaderboard will reject
* with a terse validator error, we throw a clear "re-run" message here.
*
* ai_time_to_first_token is stored in milliseconds but the leaderboard's v2 API
* treats it as SECONDS, so it is divided by 1000 here.
*/
private _buildV2Submission(result: BenchmarkResult, anonymous: boolean): RepositorySubmissionV2 {
const reRun = 'Re-run a Full Benchmark to share your results.'
// Required raw channels — all must be present and > 0.
const requiredRaws: Array<[string, number | null]> = [
['CPU single-thread', result.cpu_events_single],
['CPU multi-thread', result.cpu_events_multi],
['memory', result.memory_ops_per_sec],
['disk read', result.disk_read_mb_per_sec],
['disk write', result.disk_write_mb_per_sec],
]
for (const [label, value] of requiredRaws) {
if (value == null || !(value > 0)) {
throw new Error(`This benchmark is missing the ${label} raw measurement needed for the v2 leaderboard. ${reRun}`)
}
}
if (result.ai_time_to_first_token == null || !(result.ai_time_to_first_token > 0)) {
throw new Error(`This benchmark is missing AI time-to-first-token data. ${reRun}`)
}
if (
result.cpu_benchmark_threads == null ||
result.memory_threads == null ||
result.cpu_total_events == null ||
result.cpu_total_time == null
) {
throw new Error(`This benchmark is missing CPU test parameters needed for the v2 leaderboard. ${reRun}`)
}
if (!result.sysbench_digest || !result.ollama_version) {
throw new Error(`This benchmark is missing harness provenance (sysbench/Ollama version). ${reRun}`)
}
const submission: RepositorySubmissionV2 = {
cpu_model: result.cpu_model,
cpu_cores: result.cpu_cores,
cpu_threads: result.cpu_threads,
ram_gb: Math.round(result.ram_bytes / (1024 * 1024 * 1024)),
disk_type: result.disk_type,
gpu_model: result.gpu_model,
ai_tokens_per_second: result.ai_tokens_per_second!,
cpu_events_single: result.cpu_events_single!,
cpu_events_multi: result.cpu_events_multi!,
memory_ops_per_sec: result.memory_ops_per_sec!,
disk_read_mb_per_sec: result.disk_read_mb_per_sec!,
disk_write_mb_per_sec: result.disk_write_mb_per_sec!,
cpu_benchmark_threads: result.cpu_benchmark_threads,
memory_threads: result.memory_threads,
// Stored in ms; the v2 API expects seconds.
ai_time_to_first_token: result.ai_time_to_first_token / 1000,
cpu_total_events: result.cpu_total_events,
cpu_total_time: result.cpu_total_time,
ollama_version: result.ollama_version,
sysbench_digest: result.sysbench_digest,
nomad_version: SystemService.getAppVersion(),
benchmark_version: BENCHMARK_VERSION,
}
// Optional environment metadata — only send fields we actually have.
if (result.run_environment) submission.run_environment = result.run_environment
if (result.storage_path_type) submission.storage_path_type = result.storage_path_type
if (result.gpu_compute_detected != null) submission.gpu_compute_detected = result.gpu_compute_detected
// Builder tag: omit entirely when anonymous or unset (validator rejects null).
if (!anonymous && result.builder_tag) submission.builder_tag = result.builder_tag
return submission
}
/**
* Get comparison stats from central repository
*/
@ -362,6 +504,12 @@ export class BenchmarkService {
this._updateStatus('starting', 'Starting benchmark...')
try {
// Fail fast: if this run will download the (large) AI model, make sure there's
// room BEFORE spending minutes on the system benchmarks.
if (includeAI && (type === 'full' || type === 'ai')) {
await this._preflightModelDiskSpace()
}
// Detect hardware
const hardware = await this.getHardwareInfo()
@ -372,9 +520,12 @@ export class BenchmarkService {
disk_read_score: 0,
disk_write_score: 0,
}
let systemRaws: SystemBenchmarkRawsV2 | null = null
if (type === 'full' || type === 'system') {
systemScores = await this._runSystemBenchmarks()
const systemOutput = await this._runSystemBenchmarks(hardware.cpu_threads)
systemScores = systemOutput.scores
systemRaws = systemOutput.raws
}
// Run AI benchmark if requested and Ollama is available
@ -392,10 +543,33 @@ export class BenchmarkService {
}
}
// Calculate NOMAD score
// Calculate NOMAD scores (v1 legacy + v2 uncapped)
this._updateStatus('calculating_score', 'Calculating NOMAD score...')
const nomadScore = this._calculateNomadScore(systemScores, aiScores)
// v2 requires a complete run of every channel (system raws + AI). Only a
// full benchmark with AI qualifies; otherwise nomad_score_v2 stays null.
let nomadScoreV2: number | null = null
if (systemRaws && aiScores.ai_tokens_per_second && aiScores.ai_tokens_per_second > 0) {
try {
nomadScoreV2 = this._calculateNomadScoreV2({
ai_tokens_per_second: aiScores.ai_tokens_per_second,
cpu_events_single: systemRaws.cpu_events_single,
cpu_events_multi: systemRaws.cpu_events_multi,
memory_ops_per_sec: systemRaws.memory_ops_per_sec,
disk_read_mb_per_sec: systemRaws.disk_read_mb_per_sec,
disk_write_mb_per_sec: systemRaws.disk_write_mb_per_sec,
})
} catch (error) {
// A non-positive channel shouldn't reach here (each sysbench step throws
// on a bad parse), but never let a v2 math error sink the whole run.
logger.warn(`NOMAD Score v2 not computed: ${error.message}`)
}
}
// Best-effort environment metadata (never fails the run).
const env = await this._detectRunEnvironment()
// Save result
const result = await BenchmarkResult.create({
benchmark_id: this.currentBenchmarkId,
@ -417,6 +591,20 @@ export class BenchmarkService {
submitted_to_repository: false,
sysbench_digest: SYSBENCH_DIGEST,
ollama_version: aiScores.ai_ollama_version ?? null,
// v2 raw channels + score (null on system-only / AI-less runs)
cpu_events_single: systemRaws?.cpu_events_single ?? null,
cpu_events_multi: systemRaws?.cpu_events_multi ?? null,
cpu_benchmark_threads: systemRaws?.cpu_benchmark_threads ?? null,
cpu_total_events: systemRaws?.cpu_total_events ?? null,
cpu_total_time: systemRaws?.cpu_total_time ?? null,
memory_ops_per_sec: systemRaws?.memory_ops_per_sec ?? null,
memory_threads: systemRaws?.memory_threads ?? null,
disk_read_mb_per_sec: systemRaws?.disk_read_mb_per_sec ?? null,
disk_write_mb_per_sec: systemRaws?.disk_write_mb_per_sec ?? null,
nomad_score_v2: nomadScoreV2,
run_environment: env.run_environment,
storage_path_type: env.storage_path_type,
gpu_compute_detected: env.gpu_compute_detected,
})
this._updateStatus('completed', 'Benchmark completed successfully')
@ -433,15 +621,25 @@ export class BenchmarkService {
}
/**
* Run system benchmarks using sysbench in Docker
* Run system benchmarks using sysbench in Docker.
*
* Produces both the legacy v1 0-1 sub-scores AND the v2 raw channels from the
* same runs. CPU runs twice: 1 thread (single-core index) and all logical
* threads (multi-core index); the v1 cpu_score keys off the multi pass to stay
* comparable with historical results.
*
* @param multiThreads number of threads for the multi-core CPU pass (all logical CPUs)
*/
private async _runSystemBenchmarks(): Promise<SystemScores> {
private async _runSystemBenchmarks(multiThreads: number): Promise<SystemBenchmarkOutput> {
// Ensure sysbench image is available
await this._ensureSysbenchImage()
// Run CPU benchmark
this._updateStatus('running_cpu', 'Running CPU benchmark...')
const cpuResult = await this._runSysbenchCpu()
// Run CPU benchmarks: single-thread then all-thread.
this._updateStatus('running_cpu', 'Running CPU benchmark (single-thread)...')
const cpuSingle = await this._runSysbenchCpu(1)
this._updateStatus('running_cpu', `Running CPU benchmark (${multiThreads}-thread)...`)
const cpuMulti = await this._runSysbenchCpu(multiThreads)
// Run memory benchmark
this._updateStatus('running_memory', 'Running memory benchmark...')
@ -454,13 +652,29 @@ export class BenchmarkService {
this._updateStatus('running_disk_write', 'Running disk write benchmark...')
const diskWriteResult = await this._runSysbenchDiskWrite()
// Normalize scores to 0-100 scale
return {
cpu_score: this._normalizeScore(cpuResult.events_per_second, REFERENCE_SCORES.cpu_events_per_second),
// Legacy v1 sub-scores (0-1), normalized against the v1 references. cpu_score
// uses the multi-thread pass to match historical (4-thread) behaviour closely.
const scores: SystemScores = {
cpu_score: this._normalizeScore(cpuMulti.events_per_second, REFERENCE_SCORES.cpu_events_per_second),
memory_score: this._normalizeScore(memoryResult.operations_per_second, REFERENCE_SCORES.memory_ops_per_second),
disk_read_score: this._normalizeScore(diskReadResult.read_mb_per_sec, REFERENCE_SCORES.disk_read_mb_per_sec),
disk_write_score: this._normalizeScore(diskWriteResult.write_mb_per_sec, REFERENCE_SCORES.disk_write_mb_per_sec),
}
// v2 raw channels — the payload of record for the leaderboard.
const raws: SystemBenchmarkRawsV2 = {
cpu_events_single: cpuSingle.events_per_second,
cpu_events_multi: cpuMulti.events_per_second,
cpu_benchmark_threads: multiThreads,
cpu_total_events: cpuMulti.total_events,
cpu_total_time: cpuMulti.total_time,
memory_ops_per_sec: memoryResult.operations_per_second,
memory_threads: MEMORY_BENCHMARK_THREADS,
disk_read_mb_per_sec: diskReadResult.read_mb_per_sec,
disk_write_mb_per_sec: diskWriteResult.write_mb_per_sec,
}
return { scores, raws }
}
/**
@ -495,56 +709,161 @@ export class BenchmarkService {
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
}
// Run inference benchmark
const startTime = Date.now()
// Evict any other models resident in VRAM/RAM before benchmarking. With an 8B
// reference model, a chat model left loaded can starve it of VRAM → Ollama
// offloads layers to CPU → a low, non-comparable score. Give the benchmark
// model the whole GPU. Best-effort: never fail the run on an eviction hiccup.
await this._unloadResidentModels(ollamaAPIURL)
const response = await axios.post(
`${ollamaAPIURL}/api/generate`,
{
model: AI_BENCHMARK_MODEL,
prompt: AI_BENCHMARK_PROMPT,
stream: false,
},
{ timeout: 120000 }
)
// Run inference AI_BENCHMARK_RUNS times and take the median run by tok/s (W7).
// A single inference is noisy (scheduler, thermal, cache); the median damps
// outliers without a warmup-run bias. TTFT is reported from the same run that
// is the tok/s median, so the two figures always describe one real inference.
const runs: { tps: number; ttft: number }[] = []
for (let i = 0; i < AI_BENCHMARK_RUNS; i++) {
this._updateStatus('running_ai', `Running AI benchmark (${i + 1}/${AI_BENCHMARK_RUNS})...`)
runs.push(await this._runSingleAIInference(ollamaAPIURL))
}
const endTime = Date.now()
const totalTime = (endTime - startTime) / 1000 // seconds
// Median by tok/s (odd count → middle element).
runs.sort((a, b) => a.tps - b.tps)
const median = runs[Math.floor(runs.length / 2)]
// Ollama returns eval_count (tokens generated) and eval_duration (nanoseconds)
if (response.data.eval_count && response.data.eval_duration) {
const tokenCount = response.data.eval_count
const evalDurationSeconds = response.data.eval_duration / 1e9
const tokensPerSecond = tokenCount / evalDurationSeconds
// Leave the GPU as clean as we found it: unload our benchmark model so the
// user's next chat loads their model into a free GPU (Ollama reloads on demand;
// we deliberately don't try to restore whichever model they had before).
await this._unloadResidentModels(ollamaAPIURL)
// Time to first token from prompt_eval_duration
const ttft = response.data.prompt_eval_duration
? response.data.prompt_eval_duration / 1e6 // Convert to ms
: (totalTime * 1000) / 2 // Estimate if not available
return {
ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,
ai_model_used: AI_BENCHMARK_MODEL,
ai_time_to_first_token: Math.round(ttft * 100) / 100,
ai_ollama_version: ollamaVersion,
}
}
// Fallback calculation
const estimatedTokens = response.data.response?.split(' ').length * 1.3 || 100
const tokensPerSecond = estimatedTokens / totalTime
return {
ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,
ai_model_used: AI_BENCHMARK_MODEL,
ai_time_to_first_token: Math.round((totalTime * 1000) / 2),
ai_ollama_version: ollamaVersion,
}
return {
ai_tokens_per_second: Math.round(median.tps * 100) / 100,
ai_model_used: AI_BENCHMARK_MODEL,
ai_time_to_first_token: Math.round(median.ttft * 100) / 100,
ai_ollama_version: ollamaVersion,
}
} catch (error) {
throw new Error(`AI benchmark failed: ${error.message}`)
}
}
/**
* Run a single Ollama inference and return its tokens/sec and time-to-first-token
* (TTFT in milliseconds). Extracted so the AI benchmark can run it N times for a
* median. Prefers Ollama's eval_count/eval_duration; falls back to wall-clock
* word-count estimation if those fields are absent.
*/
private async _runSingleAIInference(
ollamaAPIURL: string
): Promise<{ tps: number; ttft: number }> {
const startTime = Date.now()
const response = await axios.post(
`${ollamaAPIURL}/api/generate`,
{
model: AI_BENCHMARK_MODEL,
prompt: AI_BENCHMARK_PROMPT,
stream: false,
options: { num_predict: AI_BENCHMARK_NUM_PREDICT },
},
{ timeout: 120000 }
)
const endTime = Date.now()
const totalTime = (endTime - startTime) / 1000 // seconds
// Ollama returns eval_count (tokens generated) and eval_duration (nanoseconds)
if (response.data.eval_count && response.data.eval_duration) {
const tokenCount = response.data.eval_count
const evalDurationSeconds = response.data.eval_duration / 1e9
const tokensPerSecond = tokenCount / evalDurationSeconds
// Time to first token from prompt_eval_duration
const ttft = response.data.prompt_eval_duration
? response.data.prompt_eval_duration / 1e6 // Convert to ms
: (totalTime * 1000) / 2 // Estimate if not available
return { tps: tokensPerSecond, ttft }
}
// Fallback calculation
const estimatedTokens = response.data.response?.split(' ').length * 1.3 || 100
const tokensPerSecond = estimatedTokens / totalTime
return { tps: tokensPerSecond, ttft: (totalTime * 1000) / 2 }
}
/**
* Fail fast if the AI model will need downloading and there isn't room for it.
*
* Only relevant on first run: if the model is already pulled, no download happens
* and we skip the check. Best-effort throughout if Ollama isn't reachable or free
* space can't be determined, we do NOT block (the AI stage will surface its own
* error), because a false "insufficient disk" is worse than letting the pull try.
*/
private async _preflightModelDiskSpace(): Promise<void> {
let ollamaAPIURL: string | null = null
try {
ollamaAPIURL = await this.dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
} catch {
return // AI stage will report Ollama problems
}
if (!ollamaAPIURL) return
// If the model is already present, no download → no disk need.
try {
const tags = await axios.get(`${ollamaAPIURL}/api/tags`, { timeout: 5000 })
const models: string[] = (tags.data?.models ?? []).map((m: { name?: string }) => m.name)
if (models.includes(AI_BENCHMARK_MODEL)) return
} catch {
return // can't reach Ollama; let the AI stage handle it
}
// Model not cached → a pull is coming. Require headroom on the root FS.
let freeBytes: number | null = null
try {
const systemService = new (await import('./system_service.js')).SystemService(this.dockerService)
freeBytes = await getFreeBytes(systemService)
} catch {
return // couldn't measure free space — don't block on uncertainty
}
if (freeBytes === null) return
if (freeBytes < AI_BENCHMARK_MODEL_MIN_FREE_BYTES) {
const gib = (b: number) => `${(b / 1024 / 1024 / 1024).toFixed(1)} GB`
throw new Error(
`Not enough free disk space to download the AI benchmark model (${AI_BENCHMARK_MODEL}). ` +
`About ${gib(AI_BENCHMARK_MODEL_MIN_FREE_BYTES)} is required but only ${gib(freeBytes)} is free. ` +
`Free up some space and try again.`
)
}
}
/**
* Evict every model currently resident in Ollama (VRAM/RAM) so the benchmark
* model gets the whole GPU. Queries /api/ps for loaded models and unloads each
* via a keep_alive:0 request. Entirely best-effort: any failure (older Ollama
* without /api/ps, a model mid-stream) is logged and swallowed a clean-VRAM
* optimization must never block the benchmark itself.
*/
private async _unloadResidentModels(ollamaAPIURL: string): Promise<void> {
try {
const ps = await axios.get(`${ollamaAPIURL}/api/ps`, { timeout: 5000 })
const loaded: string[] = (ps.data?.models ?? [])
.map((m: { name?: string; model?: string }) => m.name || m.model)
.filter((n: string | undefined): n is string => !!n)
if (loaded.length === 0) return
logger.info(`[BenchmarkService] Evicting ${loaded.length} resident model(s) before AI benchmark: ${loaded.join(', ')}`)
for (const model of loaded) {
// keep_alive:0 tells Ollama to unload immediately (no prompt needed).
await axios
.post(`${ollamaAPIURL}/api/generate`, { model, keep_alive: 0 }, { timeout: 15000 })
.catch((e) => logger.warn(`[BenchmarkService] Failed to unload ${model}: ${e.message}`))
}
} catch (error) {
logger.warn(`[BenchmarkService] Could not enumerate/unload resident models (continuing): ${error.message}`)
}
}
/**
* Calculate weighted NOMAD score
*/
@ -592,6 +911,71 @@ export class BenchmarkService {
return Math.round(Math.min(100, Math.max(0, nomadScore)) * 100) / 100
}
/**
* Calculate the NOMAD Score v2 from raw channel values.
*
* Byte-identical to the leaderboard's computeNomadScoreV2 (score_service.ts):
* the weighted geometric mean vs the frozen Reference Build, computed in the
* log domain, rounded to one decimal. NO clamps. Throws if any channel is
* <= 0 a full v2 run measures every channel, and log2 of a non-positive
* ratio is undefined, so a non-positive value here is a programming error, not
* a user input to clamp. Every scored channel (incl. AI) is required; callers
* only invoke this for a complete full-benchmark run.
*/
private _calculateNomadScoreV2(raws: ScoreRawsV2): number {
let logSum = 0
for (const channel of SCORE_CHANNELS_V2) {
const raw = raws[channel]
if (!(raw > 0)) {
throw new Error(`_calculateNomadScoreV2: channel "${channel}" must be > 0 (got ${raw})`)
}
const ratio = raw / REFERENCE_SCORES_V2[channel]
logSum += SCORE_WEIGHTS_V2[channel] * Math.log2(ratio)
}
const score = 1000 * Math.pow(2, logSum)
// One decimal, matching the leaderboard's display convention.
return Math.round(score * 10) / 10
}
/**
* Detect best-effort run-environment metadata for the leaderboard (issue #1016).
* Every probe is independently guarded this never throws and never fails a run;
* any field it can't determine stays null.
*/
private async _detectRunEnvironment(): Promise<RunEnvironmentInfo> {
const info: RunEnvironmentInfo = {
run_environment: null,
storage_path_type: null,
gpu_compute_detected: null,
}
// run_environment: WSL2 vs native Linux, from the host kernel string.
try {
const procVersion = await readFile('/proc/version', 'utf8')
info.run_environment = /microsoft|wsl/i.test(procVersion) ? 'wsl2' : 'native-linux'
} catch {
// /proc/version unreadable — leave null
}
// storage_path_type: the Docker storage driver backing the benchmarked FS.
try {
const dockerInfo = await this.dockerService.docker.info()
if (dockerInfo?.Driver) info.storage_path_type = String(dockerInfo.Driver)
} catch {
// docker info failed — leave null
}
// gpu_compute_detected: whether a GPU compute type was detected + persisted.
try {
const gpuType = await KVStore.getValue('gpu.type')
info.gpu_compute_detected = gpuType === 'nvidia' || gpuType === 'amd'
} catch {
// KV read failed — leave null
}
return info
}
/**
* Normalize a raw score to 0-100 scale using log scaling
* This provides diminishing returns for very high scores
@ -629,14 +1013,18 @@ export class BenchmarkService {
}
/**
* Run sysbench CPU benchmark
* Run sysbench CPU benchmark at the given thread count.
*
* v2 runs this twice: 1 thread (single-core index) and all threads (multi-core
* index). The multi pass also supplies the W6 consistency companions
* (total_events / total_time) the leaderboard cross-checks against events/sec.
*/
private async _runSysbenchCpu(): Promise<SysbenchCpuResult> {
private async _runSysbenchCpu(threads: number): Promise<SysbenchCpuResult> {
const output = await this._runSysbenchCommand([
'sysbench',
'cpu',
'--cpu-max-prime=20000',
'--threads=4',
`--threads=${threads}`,
'--time=30',
'run',
])
@ -645,7 +1033,7 @@ export class BenchmarkService {
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/i)
logger.debug(`[BenchmarkService] CPU output parsing - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
logger.debug(`[BenchmarkService] CPU output parsing (${threads}T) - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
// Scored primary metric: fail loudly instead of silently scoring zero on a parse miss
const eventsPerSecond = eventsMatch ? parseFloat(eventsMatch[1]) : Number.NaN
@ -653,10 +1041,21 @@ export class BenchmarkService {
throw new Error('sysbench CPU benchmark produced no parseable events/sec — aborting (output may have changed or the test failed)')
}
const totalTime = totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30
// total_events feeds the leaderboard's W6 cross-check (events/sec must equal
// total_events/total_time within 2%). If the count line ever fails to parse,
// derive it from the rate so we submit a self-consistent payload rather than
// a zero that would be rejected as "internally inconsistent".
const parsedTotalEvents = totalEventsMatch ? parseInt(totalEventsMatch[1]) : Number.NaN
const totalEvents =
Number.isFinite(parsedTotalEvents) && parsedTotalEvents > 0
? parsedTotalEvents
: Math.round(eventsPerSecond * totalTime)
return {
events_per_second: eventsPerSecond,
total_time: totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30,
total_events: totalEventsMatch ? parseInt(totalEventsMatch[1]) : 0,
total_time: totalTime,
total_events: totalEvents,
}
}
@ -669,7 +1068,7 @@ export class BenchmarkService {
'memory',
'--memory-block-size=1K',
'--memory-total-size=10G',
'--threads=4',
`--threads=${MEMORY_BENCHMARK_THREADS}`,
'run',
])
@ -697,11 +1096,14 @@ export class BenchmarkService {
private async _runSysbenchDiskRead(): Promise<SysbenchDiskResult> {
// Run prepare, test, and cleanup in a single container
// This is necessary because each container has its own filesystem
// --file-extra-flags=direct (O_DIRECT) on the run bypasses the page cache so
// we measure real device throughput, not RAM (W4). Only the run needs it;
// prepare/cleanup are plain file ops. v2 reference disk numbers are O_DIRECT.
const output = await this._runSysbenchCommand([
'sh',
'-c',
'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqrd --time=30 run && ' +
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqrd --file-extra-flags=direct --time=30 run && ' +
'sysbench fileio --file-total-size=1G --file-num=4 cleanup',
])
@ -732,11 +1134,12 @@ export class BenchmarkService {
private async _runSysbenchDiskWrite(): Promise<SysbenchDiskResult> {
// Run prepare, test, and cleanup in a single container
// This is necessary because each container has its own filesystem
// --file-extra-flags=direct (O_DIRECT) on the run bypasses the page cache (W4).
const output = await this._runSysbenchCommand([
'sh',
'-c',
'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqwr --time=30 run && ' +
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqwr --file-extra-flags=direct --time=30 run && ' +
'sysbench fileio --file-total-size=1G --file-num=4 cleanup',
])

View File

@ -0,0 +1,49 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'benchmark_results'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// NOMAD Score v2 (Phase 4): raw channel values the leaderboard scores from,
// the frozen test parameters, the W6 consistency companions, the uncapped
// score, and best-effort run-environment metadata (#1016). All nullable —
// pre-v2 rows and system-only runs simply leave them empty.
// double (not float): Knex's MySQL float is float(8,2) — max 999999.99 and
// rounded to 2 decimals. memory_ops_per_sec / cpu_total_events run into the
// millions, and rounding raws would break byte-match with the leaderboard's
// server-side score recompute. double holds full-precision large values.
table.double('cpu_events_single').nullable()
table.double('cpu_events_multi').nullable()
table.integer('cpu_benchmark_threads').nullable()
table.double('cpu_total_events').nullable()
table.double('cpu_total_time').nullable()
table.double('memory_ops_per_sec').nullable()
table.integer('memory_threads').nullable()
table.double('disk_read_mb_per_sec').nullable()
table.double('disk_write_mb_per_sec').nullable()
table.double('nomad_score_v2').nullable()
table.string('run_environment').nullable()
table.string('storage_path_type').nullable()
table.boolean('gpu_compute_detected').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('cpu_events_single')
table.dropColumn('cpu_events_multi')
table.dropColumn('cpu_benchmark_threads')
table.dropColumn('cpu_total_events')
table.dropColumn('cpu_total_time')
table.dropColumn('memory_ops_per_sec')
table.dropColumn('memory_threads')
table.dropColumn('disk_read_mb_per_sec')
table.dropColumn('disk_write_mb_per_sec')
table.dropColumn('nomad_score_v2')
table.dropColumn('run_environment')
table.dropColumn('storage_path_type')
table.dropColumn('gpu_compute_detected')
})
}
}

View File

@ -82,7 +82,7 @@ export default function BenchmarkPage(props: {
setProgress({
status: 'starting',
progress: 5,
message: 'Starting benchmark... This takes 2-5 minutes.',
message: 'Starting benchmark... This takes 3-6 minutes.',
current_stage: 'Starting',
benchmark_id: '',
timestamp: new Date().toISOString(),
@ -423,7 +423,7 @@ export default function BenchmarkPage(props: {
)}
<p className="text-desert-stone-dark">
Run a benchmark to measure your system's CPU, memory, disk, and AI inference
performance. The benchmark takes approximately 2-5 minutes to complete.
performance. The benchmark takes approximately 3-6 minutes to complete.
</p>
<div className="flex flex-wrap gap-4">
<StyledButton
@ -487,7 +487,7 @@ export default function BenchmarkPage(props: {
<div className="shrink-0">
<CircularGauge
value={latestResult.nomad_score}
label="NOMAD Score"
label={latestResult.nomad_score_v2 != null ? 'Legacy Score' : 'NOMAD Score'}
size="lg"
variant="cpu"
subtext="out of 100"
@ -495,14 +495,36 @@ export default function BenchmarkPage(props: {
/>
</div>
<div className="flex-1 space-y-4">
<div
className={`text-5xl font-bold ${getScoreColor(latestResult.nomad_score)}`}
>
{latestResult.nomad_score.toFixed(1)}
</div>
<p className="text-desert-stone-dark">
Your NOMAD Score is a weighted composite of all benchmark results.
</p>
{latestResult.nomad_score_v2 != null ? (
<>
<div className="flex items-baseline gap-3">
<div className="text-5xl font-bold text-desert-green">
{latestResult.nomad_score_v2.toFixed(1)}
</div>
<div className="text-sm text-desert-stone-dark flex items-center gap-1">
NOMAD Score
<InfoTooltip text="NOMAD Score v2 is an uncapped index versus the NOMAD Reference Build, which scores exactly 1000. Higher is better, and there is no ceiling." />
</div>
</div>
<p className="text-sm text-desert-stone-dark">
Reference Build = 1000.{' '}
<span className="text-desert-stone">
Legacy scale: {latestResult.nomad_score.toFixed(1)} / 100
</span>
</p>
</>
) : (
<>
<div
className={`text-5xl font-bold ${getScoreColor(latestResult.nomad_score)}`}
>
{latestResult.nomad_score.toFixed(1)}
</div>
<p className="text-desert-stone-dark">
Your NOMAD Score is a weighted composite of all benchmark results.
</p>
</>
)}
{/* Share with Community - Only for full benchmarks with AI data */}
{canShareBenchmark && (
@ -769,7 +791,7 @@ export default function BenchmarkPage(props: {
<div>
<div className="text-desert-stone-dark">NOMAD Score</div>
<div className="font-bold text-desert-green">
{latestResult.nomad_score.toFixed(1)}
{(latestResult.nomad_score_v2 ?? latestResult.nomad_score).toFixed(1)}
</div>
</div>
</div>
@ -880,6 +902,101 @@ export default function BenchmarkPage(props: {
</div>
</div>
</div>
{/* v2 raw measurements + run environment */}
{latestResult.cpu_events_multi != null && (
<div className="mt-6 pt-6 border-t border-desert-stone-light grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-desert-green mb-3">
Measured Performance (v2)
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-desert-stone-dark">CPU single-thread</span>
<span className="font-mono">
{latestResult.cpu_events_single?.toFixed(1)} events/s
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">
CPU multi-thread ({latestResult.cpu_benchmark_threads}T)
</span>
<span className="font-mono">
{latestResult.cpu_events_multi?.toFixed(1)} events/s
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">
Memory ({latestResult.memory_threads}T)
</span>
<span className="font-mono">
{latestResult.memory_ops_per_sec?.toLocaleString()} ops/s
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">
Disk read (O_DIRECT)
</span>
<span className="font-mono">
{latestResult.disk_read_mb_per_sec?.toFixed(1)} MB/s
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">
Disk write (O_DIRECT)
</span>
<span className="font-mono">
{latestResult.disk_write_mb_per_sec?.toFixed(1)} MB/s
</span>
</div>
</div>
</div>
<div>
<h4 className="font-semibold text-desert-green mb-3">Environment</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-desert-stone-dark">Run environment</span>
<span className="font-mono">
{latestResult.run_environment || 'Unknown'}
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Storage backend</span>
<span className="font-mono">
{latestResult.storage_path_type || 'Unknown'}
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">GPU compute</span>
<span className="font-mono">
{latestResult.gpu_compute_detected == null
? 'Unknown'
: latestResult.gpu_compute_detected
? 'Detected'
: 'Not detected'}
</span>
</div>
{latestResult.sysbench_digest && (
<div className="flex justify-between">
<span className="text-desert-stone-dark">sysbench</span>
<span className="font-mono text-xs">
{latestResult.sysbench_digest.replace('sha256:', '').slice(0, 12)}
</span>
</div>
)}
{latestResult.ollama_version && (
<div className="flex justify-between">
<span className="text-desert-stone-dark">Ollama</span>
<span className="font-mono text-xs">
{latestResult.ollama_version}
</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>

View File

@ -115,6 +115,37 @@ export type UpdateBuilderTagResponse = {
error: string
}
// NOMAD Score v2 raw channels captured from a full benchmark run (all present
// and > 0). cpu_events_multi is measured at cpu_benchmark_threads; memory at
// memory_threads; disk figures are O_DIRECT MB/s. total_events/total_time are the
// W6 consistency companions from the multi-thread CPU pass.
export type SystemBenchmarkRawsV2 = {
cpu_events_single: number
cpu_events_multi: number
cpu_benchmark_threads: number
cpu_total_events: number
cpu_total_time: number
memory_ops_per_sec: number
memory_threads: number
disk_read_mb_per_sec: number
disk_write_mb_per_sec: number
}
// Result of a system benchmark pass: the legacy 0-1 sub-scores (v1) plus the v2
// raw channels, both derived from the same sysbench runs.
export type SystemBenchmarkOutput = {
scores: SystemScores
raws: SystemBenchmarkRawsV2
}
// Best-effort run-environment metadata (issue #1016). Any field may be null when
// detection fails; none of them gate a submission.
export type RunEnvironmentInfo = {
run_environment: string | null
storage_path_type: string | null
gpu_compute_detected: boolean | null
}
// Central repository submission payload (privacy-first)
export type RepositorySubmission = Pick<
BenchmarkResult,
@ -137,6 +168,46 @@ export type RepositorySubmission = Pick<
builder_tag: string | null // null = anonymous submission
}
// NOMAD Score v2 submission payload. Mirrors the leaderboard's submitValidatorV2
// exactly: raw channels in (server recomputes the score), required test params +
// W6 companions + provenance, optional environment metadata. ai_time_to_first_token
// is in SECONDS here (the server treats it as seconds); the client stores TTFT in
// ms, so submitToRepository divides by 1000.
export type RepositorySubmissionV2 = {
// Hardware
cpu_model: string
cpu_cores: number
cpu_threads: number
ram_gb: number
disk_type: DiskType
gpu_model: string | null
// Scored raw channels (all required, > 0)
ai_tokens_per_second: number
cpu_events_single: number
cpu_events_multi: number
memory_ops_per_sec: number
disk_read_mb_per_sec: number
disk_write_mb_per_sec: number
// Test parameters
cpu_benchmark_threads: number
memory_threads: number
// Metadata channel (weight 0) + W6 consistency companions
ai_time_to_first_token: number // seconds
cpu_total_events: number
cpu_total_time: number
// Provenance (required)
ollama_version: string
sysbench_digest: string
// Environment metadata (best-effort)
run_environment?: string
storage_path_type?: string
gpu_compute_detected?: boolean
// Benchmark metadata (shared with v1)
nomad_version: string
benchmark_version: string
builder_tag?: string
}
// Central repository response types
export type RepositorySubmitResponse = {
success: boolean