Skip to content

Commit 9a204ee

Browse files
feat(compare): filter index, sitemap, and SSG params by real benchmark coverage
Only emit /compare/<model>-<a>-vs-<b> URLs where both GPUs actually have benchmark rows for that model in Neon. Avoids 156 empty pair cards in the index and ~123 unreachable sitemap entries (288 -> 165). - compare-availability.ts: cached server-side helper using getAvailabilityData - compare/page.tsx: async, filtered modelsWithPairs, per-model card sections - sitemap.ts: async, only canonical (model, pair) combos with data - [slug]/page.tsx + opengraph-image.tsx: generateStaticParams now async + filtered - compare-slug.ts: kimi-k26 / glm-5-1 / minimax-m27 dbKeys now include both point releases so the canonical slug pulls existing data while staying forward-compatible with the newer DB key when it arrives Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18a9391 commit 9a204ee

6 files changed

Lines changed: 144 additions & 36 deletions

File tree

packages/app/src/app/compare/[slug]/opengraph-image.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,8 @@ import { join } from 'node:path';
77
import { notFound } from 'next/navigation';
88
import { ImageResponse } from 'next/og';
99

10-
import {
11-
allCanonicalCompareSlugs,
12-
canonicalCompareSlug,
13-
compareDisplayLabel,
14-
parseCompareSlug,
15-
} from '@/lib/compare-slug';
10+
import { getAllComparableCompareSlugs } from '@/lib/compare-availability';
11+
import { canonicalCompareSlug, compareDisplayLabel, parseCompareSlug } from '@/lib/compare-slug';
1612

1713
export const alt = 'GPU inference benchmark comparison';
1814
export const size = { width: 1200, height: 630 };
@@ -38,10 +34,12 @@ const TILE_GRID: ({ file: string; rotate?: number } | null)[] = [
3834
{ file: 'teal-organic.png', rotate: 180 },
3935
];
4036

