Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions packages/app/src/app/(dashboard)/evaluation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import type { Metadata } from 'next';

import { EvaluationProvider } from '@/components/evaluation/EvaluationContext';
import EvaluationChartDisplay from '@/components/evaluation/ui/ChartDisplay';
import RecipeComparison from '@/components/evaluation/RecipeComparison';
import { NudgeEngine } from '@/components/nudge-engine';
import { tabMetadata } from '@/lib/tab-meta';

export const metadata: Metadata = tabMetadata('evaluation');

export default function EvaluationPage() {
return (
<EvaluationProvider>
<EvaluationChartDisplay />
<>
<RecipeComparison />
<NudgeEngine scope="evaluation" />
</EvaluationProvider>
</>
);
}
201 changes: 201 additions & 0 deletions packages/app/src/components/evaluation/RecipeComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
'use client';

import { ExternalLink } from 'lucide-react';
import { useMemo } from 'react';

import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { useGlobalFilters } from '@/components/GlobalFilterContext';
import { useBenchmarks } from '@/hooks/api/use-benchmarks';
import { useEvaluations } from '@/hooks/api/use-evaluations';

import { buildRecipeRows, type RecipeRow } from './recipe-data';

function fmtNum(n: number | null, digits = 2, suffix = ''): string {
if (n === null || !Number.isFinite(n)) return '—';
return `${n.toFixed(digits)}${suffix}`;
}

function fmtPct(n: number | null): string {
if (n === null || !Number.isFinite(n)) return '—';
return `${(n * 100).toFixed(1)}%`;
}

function fmtSpeedup(n: number | null): string {
if (n === null || !Number.isFinite(n)) return '—';
return `${n.toFixed(2)}×`;
}

