From 0562f7cfff344451815417fca85e1357b8d27bc4 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 04:01:48 +0000 Subject: [PATCH] feat: add SSR'd /compare/[a]-vs-[b] head-to-head GPU comparison pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #267. New top-level route that renders any pair of GPUs from the HW_REGISTRY as a focused, indexable head-to-head comparison. - `/compare/[slug]` is server-rendered (`force-dynamic`) and reads from the same `cachedQuery` blob cache used by `/api/v1/benchmarks`, so the existing `/api/v1/invalidate` endpoint (which calls `purgeAll()` → `blobPurge()` + `revalidateTag('db')`) also flushes the compare pages on each ingest. No time-based revalidation. - Slug = `-vs-`. Canonical = alphabetical; non-canonical slugs redirect (307) to canonical so we get one URL per pair. - JSON-LD `ItemList` of `Product` entries with vendor, architecture, TDP, and best-in-class throughput / TTFT / TPOT pulled from the actual benchmark data — consumable by search engines and LLMs. - Reuses `` + `` so the chart, table, controls, PNG/CSV export, pan/zoom, and unofficial-run overlay path all carry over unchanged. - Server picks `(precision, sequence)` defaults that maximise overlap between the two GPUs in the pair (so `/compare/h100-vs-h200` lands on FP8 instead of the global FP4 default which has no Hopper data). New `initialModel` / `initialSequence` / `initialPrecisions` props on `GlobalFilterProvider` take effect only when the URL has no override. - `InferenceProvider` gains `initialActiveHwTypes` to seed the legend filter from the slug. The existing `pendingActiveHwTypes` consumer now understands bare GPU prefixes (e.g. `h100`) in addition to full framework-suffixed hwKeys, so we can pass `[a, b]` without knowing which framework configs exist. - OG image generator at `/compare/[slug]/opengraph-image` produces a vendor-coloured side-by-side `A vs B` PNG. - Sitemap includes all C(n, 2) canonical pairs. - 14 unit tests for slug parse / canonicalize / pair-enumeration. Verified in Playwright: - /compare/h100-vs-h200 → FP8 auto-selected, H100/H200 active in legend, others dimmed; both charts render real data. - /compare/b200-vs-mi355x (cross-vendor) → both shown, real data. - /compare/h200-vs-h100 → 307 → /compare/h100-vs-h200. - /compare/a100-vs-h100 → 404. - OG image renders correctly. Co-authored-by: functionstackx --- .../app/compare/[slug]/opengraph-image.tsx | 234 ++++++++++++++++ .../src/app/compare/[slug]/page-client.tsx | 187 ++++++++++++ packages/app/src/app/compare/[slug]/page.tsx | 265 ++++++++++++++++++ packages/app/src/app/compare/layout.tsx | 18 ++ packages/app/src/app/sitemap.ts | 7 + .../src/components/GlobalFilterContext.tsx | 26 +- .../components/inference/InferenceContext.tsx | 33 ++- packages/app/src/lib/compare-slug.test.ts | 99 +++++++ packages/app/src/lib/compare-slug.ts | 54 ++++ 9 files changed, 915 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/app/compare/[slug]/opengraph-image.tsx create mode 100644 packages/app/src/app/compare/[slug]/page-client.tsx create mode 100644 packages/app/src/app/compare/[slug]/page.tsx create mode 100644 packages/app/src/app/compare/layout.tsx create mode 100644 packages/app/src/lib/compare-slug.test.ts create mode 100644 packages/app/src/lib/compare-slug.ts diff --git a/packages/app/src/app/compare/[slug]/opengraph-image.tsx b/packages/app/src/app/compare/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..38af8c51 --- /dev/null +++ b/packages/app/src/app/compare/[slug]/opengraph-image.tsx @@ -0,0 +1,234 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { ImageResponse } from 'next/og'; + +import { HW_REGISTRY } from '@semianalysisai/inferencex-constants'; + +import { allCanonicalComparePairs, parseCompareSlug, toCompareSlug } from '@/lib/compare-slug'; + +export const alt = 'GPU inference benchmark comparison'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +const BLUE = '#0B86D1'; +const BG = '#131416'; +const PANEL_BG = '#0F1214'; +const VENDOR_COLOR: Record = { + NVIDIA: '#76B900', + AMD: '#ED1C24', + Intel: '#0071C5', +}; + +export function generateStaticParams() { + return allCanonicalComparePairs().map(({ a, b }) => ({ slug: toCompareSlug(a, b) })); +} + +export default async function OgImage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const pair = parseCompareSlug(slug); + const logoSrc = await readFile(join(process.cwd(), 'public/brand/logo-color.png')).then( + (buf) => `data:image/png;base64,${buf.toString('base64')}`, + ); + + if (!pair) { + return new ImageResponse( +
+ InferenceX GPU Comparison +
, + size, + ); + } + + const aMeta = HW_REGISTRY[pair.a]; + const bMeta = HW_REGISTRY[pair.b]; + const aLabel = aMeta?.label ?? pair.a.toUpperCase(); + const bLabel = bMeta?.label ?? pair.b.toUpperCase(); + const aColor = aMeta ? (VENDOR_COLOR[aMeta.vendor] ?? BLUE) : BLUE; + const bColor = bMeta ? (VENDOR_COLOR[bMeta.vendor] ?? BLUE) : BLUE; + + const fontSize = aLabel.length + bLabel.length > 22 ? 96 : 120; + + return new ImageResponse( +
+
+
+ Head-to-head GPU benchmark +
+ +
+ +
+
+ VS +
+
+ +
+ +
+ + AI inference benchmark · latency, throughput, cost + + +
+
, + size, + ); +} + +function GpuPanel({ + label, + vendor, + arch, + color, + fontSize, + align, +}: { + label: string; + vendor?: string; + arch?: string; + color: string; + fontSize: number; + align: 'flex-start' | 'flex-end'; +}) { + return ( +
+
+ {label} +
+
+ {vendor && ( + + {vendor} + + )} + {arch && {arch}} +
+
+ ); +} diff --git a/packages/app/src/app/compare/[slug]/page-client.tsx b/packages/app/src/app/compare/[slug]/page-client.tsx new file mode 100644 index 00000000..230f35c3 --- /dev/null +++ b/packages/app/src/app/compare/[slug]/page-client.tsx @@ -0,0 +1,187 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect } from 'react'; + +import { track } from '@/lib/analytics'; +import { Card } from '@/components/ui/card'; +import { GlobalFilterProvider } from '@/components/GlobalFilterContext'; +import { InferenceProvider } from '@/components/inference/InferenceContext'; +import InferenceChartDisplay from '@/components/inference/ui/ChartDisplay'; +import { Model, Precision, Sequence } from '@/lib/data-mappings'; + +interface PairSummary { + hardware: string; + configCount: number; + bestThroughputPerGpu: number | null; + bestMedianTtft: number | null; + bestMedianTpot: number | null; +} + +interface ComparePageClientProps { + a: string; + b: string; + label: string; + defaultModel: string; + defaultSequence: string | null; + defaultPrecision: string | null; + ssrSummary: Record; + aLabel: string; + bLabel: string; + aVendor: string; + bVendor: string; + aArch: string; + bArch: string; +} + +function fmtNum(value: number | null, decimals: number): string { + if (value === null) return '—'; + return value.toFixed(decimals); +} + +function toModel(value: string): Model | undefined { + return Object.values(Model).includes(value as Model) ? (value as Model) : undefined; +} + +function toSequence(value: string | null): Sequence | undefined { + if (!value) return undefined; + return Object.values(Sequence).includes(value as Sequence) ? (value as Sequence) : undefined; +} + +function toPrecisions(value: string | null): string[] | undefined { + if (!value) return undefined; + return Object.values(Precision).includes(value as Precision) ? [value] : undefined; +} + +export default function ComparePageClient({ + a, + b, + label, + defaultModel, + defaultSequence, + defaultPrecision, + ssrSummary, + aLabel, + bLabel, + aVendor, + bVendor, + aArch, + bArch, +}: ComparePageClientProps) { + useEffect(() => { + track('compare_page_view', { gpu_a: a, gpu_b: b, default_model: defaultModel }); + }, [a, b, defaultModel]); + + const summaryA = ssrSummary[a]; + const summaryB = ssrSummary[b]; + const initialModel = toModel(defaultModel); + const initialSequence = toSequence(defaultSequence); + const initialPrecisions = toPrecisions(defaultPrecision); + + return ( + + + +
+
+ GPU comparison +
+

{label}

+

+ Head-to-head AI inference benchmark comparison of {aLabel} ({aVendor}{' '} + {aArch}) and {bLabel} ({bVendor} {bArch}). Latency, throughput, and + cost across LLM workloads. Use the controls below to switch models, sequences, + precisions, and metrics — same interactions as{' '} + + the main inference chart + + . +

+
+ +
+ +
+
+ ); +} + +function PairSummaryGrid({ + a, + b, + aLabel, + bLabel, + summaryA, + summaryB, + defaultModel, +}: { + a: string; + b: string; + aLabel: string; + bLabel: string; + summaryA: PairSummary | undefined; + summaryB: PairSummary | undefined; + defaultModel: string; +}) { + if (!summaryA || !summaryB) return null; + + const rows: { label: string; aVal: string; bVal: string }[] = [ + { + label: 'Best throughput / GPU (tok/s)', + aVal: fmtNum(summaryA.bestThroughputPerGpu, 1), + bVal: fmtNum(summaryB.bestThroughputPerGpu, 1), + }, + { + label: 'Best median TTFT (s)', + aVal: fmtNum(summaryA.bestMedianTtft, 3), + bVal: fmtNum(summaryB.bestMedianTtft, 3), + }, + { + label: 'Best median TPOT (s)', + aVal: fmtNum(summaryA.bestMedianTpot, 4), + bVal: fmtNum(summaryB.bestMedianTpot, 4), + }, + { + label: 'Benchmark configurations', + aVal: String(summaryA.configCount), + bVal: String(summaryB.configCount), + }, + ]; + + return ( +
+
+ Latest results for {defaultModel} (best across all configurations) — explore more in the + chart below. +
+
+
+ Metric +
+
{aLabel}
+
{bLabel}
+ {rows.map((row) => ( +
+
+ {row.label} +
+
{row.aVal}
+
{row.bVal}
+
+ ))} +
+
+ ); +} diff --git a/packages/app/src/app/compare/[slug]/page.tsx b/packages/app/src/app/compare/[slug]/page.tsx new file mode 100644 index 00000000..73e57aec --- /dev/null +++ b/packages/app/src/app/compare/[slug]/page.tsx @@ -0,0 +1,265 @@ +import type { Metadata } from 'next'; +import { notFound, redirect } from 'next/navigation'; + +import { + HW_REGISTRY, + islOslToSequence, + SITE_NAME, + SITE_URL, +} from '@semianalysisai/inferencex-constants'; +import { JSON_MODE, getDb } from '@semianalysisai/inferencex-db/connection'; +import * as jsonProvider from '@semianalysisai/inferencex-db/json-provider'; +import { getLatestBenchmarks } from '@semianalysisai/inferencex-db/queries/benchmarks'; + +import { cachedQuery } from '@/lib/api-cache'; +import { + allCanonicalComparePairs, + canonicalCompareSlug, + compareDisplayLabel, + parseCompareSlug, + toCompareSlug, +} from '@/lib/compare-slug'; + +import ComparePageClient from './page-client'; + +// Dynamic SSR — page reflects latest data from cache, which is purged via +// /api/v1/invalidate (no time-based revalidation needed). +export const dynamic = 'force-dynamic'; + +interface Props { + params: Promise<{ slug: string }>; +} + +const DEFAULT_MODEL_DB_KEYS = ['dsr1']; +const DEFAULT_MODEL_DISPLAY = 'DeepSeek-R1-0528'; + +const getCachedBenchmarks = cachedQuery( + (dbModelKeys: string[]) => { + if (JSON_MODE) return Promise.resolve(jsonProvider.getLatestBenchmarks(dbModelKeys)); + return getLatestBenchmarks(getDb(), dbModelKeys); + }, + 'benchmarks', + { blobOnly: true }, +); + +export function generateStaticParams() { + return allCanonicalComparePairs().map(({ a, b }) => ({ slug: toCompareSlug(a, b) })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const pair = parseCompareSlug(slug); + if (!pair) return {}; + const label = compareDisplayLabel(pair.a, pair.b); + const url = `${SITE_URL}/compare/${canonicalCompareSlug(pair.a, pair.b)}`; + const description = `Head-to-head GPU inference benchmark comparison: ${label}. Latency, throughput, and cost across LLM workloads.`; + return { + title: `${label} Inference Benchmark`, + description, + alternates: { canonical: url }, + openGraph: { + title: `${label} | ${SITE_NAME}`, + description, + url, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: `${label} Inference Benchmark`, + description, + }, + }; +} + +interface PairSummary { + hardware: string; + configCount: number; + bestThroughputPerGpu: number | null; + bestMedianTtft: number | null; + bestMedianTpot: number | null; +} + +/** + * Pick the (sequence, precision) combo that maximises the number of distinct + * (concurrency, framework, spec_method) configs covered by BOTH GPUs in the + * pair. Falls back to whichever combo has any data for the pair if no overlap + * exists. Returns nulls if neither GPU has any rows at all (the chart will + * still render — InferenceProvider falls through to its hard-coded defaults). + */ +function pickPairDefaults( + rows: Awaited>, + a: string, + b: string, +): { sequence: string | null; precision: string | null } { + const tally = new Map(); + const seenA = new Map>(); + const seenB = new Map>(); + for (const row of rows) { + if (row.hardware !== a && row.hardware !== b) continue; + const seq = islOslToSequence(row.isl, row.osl); + if (!seq) continue; + const key = `${seq}|${row.precision}`; + const variantId = `${row.framework}|${row.spec_method}|${row.conc}`; + if (row.hardware === a) { + if (!seenA.has(key)) seenA.set(key, new Set()); + seenA.get(key)!.add(variantId); + } else { + if (!seenB.has(key)) seenB.set(key, new Set()); + seenB.get(key)!.add(variantId); + } + } + for (const key of new Set([...seenA.keys(), ...seenB.keys()])) { + const aSet = seenA.get(key) ?? new Set(); + const bSet = seenB.get(key) ?? new Set(); + let both = 0; + for (const v of aSet) if (bSet.has(v)) both++; + tally.set(key, { both, either: aSet.size + bSet.size }); + } + if (tally.size === 0) return { sequence: null, precision: null }; + // Prefer combos where both GPUs have data; tiebreak on combined coverage. + const best = [...tally.entries()].toSorted((left, right) => { + if (left[1].both !== right[1].both) return right[1].both - left[1].both; + return right[1].either - left[1].either; + })[0]; + const [seq, prec] = best[0].split('|'); + return { sequence: seq, precision: prec }; +} + +function summarize(rows: Awaited>, hw: string): PairSummary { + const hwRows = rows.filter((r) => r.hardware === hw); + let bestThroughput: number | null = null; + let bestTtft: number | null = null; + let bestTpot: number | null = null; + for (const row of hwRows) { + const m = row.metrics ?? {}; + const tput = typeof m.tput_per_gpu === 'number' ? m.tput_per_gpu : null; + const ttft = typeof m.median_ttft === 'number' ? m.median_ttft : null; + const tpot = typeof m.median_tpot === 'number' ? m.median_tpot : null; + if (tput !== null && (bestThroughput === null || tput > bestThroughput)) bestThroughput = tput; + if (ttft !== null && (bestTtft === null || ttft < bestTtft)) bestTtft = ttft; + if (tpot !== null && (bestTpot === null || tpot < bestTpot)) bestTpot = tpot; + } + return { + hardware: hw, + configCount: hwRows.length, + bestThroughputPerGpu: bestThroughput, + bestMedianTtft: bestTtft, + bestMedianTpot: bestTpot, + }; +} + +function buildJsonLd( + a: string, + b: string, + url: string, + summaryA: PairSummary, + summaryB: PairSummary, +) { + const entryFor = (key: string, summary: PairSummary, position: number) => { + const meta = HW_REGISTRY[key]; + const label = meta?.label ?? key.toUpperCase(); + const props: { name: string; value: string | number }[] = []; + if (meta) { + props.push({ name: 'Vendor', value: meta.vendor }); + props.push({ name: 'Architecture', value: meta.arch }); + props.push({ name: 'TDP (W)', value: meta.tdp }); + } + if (summary.bestThroughputPerGpu !== null) { + props.push({ + name: 'Best Throughput per GPU (tok/s)', + value: Number(summary.bestThroughputPerGpu.toFixed(2)), + }); + } + if (summary.bestMedianTtft !== null) { + props.push({ + name: 'Best Median TTFT (s)', + value: Number(summary.bestMedianTtft.toFixed(3)), + }); + } + if (summary.bestMedianTpot !== null) { + props.push({ + name: 'Best Median TPOT (s)', + value: Number(summary.bestMedianTpot.toFixed(4)), + }); + } + props.push({ name: 'Benchmark Configurations', value: summary.configCount }); + return { + '@type': 'ListItem', + position, + item: { + '@type': 'Product', + name: label, + brand: { '@type': 'Brand', name: meta?.vendor ?? 'Unknown' }, + category: 'GPU', + ...(props.length > 0 && { + additionalProperty: props.map((p) => ({ + '@type': 'PropertyValue', + name: p.name, + value: p.value, + })), + }), + }, + }; + }; + + return { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: `${compareDisplayLabel(a, b)} Inference Benchmark`, + description: `Head-to-head AI inference benchmark comparison of ${HW_REGISTRY[a]?.label ?? a} and ${HW_REGISTRY[b]?.label ?? b} across LLM workloads.`, + url, + itemListOrder: 'https://schema.org/ItemListOrderAscending', + numberOfItems: 2, + itemListElement: [entryFor(a, summaryA, 1), entryFor(b, summaryB, 2)], + }; +} + +export default async function ComparePage({ params }: Props) { + const { slug } = await params; + const pair = parseCompareSlug(slug); + if (!pair) notFound(); + + const canonical = canonicalCompareSlug(pair.a, pair.b); + if (canonical !== slug) { + redirect(`/compare/${canonical}`); + } + + const rows = await getCachedBenchmarks(DEFAULT_MODEL_DB_KEYS); + const summaryA = summarize(rows, pair.a); + const summaryB = summarize(rows, pair.b); + const { sequence: defaultSequence, precision: defaultPrecision } = pickPairDefaults( + rows, + pair.a, + pair.b, + ); + + const url = `${SITE_URL}/compare/${canonical}`; + const jsonLd = buildJsonLd(pair.a, pair.b, url, summaryA, summaryB); + const label = compareDisplayLabel(pair.a, pair.b); + const aMeta = HW_REGISTRY[pair.a]; + const bMeta = HW_REGISTRY[pair.b]; + + return ( + <> + + + + ); +} diff --git a/packages/app/src/app/compare/layout.tsx b/packages/app/src/app/compare/layout.tsx new file mode 100644 index 00000000..b6047a83 --- /dev/null +++ b/packages/app/src/app/compare/layout.tsx @@ -0,0 +1,18 @@ +import { UnofficialRunProvider } from '@/components/unofficial-run-provider'; + +/** + * Wraps `/compare/*` pages in UnofficialRunProvider but skips DashboardShell so + * we get a focused single-purpose page (no TabNav). The GlobalFilterProvider is + * mounted inside the page's client component instead — that lets the page seed + * its initial precision/sequence based on which combos actually have data for + * the GPU pair from the slug. + */ +export default function CompareLayout({ children }: { children: React.ReactNode }) { + return ( + +
+
{children}
+
+
+ ); +} diff --git a/packages/app/src/app/sitemap.ts b/packages/app/src/app/sitemap.ts index 8f7fcc84..2cd5b8c9 100644 --- a/packages/app/src/app/sitemap.ts +++ b/packages/app/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from 'next'; import { getAllPosts } from '@/lib/blog'; +import { allCanonicalComparePairs, toCompareSlug } from '@/lib/compare-slug'; import { SITE_URL as BASE_URL } from '@semianalysisai/inferencex-constants'; const TABS = [ @@ -52,5 +53,11 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'monthly' as const, priority: 0.7, })), + ...allCanonicalComparePairs().map(({ a, b }) => ({ + url: `${BASE_URL}/compare/${toCompareSlug(a, b)}`, + lastModified: now, + changeFrequency: 'daily' as const, + priority: 0.7, + })), ]; } diff --git a/packages/app/src/components/GlobalFilterContext.tsx b/packages/app/src/components/GlobalFilterContext.tsx index 2780a202..4861cdc7 100644 --- a/packages/app/src/components/GlobalFilterContext.tsx +++ b/packages/app/src/components/GlobalFilterContext.tsx @@ -108,7 +108,23 @@ function buildRunInfo(data: WorkflowInfoResponse): Record { return runs; } -export function GlobalFilterProvider({ children }: { children: ReactNode }) { +export function GlobalFilterProvider({ + children, + initialModel, + initialSequence, + initialPrecisions, +}: { + children: ReactNode; + /** + * Initial values used when no URL params are present. Lets per-route entry + * points (e.g. `/compare/[a]-vs-[b]`) seed sensible defaults derived from + * actual data — without these, every page falls back to FP4/8K-1K which + * has no data for older GPUs (Hopper, CDNA 3). + */ + initialModel?: Model; + initialSequence?: Sequence; + initialPrecisions?: string[]; +}) { const { hasUrlParam, getUrlParam, setUrlParams } = useUrlState(); // ── Core filter state ───────────────────────────────────────────────────── @@ -117,13 +133,13 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) { if (urlModel && Object.values(Model).includes(urlModel as Model)) { return urlModel as Model; } - return Model.DeepSeek_R1; + return initialModel ?? Model.DeepSeek_R1; }); const [selectedSequence, setSelectedSequence] = useState(() => { const urlSeq = getUrlParam('i_seq'); if (urlSeq && Object.values(Sequence).includes(urlSeq as Sequence)) return urlSeq as Sequence; - return Sequence.EightK_OneK; + return initialSequence ?? Sequence.EightK_OneK; }); const [selectedPrecisions, setSelectedPrecisionsRaw] = useState(() => { @@ -132,6 +148,10 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) { const precs = urlPrec.split(',').filter((p) => PRECISION_OPTIONS.includes(p as any)); if (precs.length > 0) return precs; } + if (initialPrecisions && initialPrecisions.length > 0) { + const valid = initialPrecisions.filter((p) => PRECISION_OPTIONS.includes(p as any)); + if (valid.length > 0) return valid; + } return [Precision.FP4]; }); const setSelectedPrecisions = useCallback((precisions: string[]) => { diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 5d30b8a8..02b9bdf0 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -59,11 +59,20 @@ export const InferenceContext = createContext | null>(() => { const v = getUrlParam('i_active'); - if (!v) return null; - const set = new Set(v.split(',').filter(Boolean)); - return set.size > 0 ? set : null; + if (v) { + const set = new Set(v.split(',').filter(Boolean)); + return set.size > 0 ? set : null; + } + if (initialActiveHwTypes && initialActiveHwTypes.length > 0) { + return new Set(initialActiveHwTypes); + } + return null; }); // --- MTP cross-engine conflict toast state --- @@ -537,7 +551,16 @@ export function InferenceProvider({ if (!pendingActiveHwTypes) return; if (pendingHwFilterRef.current) return; if (hwTypesWithData.size === 0) return; - let restored = new Set([...pendingActiveHwTypes].filter((k) => hwTypesWithData.has(k))); + // Match exact hwKeys (URL-restored) AND bare GPU prefixes (used by + // /compare/[a]-vs-[b] pages, which know the GPU key but not which framework + // configs exist for it). + const prefixes = [...pendingActiveHwTypes].filter((k) => !k.includes('_')); + let restored = new Set( + [...hwTypesWithData].filter( + (k) => + pendingActiveHwTypes.has(k) || prefixes.some((p) => k.startsWith(`${p}_`) || k === p), + ), + ); // Empty intersection (e.g. URL referenced GPUs no longer in availability, // or the URL only contained multi-family MTP keys that get sanitized away) // → fall back to the default "all available" set. MTP sanitization is then diff --git a/packages/app/src/lib/compare-slug.test.ts b/packages/app/src/lib/compare-slug.test.ts new file mode 100644 index 00000000..1d6ba70e --- /dev/null +++ b/packages/app/src/lib/compare-slug.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; + +import { + allCanonicalComparePairs, + canonicalCompareSlug, + compareDisplayLabel, + parseCompareSlug, + toCompareSlug, +} from './compare-slug'; + +describe('parseCompareSlug', () => { + it('parses a valid canonical slug', () => { + expect(parseCompareSlug('h100-vs-h200')).toEqual({ a: 'h100', b: 'h200' }); + }); + + it('parses a non-canonical slug (preserves order)', () => { + expect(parseCompareSlug('h200-vs-h100')).toEqual({ a: 'h200', b: 'h100' }); + }); + + it('handles uppercase input', () => { + expect(parseCompareSlug('H100-VS-H200')).toEqual({ a: 'h100', b: 'h200' }); + }); + + it('returns null for unknown GPU keys', () => { + expect(parseCompareSlug('a100-vs-h100')).toBeNull(); + }); + + it('returns null when both sides are the same GPU', () => { + expect(parseCompareSlug('h100-vs-h100')).toBeNull(); + }); + + it('returns null for malformed slugs', () => { + expect(parseCompareSlug('h100')).toBeNull(); + expect(parseCompareSlug('')).toBeNull(); + expect(parseCompareSlug('-vs-h100')).toBeNull(); + expect(parseCompareSlug('h100-vs-')).toBeNull(); + expect(parseCompareSlug('h100-and-h200')).toBeNull(); + }); + + it('handles AMD GPU keys', () => { + expect(parseCompareSlug('mi300x-vs-mi325x')).toEqual({ a: 'mi300x', b: 'mi325x' }); + }); +}); + +describe('toCompareSlug', () => { + it('joins with -vs-', () => { + expect(toCompareSlug('h100', 'h200')).toBe('h100-vs-h200'); + }); + + it('does not canonicalize order', () => { + expect(toCompareSlug('h200', 'h100')).toBe('h200-vs-h100'); + }); +}); + +describe('canonicalCompareSlug', () => { + it('returns alphabetical order regardless of input order', () => { + expect(canonicalCompareSlug('h200', 'h100')).toBe('h100-vs-h200'); + expect(canonicalCompareSlug('h100', 'h200')).toBe('h100-vs-h200'); + }); + + it('handles cross-vendor pairs', () => { + expect(canonicalCompareSlug('mi300x', 'h100')).toBe('h100-vs-mi300x'); + }); +}); + +describe('allCanonicalComparePairs', () => { + it('produces no duplicates and no self-pairs', () => { + const pairs = allCanonicalComparePairs(); + const seen = new Set(); + for (const { a, b } of pairs) { + expect(a).not.toBe(b); + expect(a < b).toBe(true); + const key = `${a}|${b}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + }); + + it('count = n*(n-1)/2', () => { + const pairs = allCanonicalComparePairs(); + // GPU_KEYS currently has 9 entries → 9*8/2 = 36 + // Test the formula rather than the literal count so this stays valid + // when new GPUs are added. + const seenKeys = new Set(); + for (const { a, b } of pairs) { + seenKeys.add(a); + seenKeys.add(b); + } + const n = seenKeys.size; + expect(pairs.length).toBe((n * (n - 1)) / 2); + }); +}); + +describe('compareDisplayLabel', () => { + it('uses HW_REGISTRY labels', () => { + expect(compareDisplayLabel('h100', 'h200')).toBe('H100 vs H200'); + expect(compareDisplayLabel('gb200', 'mi355x')).toBe('GB200 NVL72 vs MI355X'); + }); +}); diff --git a/packages/app/src/lib/compare-slug.ts b/packages/app/src/lib/compare-slug.ts new file mode 100644 index 00000000..31df3a33 --- /dev/null +++ b/packages/app/src/lib/compare-slug.ts @@ -0,0 +1,54 @@ +import { GPU_KEYS, HW_REGISTRY } from '@semianalysisai/inferencex-constants'; + +const SEPARATOR = '-vs-'; + +export interface ComparePair { + a: string; + b: string; +} + +/** Parse a slug like "h100-vs-h200" into { a, b }. Returns null for invalid input. */ +export function parseCompareSlug(slug: string): ComparePair | null { + if (!slug) return null; + const lower = slug.toLowerCase(); + const idx = lower.indexOf(SEPARATOR); + if (idx <= 0) return null; + const a = lower.slice(0, idx); + const b = lower.slice(idx + SEPARATOR.length); + if (!a || !b || a === b) return null; + if (!GPU_KEYS.has(a) || !GPU_KEYS.has(b)) return null; + return { a, b }; +} + +/** Build a slug from two GPU keys. Does NOT canonicalize order. */ +export function toCompareSlug(a: string, b: string): string { + return `${a}${SEPARATOR}${b}`; +} + +/** + * Canonical ordering = alphabetical by GPU key. Stable, easy to verify, matches + * how external links to these pages will look once search engines crawl them. + */ +export function canonicalCompareSlug(a: string, b: string): string { + const [first, second] = [a, b].toSorted(); + return toCompareSlug(first, second); +} + +/** All canonical (alphabetical, distinct) GPU pairs from HW_REGISTRY. */ +export function allCanonicalComparePairs(): ComparePair[] { + const keys = [...GPU_KEYS].toSorted(); + const pairs: ComparePair[] = []; + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + pairs.push({ a: keys[i], b: keys[j] }); + } + } + return pairs; +} + +/** Display label for a pair, e.g. "H100 vs H200" or "GB200 NVL72 vs MI355X". */ +export function compareDisplayLabel(a: string, b: string): string { + const aLabel = HW_REGISTRY[a]?.label ?? a.toUpperCase(); + const bLabel = HW_REGISTRY[b]?.label ?? b.toUpperCase(); + return `${aLabel} vs ${bLabel}`; +}