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
74 changes: 64 additions & 10 deletions packages/app/src/components/evaluation/RecipeComparison.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
'use client';

import { ExternalLink } from 'lucide-react';
import { useMemo } from 'react';
import { useMemo, useState } 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 { track } from '@/lib/analytics';

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

function fmtNum(n: number | null, digits = 2, suffix = ''): string {
if (n === null || !Number.isFinite(n)) return '—';
Expand All @@ -32,6 +38,12 @@ const COLUMNS: DataTableColumn<RecipeRow>[] = [
cell: (r) => r.hardware,
sortValue: (r) => r.hardware,
},
{
header: 'Topology',
align: 'left',
cell: (r) => <span className="whitespace-nowrap font-mono text-xs">{r.topology}</span>,
sortValue: (r) => `${r.numPrefillGpu + r.numDecodeGpu}_${r.prefillTp}_${r.disagg ? 1 : 0}`,
},
{
header: 'Precision',
align: 'left',
Expand Down Expand Up @@ -132,6 +144,7 @@ const COLUMNS: DataTableColumn<RecipeRow>[] = [

export default function RecipeComparison() {
const { selectedModel, selectedRunDate } = useGlobalFilters();
const [category, setCategory] = useState<TechniqueCategory | 'all'>('all');

const {
data: benchmarks,
Expand All @@ -140,7 +153,7 @@ export default function RecipeComparison() {
} = useBenchmarks(selectedModel, selectedRunDate || undefined);
const { data: evals, isLoading: evalLoading, error: evalError } = useEvaluations();

const rows = useMemo(() => {
const allRows = 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.
Expand All @@ -151,18 +164,59 @@ export default function RecipeComparison() {
});
}, [benchmarks, evals]);

// Only show category chips for axes that actually have rows. Avoids empty
// chips that lead to a blank table when selected.
const availableCategories = useMemo(() => {
const seen = new Set<TechniqueCategory>(allRows.map((r) => r.category));
return TECHNIQUE_CATEGORIES.filter((c) => c.value === 'all' || seen.has(c.value));
}, [allRows]);

const rows = useMemo(() => {
if (category === 'all') return allRows;
// Always include baseline rows in any non-"all" filter so the user has a
// reference variant on the page if one exists in the group.
return allRows.filter((r) => r.category === category || r.isBaseline);
}, [allRows, category]);

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 className="flex flex-col gap-3">
<div 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, batch size, …) on the same deployment.
Speedup is computed against the variant whose <code>techniques</code> is empty within
the same (model × topology × precision × isl/osl × conc) group.
</p>
</div>
{availableCategories.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Filter by knob:</span>
{availableCategories.map((c) => {
const active = category === c.value;
return (
<button
key={c.value}
type="button"
onClick={() => {
setCategory(c.value);
track('evaluation_recipe_category_changed', { category: c.value });
}}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
active
? 'border-brand bg-brand text-primary-foreground'
: 'border-border bg-card hover:bg-accent'
}`}
>
{c.label}
</button>
);
})}
</div>
)}
</header>

{error && (
Expand Down
39 changes: 38 additions & 1 deletion packages/app/src/components/evaluation/recipe-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';

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

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

function bmk(overrides: Partial<BenchmarkRow>): BenchmarkRow {
return {
Expand Down Expand Up @@ -142,6 +142,43 @@ describe('buildRecipeRows', () => {
expect(rows[0]?.accuracy).toBeCloseTo(0.842, 5);
});

it('separates 1× and 2× H100 into distinct groups via topology in groupKey', () => {
const rows = buildRecipeRows(
[
bmk({
num_prefill_gpu: 1,
num_decode_gpu: 1,
prefill_tp: 1,
decode_tp: 1,
techniques: { max_num_batched_tokens: 4096 },
}),
bmk({
num_prefill_gpu: 2,
num_decode_gpu: 2,
prefill_tp: 2,
decode_tp: 2,
techniques: { spec_method: 'mtp', num_speculative_tokens: 4 },
}),
],
[],
);
expect(new Set(rows.map((r) => r.groupKey)).size).toBe(2);
const oneXh = rows.find((r) => r.numPrefillGpu === 1)!;
const twoXh = rows.find((r) => r.numPrefillGpu === 2)!;
expect(oneXh.topology).toBe('1× (TP=1)');
expect(twoXh.topology).toBe('2× (TP=2)');
});

it('tags rows with technique category for filter chips', () => {
expect(categorizeTechniques({})).toBe('baseline');
expect(categorizeTechniques({ spec_method: 'mtp', num_speculative_tokens: 4 })).toBe(
'spec-decoding',
);
expect(categorizeTechniques({ max_num_batched_tokens: 4096 })).toBe('batch-size');
expect(categorizeTechniques({ kv_cache_dtype: 'fp8' })).toBe('kv-cache');
expect(categorizeTechniques({ prefix_cache: 'true' })).toBe('prefix-cache');
});

it('reads acceptance rate from variant metrics', () => {
const rows = buildRecipeRows(
[
Expand Down
105 changes: 100 additions & 5 deletions packages/app/src/components/evaluation/recipe-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,50 @@ import type { BenchmarkRow, EvalRow } from '@/lib/api';

const ACCURACY_KEY = 'em_strict';

/**
* Recipe-comparison filter axes. Each row is tagged with one category so the
* UI can show e.g. only spec-decoding variants without mixing in batch-size
* sweeps.
*/
export type TechniqueCategory =
| 'baseline'
| 'spec-decoding'
| 'batch-size'
| 'kv-cache'
| 'prefix-cache'
| 'other';

export const TECHNIQUE_CATEGORIES: { value: TechniqueCategory | 'all'; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'spec-decoding', label: 'Spec decoding' },
{ value: 'batch-size', label: 'Batch size' },
{ value: 'kv-cache', label: 'KV cache' },
{ value: 'prefix-cache', label: 'Prefix cache' },
{ value: 'baseline', label: 'Baseline only' },
];

export interface RecipeRow {
groupKey: string;
/** Identity (constant within a group). */
model: string;
hardware: string;
framework: string;
precision: string;
/** Parallelism dims that distinguish 1× vs 2× topology, disagg, etc. */
numPrefillGpu: number;
numDecodeGpu: number;
prefillTp: number;
decodeTp: number;
disagg: boolean;
/** Pretty-printed topology, e.g. "2× (TP=2)", "1P+1D (TP=2/2)". */
topology: string;
isl: number;
osl: number;
conc: number;
/** Variant. */
techniques: Record<string, string | number>;
variantLabel: string;
category: TechniqueCategory;
isBaseline: boolean;
/** Measurements. */
tputPerGpu: number;
Expand All @@ -48,9 +79,65 @@ export interface RecipeRow {
}

function groupKey(
r: Pick<BenchmarkRow, 'model' | 'hardware' | 'framework' | 'precision' | 'isl' | 'osl' | 'conc'>,
r: Pick<
BenchmarkRow,
| 'model'
| 'hardware'
| 'framework'
| 'precision'
| 'isl'
| 'osl'
| 'conc'
| 'num_prefill_gpu'
| 'num_decode_gpu'
| 'prefill_tp'
| 'decode_tp'
| 'disagg'
>,
): string {
return `${r.model}|${r.hardware}|${r.framework}|${r.precision}|${r.isl}|${r.osl}|${r.conc}`;
// Topology dims (num_*_gpu, *_tp, disagg) are part of the deployment
// context — a 1× H100 and a 2× H100 run are different deployments and must
// not collapse into the same row group.
return [
r.model,
r.hardware,
r.framework,
r.precision,
r.isl,
r.osl,
r.conc,
r.num_prefill_gpu,
r.num_decode_gpu,
r.prefill_tp,
r.decode_tp,
r.disagg ? '1' : '0',
].join('|');
}

function topologyLabel(r: {
num_prefill_gpu: number;
num_decode_gpu: number;
prefill_tp: number;
decode_tp: number;
disagg: boolean;
}): string {
if (r.disagg) {
return `${r.num_prefill_gpu}P+${r.num_decode_gpu}D (TP=${r.prefill_tp}/${r.decode_tp})`;
}
return `${r.num_prefill_gpu}× (TP=${r.prefill_tp})`;
}

/**
* Tag a techniques jsonb with the category that best describes the knob being
* tuned. Used to drive the filter chip group on /evaluation.
*/
export function categorizeTechniques(t: Record<string, string | number>): TechniqueCategory {
if (Object.keys(t).length === 0) return 'baseline';
if (typeof t.spec_method === 'string' && t.spec_method !== 'none') return 'spec-decoding';
if (t.max_num_batched_tokens !== undefined) return 'batch-size';
if (t.kv_cache_dtype !== undefined) return 'kv-cache';
if (t.prefix_cache !== undefined) return 'prefix-cache';
return 'other';
}

function accuracyKey(r: {
Expand Down Expand Up @@ -130,18 +217,26 @@ export function buildRecipeRows(benchmarks: BenchmarkRow[], evals: EvalRow[]): R
baseline && baseline.metrics?.median_tpot
? tpot / Number(baseline.metrics.median_tpot)
: null;
const techniques = r.techniques ?? {};
out.push({
groupKey: k,
model: r.model,
hardware: r.hardware,
framework: r.framework,
precision: r.precision,
numPrefillGpu: r.num_prefill_gpu,
numDecodeGpu: r.num_decode_gpu,
prefillTp: r.prefill_tp,
decodeTp: r.decode_tp,
disagg: r.disagg,
topology: topologyLabel(r),
isl: r.isl,
osl: r.osl,
conc: r.conc,
techniques: r.techniques ?? {},
variantLabel: describeTechniques(r.techniques ?? {}),
isBaseline: isBaseline(r.techniques ?? {}),
techniques,
variantLabel: describeTechniques(techniques),
category: categorizeTechniques(techniques),
isBaseline: isBaseline(techniques),
tputPerGpu: tput,
medianTpot: tpot,
medianIntvty: intvty,
Expand Down
Loading