const COLUMNS: DataTableColumn<RecipeRow>[] = [
{
header: 'HW',
align: 'left',
cell: (r) => r.hardware,
sortValue: (r) => r.hardware,
},
{
header: 'Precision',
align: 'left',
cell: (r) => r.precision,
sortValue: (r) => r.precision,
},
{
header: 'Seq',
align: 'left',
cell: (r) => `${r.isl / 1024}k/${r.osl / 1024}k`,
sortValue: (r) => `${r.isl}_${r.osl}`,
},
{
header: 'Conc',
align: 'right',
cell: (r) => String(r.conc),
sortValue: (r) => r.conc,
},
{
header: 'Variant',
align: 'left',
cell: (r) => (
<span className={r.isBaseline ? 'text-muted-foreground' : 'font-medium'}>
{r.variantLabel}
</span>
),
sortValue: (r) => r.variantLabel,
},
{
header: 'Speedup',
align: 'right',
cell: (r) => {
if (r.speedup === null) return <span className="text-muted-foreground">—</span>;
const better = r.speedup > 1.01;
const worse = r.speedup < 0.99;
const cls = better ? 'text-green-500' : worse ? 'text-red-500' : '';
return <span className={cls}>{fmtSpeedup(r.speedup)}</span>;
},
sortValue: (r) => r.speedup ?? -Infinity,
},
{
header: 'tput/GPU',
align: 'right',
cell: (r) => fmtNum(r.tputPerGpu, 0),
sortValue: (r) => r.tputPerGpu,
},
{
header: 'TPOT (s)',
align: 'right',
cell: (r) => fmtNum(r.medianTpot, 4),
sortValue: (r) => r.medianTpot,
},
{
header: 'Accept Rate',
align: 'right',
cell: (r) => fmtPct(r.acceptanceRate),
sortValue: (r) => r.acceptanceRate ?? -Infinity,
},
{
header: 'Accuracy',
align: 'right',
cell: (r) => {
if (r.accuracy === null) return <span className="text-muted-foreground">—</span>;
const delta = r.accuracyDelta;
return (
<span>
{fmtPct(r.accuracy)}
{delta !== null && Math.abs(delta) >= 0.001 && (
<span className={`ml-1 text-xs ${delta < 0 ? 'text-red-500' : 'text-green-500'}`}>
{delta > 0 ? '+' : ''}
{(delta * 100).toFixed(2)}pp
</span>
)}
</span>
);
},
sortValue: (r) => r.accuracy ?? -Infinity,
},
{
header: 'Run',
align: 'center',
cell: (r) =>
r.runUrl ? (
<a
href={r.runUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-muted-foreground hover:text-foreground"
title="Open benchmark run on GitHub Actions"
>
<ExternalLink className="size-4" />
</a>
) : (
<span className="text-muted-foreground">—</span>
),
},
];

export default function RecipeComparison() {
const { selectedModel, selectedRunDate } = useGlobalFilters();

const {
data: benchmarks,
isLoading: bmkLoading,
error: bmkError,
} = useBenchmarks(selectedModel, selectedRunDate || undefined);
const { data: evals, isLoading: evalLoading, error: evalError } = useEvaluations();

const rows = useMemo(() => {
if (!benchmarks || benchmarks.length === 0) return [];
const built = buildRecipeRows(benchmarks, evals ?? []);
// Sort: group rows together, baseline first within each group, then by speedup descending.
return built.toSorted((a, b) => {
if (a.groupKey !== b.groupKey) return a.groupKey.localeCompare(b.groupKey);
if (a.isBaseline !== b.isBaseline) return a.isBaseline ? -1 : 1;
return (b.speedup ?? 0) - (a.speedup ?? 0);
});
}, [benchmarks, evals]);

const loading = bmkLoading || evalLoading;
const error = bmkError ?? evalError;

return (
<section className="container mx-auto flex flex-col gap-4 px-4 py-6 lg:px-8">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">Recipe Comparison</h1>
<p className="text-sm text-muted-foreground">
Compare runtime knobs (speculative decoding, MTP layers, …) on the same deployment.
Speedup is computed against the variant whose <code>techniques</code> is empty within the
same (model × hw × precision × isl/osl × conc) group.
</p>
</header>

{error && (
<div className="rounded-md border border-red-500 bg-red-500/10 px-4 py-2 text-sm text-red-500">
{error.message}
</div>
)}

{loading && (
<div className="text-sm text-muted-foreground">Loading benchmarks and evaluations…</div>
)}

{!loading && rows.length === 0 && (
<div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
No data for <strong>{selectedModel || '(no model)'}</strong>
{selectedRunDate ? (
<>
{' '}
on <strong>{selectedRunDate}</strong>
</>
) : null}
. Pick a different model or date.
</div>
)}

{!loading && rows.length > 0 && (
<DataTable
data={rows}
columns={COLUMNS}
testId="recipe-comparison-table"
analyticsPrefix="evaluation_recipe"
/>
)}
</section>
);
}
156 changes: 156 additions & 0 deletions packages/app/src/components/evaluation/recipe-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';

import type { BenchmarkRow, EvalRow } from '@/lib/api';

import { buildRecipeRows, describeTechniques } from './recipe-data';

function bmk(overrides: Partial<BenchmarkRow>): BenchmarkRow {
return {
hardware: 'h100',
framework: 'vllm',
model: 'gemma4',
precision: 'fp8',
spec_method: 'none',
techniques: {},
disagg: false,
is_multinode: false,
prefill_tp: 2,
prefill_ep: 1,
prefill_dp_attention: false,
prefill_num_workers: 0,
decode_tp: 2,
decode_ep: 1,
decode_dp_attention: false,
decode_num_workers: 0,
num_prefill_gpu: 2,
num_decode_gpu: 2,
isl: 1024,
osl: 1024,
conc: 8,
image: null,
metrics: { tput_per_gpu: 100, median_tpot: 0.01 },
date: '2026-05-25',
run_url: 'https://github.com/vngcloud/InferenceX/actions/runs/1',
...overrides,
};
}

describe('describeTechniques', () => {
it('labels empty object as baseline', () => {
expect(describeTechniques({})).toBe('baseline');
});

it('labels mtp + layer count compactly', () => {
expect(describeTechniques({ spec_method: 'mtp', mtp_layers: 4 })).toBe('MTP×4');
expect(describeTechniques({ spec_method: 'mtp', mtp_layers: 6 })).toBe('MTP×6');
});

it('falls back to capitalized spec_method', () => {
expect(describeTechniques({ spec_method: 'eagle' })).toBe('Eagle');
});
});

describe('buildRecipeRows', () => {
it('computes speedup vs baseline within a group', () => {
const rows = buildRecipeRows(
[
bmk({ techniques: {}, metrics: { tput_per_gpu: 100, median_tpot: 0.01 } }),
bmk({
techniques: { spec_method: 'mtp', mtp_layers: 4 },
metrics: { tput_per_gpu: 150, median_tpot: 0.0067 },
}),
],
[],
);
expect(rows).toHaveLength(2);
const baseline = rows.find((r) => r.isBaseline)!;
const mtp = rows.find((r) => !r.isBaseline)!;
expect(baseline.speedup).toBe(1);
expect(mtp.speedup).toBeCloseTo(1.5, 3);
expect(mtp.tpotRatio).toBeCloseTo(0.67, 2);
});

it('returns null speedup when no baseline exists in the group', () => {
// Two MTP variants, no spec_method=none baseline.
const rows = buildRecipeRows(
[
bmk({
techniques: { spec_method: 'mtp', mtp_layers: 4 },
metrics: { tput_per_gpu: 120 },
}),
bmk({
techniques: { spec_method: 'mtp', mtp_layers: 6 },
metrics: { tput_per_gpu: 140 },
}),
],
[],
);
for (const r of rows) {
expect(r.isBaseline).toBe(false);
expect(r.speedup).toBeNull();
expect(r.tpotRatio).toBeNull();
}
});

it('groups by (model, hw, framework, precision, isl, osl, conc) and not by techniques', () => {
const rows = buildRecipeRows(
[
bmk({ techniques: {} }),
bmk({ techniques: { spec_method: 'mtp', mtp_layers: 4 } }),
bmk({ techniques: { spec_method: 'mtp', mtp_layers: 6 } }),
// Different conc → different group, no shared baseline.
bmk({ techniques: { spec_method: 'mtp', mtp_layers: 4 }, conc: 16 }),
],
[],
);
expect(rows).toHaveLength(4);
const groupKeys = new Set(rows.map((r) => r.groupKey));
expect(groupKeys.size).toBe(2);
});

it('joins accuracy from eval rows at the (model, hw, framework, precision) level', () => {
const ev: EvalRow = {
id: 1,
config_id: 1,
hardware: 'h100',
framework: 'vllm',
model: 'gemma4',
precision: 'fp8',
spec_method: 'none',
techniques: {},
disagg: false,
is_multinode: false,
prefill_tp: 2,
prefill_ep: 1,
prefill_dp_attention: false,
prefill_num_workers: 0,
decode_tp: 2,
decode_ep: 1,
decode_dp_attention: false,
decode_num_workers: 0,
num_prefill_gpu: 2,
num_decode_gpu: 2,
task: 'gsm8k',
date: '2026-05-25',
conc: null,
metrics: { em_strict: 0.842 },
timestamp: '2026-05-25T00:00:00Z',
run_url: null,
};
const rows = buildRecipeRows([bmk({ techniques: {} })], [ev]);
expect(rows[0]?.accuracy).toBeCloseTo(0.842, 5);
});

it('reads acceptance rate from variant metrics', () => {
const rows = buildRecipeRows(
[
bmk({
techniques: { spec_method: 'mtp', mtp_layers: 4 },
metrics: { tput_per_gpu: 150, median_acceptance_rate: 0.78 },
}),
],
[],
);
expect(rows[0]?.acceptanceRate).toBeCloseTo(0.78, 5);
});
});
Loading
Loading