41-
export function generateStaticParams() {
42-
return allCanonicalCompareSlugs().map(({ modelSlug, a, b }) => ({
43-
slug: canonicalCompareSlug(modelSlug, a, b),
44-
}));
37+
export async function generateStaticParams() {
38+
// Mirror the SSR page's static params — only emit (model, pair) combos
39+
// with benchmark data on both sides so we don't generate OG images for
40+
// empty pages.
41+
const slugs = await getAllComparableCompareSlugs();
42+
return slugs.map(({ modelSlug, a, b }) => ({ slug: canonicalCompareSlug(modelSlug, a, b) }));
4543
}
4644

4745
// Read once at module load; a missing asset must not 500 every OG route.

packages/app/src/app/compare/[slug]/page.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import { cachedQuery } from '@/lib/api-cache';
2020
import { rowToAggDataEntry } from '@/lib/benchmark-transform';
2121
import { loadFixture } from '@/lib/test-fixtures';
2222
import { getHardwareKey } from '@/lib/chart-utils';
23+
import { getAllComparableCompareSlugs } from '@/lib/compare-availability';
2324
import { pickPairDefaults } from '@/lib/compare-pair-defaults';
2425
import {
25-
allCanonicalCompareSlugs,
2626
canonicalCompareSlug,
2727
compareDisplayLabel,
2828
compareModelDisplayLabel,
@@ -71,10 +71,12 @@ const getCachedBenchmarks = cachedQuery(
7171
{ blobOnly: true },
7272
);
7373

74-
export function generateStaticParams() {
75-
return allCanonicalCompareSlugs().map(({ modelSlug, a, b }) => ({
76-
slug: canonicalCompareSlug(modelSlug, a, b),
77-
}));
74+
export async function generateStaticParams() {
75+
// Only enumerate (model, pair) combos with benchmark data on both sides.
76+
// Direct URL hits to non-enumerated combos still render via the dynamic
77+
// SSR path (with the empty-state fallback).
78+
const slugs = await getAllComparableCompareSlugs();
79+
return slugs.map(({ modelSlug, a, b }) => ({ slug: canonicalCompareSlug(modelSlug, a, b) }));
7880
}
7981

8082
export async function generateMetadata({ params }: Props): Promise<Metadata> {

packages/app/src/app/compare/page.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { HW_REGISTRY, SITE_NAME, SITE_URL } from '@semianalysisai/inferencex-con
55
import { ComparePairCardLink } from '@/components/compare/compare-pair-card-link';
66
import { JsonLd } from '@/components/json-ld';
77
import { Card } from '@/components/ui/card';
8+
import { getComparablePairsByModelSlug } from '@/lib/compare-availability';
89
import {
9-
allCanonicalComparePairs,
1010
canonicalCompareSlug,
1111
compareDisplayLabel,
12+
type ComparePair,
1213
COMPARE_MODEL_SLUGS,
1314
type CompareModelSlug,
1415
} from '@/lib/compare-slug';
1516

17+
export const dynamic = 'force-dynamic';
18+
1619
const DESCRIPTION =
1720
'Browse head-to-head GPU inference benchmark comparisons across every model and hardware pair we test. Latency, throughput, and cost for DeepSeek R1, Kimi K2.6, GLM 5.1, Qwen 3.5, and more.';
1821

@@ -39,14 +42,15 @@ interface VendorGroup {
3942
pairs: { a: string; b: string; slug: string; label: string }[];
4043
}
4144

42-
function groupPairsByVendorForModel(model: CompareModelSlug): VendorGroup[] {
43-
const all = allCanonicalComparePairs();
44-
45+
function groupPairsByVendorForModel(
46+
model: CompareModelSlug,
47+
comparablePairs: ComparePair[],
48+
): VendorGroup[] {
4549
const nvidia: VendorGroup['pairs'] = [];
4650
const amd: VendorGroup['pairs'] = [];
4751
const cross: VendorGroup['pairs'] = [];
4852

49-
for (const { a, b } of all) {
53+
for (const { a, b } of comparablePairs) {
5054
const entry = {
5155
a,
5256
b,
@@ -95,9 +99,17 @@ const jsonLd = {
9599
url: `${SITE_URL}/compare`,
96100
};
97101

98-
export default function CompareIndexPage() {
99-
const pairsPerModel = allCanonicalComparePairs().length;
100-
const totalUrls = COMPARE_MODEL_SLUGS.length * pairsPerModel;
102+
export default async function CompareIndexPage() {
103+
// Server-side filter: only show (model, pair) combinations where both GPUs
104+
// have benchmark data for that model. Avoids cards that would link to an
105+
// empty-state page. The page-level handler at /compare/[slug] still renders
106+
// the empty-state for direct URL hits, so this is purely a navigation
107+
// hygiene concern.
108+
const comparablePairsByModel = await getComparablePairsByModelSlug();
109+
const totalUrls = [...comparablePairsByModel.values()].reduce((s, p) => s + p.length, 0);
110+
const modelsWithPairs = COMPARE_MODEL_SLUGS.filter(
111+
(m) => (comparablePairsByModel.get(m.slug)?.length ?? 0) > 0,
112+
);
101113

102114
return (
103115
<>
@@ -106,23 +118,24 @@ export default function CompareIndexPage() {
106118
<Card>
107119
<h1 className="text-2xl lg:text-4xl font-bold tracking-tight">GPU Comparisons</h1>
108120
<p className="mt-3 text-base lg:text-lg text-muted-foreground max-w-3xl">
109-
{totalUrls.toLocaleString()} head-to-head inference benchmark comparisons —{' '}
110-
{pairsPerModel} GPU pairs × {COMPARE_MODEL_SLUGS.length} models. Each page includes
111-
interactive charts for latency, throughput, and cost metrics, plus an interpolated
112-
comparison table.
121+
{totalUrls.toLocaleString()} head-to-head inference benchmark comparisons across{' '}
122+
{modelsWithPairs.length} models. Each page includes interactive charts for latency,
123+
throughput, and cost metrics, plus an interpolated comparison table.
113124
</p>
114125
</Card>
115126
</section>
116127

117-
{COMPARE_MODEL_SLUGS.map((model) => {
118-
const groups = groupPairsByVendorForModel(model);
128+
{modelsWithPairs.map((model) => {
129+
const pairs = comparablePairsByModel.get(model.slug) ?? [];
130+
const groups = groupPairsByVendorForModel(model, pairs);
119131
return (
120132
<section key={model.slug} id={model.slug}>
121133
<Card className="flex flex-col gap-4">
122134
<div>
123135
<h2 className="text-xl lg:text-2xl font-bold tracking-tight">{model.label}</h2>
124136
<p className="text-sm text-muted-foreground mt-1">
125-
Compare any GPU pair on {model.label}.
137+
{pairs.length} GPU pair{pairs.length === 1 ? '' : 's'} with benchmark data on{' '}
138+
{model.label}.
126139
</p>
127140
</div>
128141
{groups.map((group) => (

packages/app/src/app/sitemap.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { MetadataRoute } from 'next';
22

33
import { getAllPosts } from '@/lib/blog';
4-
import { allCanonicalCompareSlugs, canonicalCompareSlug } from '@/lib/compare-slug';
4+
import { getAllComparableCompareSlugs } from '@/lib/compare-availability';
5+
import { canonicalCompareSlug } from '@/lib/compare-slug';
56
import { SITE_URL as BASE_URL } from '@semianalysisai/inferencex-constants';
67

78
const TABS = [
@@ -13,8 +14,11 @@ const TABS = [
1314
'gpu-metrics',
1415
] as const;
1516

16-
export default function sitemap(): MetadataRoute.Sitemap {
17+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
1718
const now = new Date().toISOString();
19+
// Only emit (model, pair) URLs that have benchmark data on both sides —
20+
// avoids polluting the sitemap with empty pages that hurt crawl budget.
21+
const compareSlugs = await getAllComparableCompareSlugs();
1822

1923
return [
2024
{
@@ -59,7 +63,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
5963
changeFrequency: 'monthly' as const,
6064
priority: 0.7,
6165
})),
62-
...allCanonicalCompareSlugs().map(({ modelSlug, a, b }) => ({
66+
...compareSlugs.map(({ modelSlug, a, b }) => ({
6367
url: `${BASE_URL}/compare/${canonicalCompareSlug(modelSlug, a, b)}`,
6468
lastModified: now,
6569
changeFrequency: 'daily' as const,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Server-side helper: for each canonical compare model slug, returns the list
3+
* of GPU pairs that actually have benchmark data on both sides.
4+
*
5+
* Used by the /compare index, the sitemap, and the [slug] generateStaticParams
6+
* to avoid emitting cards / URLs for (model, pair) combinations where one or
7+
* both GPUs have no rows for that model. A pair is "comparable" if both GPU
8+
* keys appear in the availability table for any of the model's dbKeys.
9+
*
10+
* The page-level handler at /compare/[slug] still renders the empty-state
11+
* fallback if a user reaches a filtered-out URL directly, so this filtering
12+
* is purely an indexing/navigation concern, not a hard gate.
13+
*/
14+
15+
import { FIXTURES_MODE, JSON_MODE, getDb } from '@semianalysisai/inferencex-db/connection';
16+
import * as jsonProvider from '@semianalysisai/inferencex-db/json-provider';
17+
import {
18+
type AvailabilityRow,
19+
getAvailabilityData,
20+
} from '@semianalysisai/inferencex-db/queries/workflow-info';
21+
22+
import { cachedQuery } from '@/lib/api-cache';
23+
import { loadFixture } from '@/lib/test-fixtures';
24+
import {
25+
allCanonicalComparePairs,
26+
type ComparePair,
27+
COMPARE_MODEL_SLUGS,
28+
} from '@/lib/compare-slug';
29+
30+
const getCachedAvailability = cachedQuery(() => {
31+
if (FIXTURES_MODE) return Promise.resolve(loadFixture<AvailabilityRow[]>('availability'));
32+
if (JSON_MODE) return Promise.resolve(jsonProvider.getAvailabilityData());
33+
return getAvailabilityData(getDb());
34+
}, 'availability');
35+
36+
/** Map from canonical model slug → set of GPU keys that have benchmark rows
37+
* for any of the model's dbKeys. */
38+
async function getHardwareByModelSlug(): Promise<Map<string, Set<string>>> {
39+
const rows = await getCachedAvailability();
40+
// Build a quick dbKey → modelSlug index so each row maps to at most one slug.
41+
const dbKeyToSlug = new Map<string, string>();
42+
for (const m of COMPARE_MODEL_SLUGS) {
43+
for (const dbKey of m.dbKeys) dbKeyToSlug.set(dbKey, m.slug);
44+
}
45+
const out = new Map<string, Set<string>>();
46+
for (const m of COMPARE_MODEL_SLUGS) out.set(m.slug, new Set());
47+
for (const row of rows) {
48+
const slug = dbKeyToSlug.get(row.model);
49+
if (!slug) continue;
50+
out.get(slug)!.add(row.hardware);
51+
}
52+
return out;
53+
}
54+
55+
/** For each canonical model slug, return the GPU pairs where both GPUs have
56+
* benchmark data for that model. Pairs are alphabetical (a < b), matching
57+
* the canonical slug ordering. Returns empty list for models with fewer than
58+
* 2 GPUs that have data. */
59+
export async function getComparablePairsByModelSlug(): Promise<Map<string, ComparePair[]>> {
60+
const hwByModel = await getHardwareByModelSlug();
61+
const allPairs = allCanonicalComparePairs();
62+
const out = new Map<string, ComparePair[]>();
63+
for (const m of COMPARE_MODEL_SLUGS) {
64+
const hw = hwByModel.get(m.slug) ?? new Set();
65+
out.set(
66+
m.slug,
67+
allPairs.filter((p) => hw.has(p.a) && hw.has(p.b)),
68+
);
69+
}
70+
return out;
71+
}
72+
73+
/** Flattened cross-product of (model, comparable pair). Used by the sitemap
74+
* and by `generateStaticParams` so neither emits URLs for empty pairs. */
75+
export async function getAllComparableCompareSlugs(): Promise<
76+
{ modelSlug: string; a: string; b: string }[]
77+
> {
78+
const byModel = await getComparablePairsByModelSlug();
79+
const out: { modelSlug: string; a: string; b: string }[] = [];
80+
for (const [modelSlug, pairs] of byModel.entries()) {
81+
for (const p of pairs) out.push({ modelSlug, a: p.a, b: p.b });
82+
}
83+
return out;
84+
}

packages/app/src/lib/compare-slug.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [
4646
{
4747
slug: 'kimi-k26',
4848
displayName: 'Kimi-K2.5',
49-
dbKeys: ['kimik2.6'],
49+
// Both K2.5 and K2.6 point releases share an architecture (mirroring
50+
// DISPLAY_MODEL_TO_DB in packages/constants/src/models.ts). The slug uses
51+
// the newer version name; the dbKey list pulls data from both DB buckets
52+
// so the slug is populated today and stays populated when K2.6 data lands.
53+
dbKeys: ['kimik2.6', 'kimik2.5'],
5054
label: 'Kimi K2.6',
5155
},
5256
{
@@ -58,13 +62,16 @@ export const COMPARE_MODEL_SLUGS: CompareModelSlug[] = [
5862
{
5963
slug: 'glm-5-1',
6064
displayName: 'GLM-5',
61-
dbKeys: ['glm5.1'],
65+
// GLM-5.0 and GLM-5.1 share an architecture per the model card; the slug
66+
// uses the newer version name but the data pull covers both DB buckets.
67+
dbKeys: ['glm5.1', 'glm5'],
6268
label: 'GLM 5.1',
6369
},
6470
{
6571
slug: 'minimax-m27',
6672
displayName: 'MiniMax-M2.5',
67-
dbKeys: ['minimaxm2.7'],
73+
// Same point-release grouping pattern as Kimi and GLM.
74+
dbKeys: ['minimaxm2.7', 'minimaxm2.5'],
6875
label: 'MiniMax M2.7',
6976
},
7077
{

0 commit comments

Comments
 (0)