From 2b2081c19d463310addad2d75d966b08ef7132bf Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 18:33:11 -0700 Subject: [PATCH 1/7] fix(compare-per-dollar): clearer axis labels and curve-extension distinction in PNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexed performance-per-dollar PNG had two readability problems: 1. Y-axis labels were inconsistent ($0.000 / $9.01 / $18.0 / $27.0) because the per-tick formatter picked precision from each tick's magnitude. Switched to a 1/2/5-step nice-axis with one precision chosen from the step size, applied uniformly to every tick. 2. The curve continued past the rightmost labeled dot with no visual distinction, leaving readers unsure whether the trailing values were real data or extrapolation. The curve is now split at the matched- interactivity bounds: the segment between the labeled dots stays solid, and the portion extending toward each SKU's operating envelope edge renders as dashed, semi-translucent. Faint italic endpoint labels mark the actual x-range, and a one-line caption under the axis explains the convention. `computeCompareImageRows` now accepts an optional `includeTargets` array so the dot targets are guaranteed to be exact samples in the curve — the partition into solid / dashed segments connects cleanly at the boundary without per-segment boundary interpolation in the renderer. --- .../performance-per-dollar.png/route.tsx | 198 ++++++++++++++---- packages/app/src/lib/compare-ssr.test.ts | 130 ++++++++++++ packages/app/src/lib/compare-ssr.ts | 42 ++-- 3 files changed, 320 insertions(+), 50 deletions(-) create mode 100644 packages/app/src/lib/compare-ssr.test.ts diff --git a/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx b/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx index 5a303ac2..a59cae64 100644 --- a/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx +++ b/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx @@ -8,6 +8,7 @@ import { computeCompareImageRows, computeCompareTableData, getCachedBenchmarks, + type SsrInterpolatedRow, } from '@/lib/compare-ssr'; export const dynamic = 'force-dynamic'; @@ -20,12 +21,13 @@ const SIZE = { height: DISPLAY_SIZE.height * IMAGE_SCALE, }; const CHART_FRAME = { left: 0, top: 18, width: 746, height: 382 }; -const CHART = { left: 96, top: 42, width: 630, height: 272 }; +const CHART = { left: 96, top: 42, width: 630, height: 260 }; const COLORS = { background: '#0d1117', panel: '#121a23', border: '#23303d', muted: '#9aa7b5', + faint: '#5f6e7d', text: '#f3f7fb', a: '#38d9a9', b: '#f7b041', @@ -38,12 +40,54 @@ interface Point { y: number; } +interface TargetedPoint extends Point { + target: number; +} + function money(value: number): string { if (value >= 10) return `$${value.toFixed(1)}`; if (value >= 1) return `$${value.toFixed(2)}`; return `$${value.toFixed(3)}`; } +/** Decimals chosen from the tick step so every label in the axis prints with + * the same precision (no $0.000/$9.01/$18.0 mix). */ +function decimalsForStep(step: number): number { + if (step >= 1) return 0; + return Math.max(0, Math.ceil(-Math.log10(step))); +} + +function moneyForStep(value: number, step: number): string { + return `$${value.toFixed(decimalsForStep(step))}`; +} + +/** "Nice" step in the 1/2/5 × 10ⁿ family, the same convention d3 uses. */ +function niceStep(span: number, targetCount: number): number { + const rawStep = span / Math.max(1, targetCount - 1); + const mag = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / mag; + if (normalized < 1.5) return mag; + if (normalized < 3) return 2 * mag; + if (normalized < 7) return 5 * mag; + return 10 * mag; +} + +function niceAxis( + min: number, + max: number, + targetCount = 5, +): { min: number; max: number; step: number; ticks: number[] } { + if (max <= min) return { min, max: min + 1, step: 1, ticks: [min] }; + const step = niceStep(max - min, targetCount); + const niceMin = Math.floor(min / step) * step; + const niceMax = Math.ceil(max / step) * step; + const ticks: number[] = []; + for (let t = niceMin; t <= niceMax + step * 1e-6; t += step) { + ticks.push(Number(t.toFixed(10))); + } + return { min: niceMin, max: niceMax, step, ticks }; +} + function pointsPath(points: Point[]): string { return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); } @@ -75,6 +119,7 @@ export async function GET( sequence, precision, interactivityRange, + plottedRows.map((r) => r.target), ).filter((row) => row.a || row.b); const curveRows = imageRows.length > 0 ? imageRows : plottedRows; @@ -85,11 +130,16 @@ export async function GET( .filter((cost): cost is number => typeof cost === 'number' && Number.isFinite(cost)); const costMin = costs.length > 0 ? Math.min(...costs) : 0; const costMax = costs.length > 0 ? Math.max(...costs) : 1; - const costPadding = Math.max((costMax - costMin) * 0.18, costMax * 0.08, 0.02); - const yMin = Math.max(0, costMin - costPadding); - const yMax = costMax + costPadding; + const yAxis = niceAxis(Math.min(0, costMin), costMax); + const yMin = yAxis.min; + const yMax = yAxis.max; + const yStep = yAxis.step; const xMin = curveRows.at(0)?.target ?? 0; const xMax = curveRows.at(-1)?.target ?? 100; + const matchedMin = plottedRows.at(0)?.target ?? xMin; + const matchedMax = plottedRows.at(-1)?.target ?? xMax; + const hasLeftExtension = matchedMin - xMin >= 0.5; + const hasRightExtension = xMax - matchedMax >= 0.5; const scaleX = (value: number) => CHART.left + (xMax === xMin ? CHART.width / 2 : ((value - xMin) / (xMax - xMin)) * CHART.width); const scaleY = (value: number) => @@ -97,20 +147,47 @@ export async function GET( CHART.height - (yMax === yMin ? CHART.height / 2 : ((value - yMin) / (yMax - yMin)) * CHART.height); - const aPoints = curveRows - .filter((row) => row.a) - .map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) })); - const bPoints = curveRows - .filter((row) => row.b) - .map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) })); + function buildSeriesPoints(getCost: (row: SsrInterpolatedRow) => number | null): TargetedPoint[] { + return curveRows + .map((row) => ({ target: row.target, cost: getCost(row) })) + .filter((p): p is { target: number; cost: number } => p.cost !== null) + .map((p) => ({ x: scaleX(p.target), y: scaleY(p.cost), target: p.target })); + } + + function splitByMatchRange(points: TargetedPoint[]) { + return { + matched: points.filter((p) => p.target >= matchedMin && p.target <= matchedMax), + leftExt: points.filter((p) => p.target <= matchedMin), + rightExt: points.filter((p) => p.target >= matchedMax), + }; + } + + const aSeries = splitByMatchRange(buildSeriesPoints((r) => r.a?.cost ?? null)); + const bSeries = splitByMatchRange(buildSeriesPoints((r) => r.b?.cost ?? null)); const aHighlightPoints = plottedRows .filter((row) => row.a) .map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) })); const bHighlightPoints = plottedRows .filter((row) => row.b) .map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) })); - const yTicks = Array.from({ length: 4 }, (_, index) => yMin + ((yMax - yMin) * index) / 3); const workload = [sequence, precision?.toUpperCase()].filter(Boolean).join(' / '); + const showRangeEndpoints = hasLeftExtension || hasRightExtension; + + function renderSeriesPath(points: Point[], stroke: string, dashed: boolean) { + if (points.length < 2) return null; + return ( + + ); + } return new ImageResponse(
- {yTicks.map((tick) => { + {yAxis.ticks.map((tick) => { const y = scaleY(tick); return ( ); })} - {aPoints.length > 1 && ( - - )} - {bPoints.length > 1 && ( - - )} + {plottedRows.map((row) => { + const x = scaleX(row.target); + return ( + + ); + })} + {renderSeriesPath(aSeries.leftExt, COLORS.a, true)} + {renderSeriesPath(aSeries.rightExt, COLORS.a, true)} + {renderSeriesPath(aSeries.matched, COLORS.a, false)} + {renderSeriesPath(bSeries.leftExt, COLORS.b, true)} + {renderSeriesPath(bSeries.rightExt, COLORS.b, true)} + {renderSeriesPath(bSeries.matched, COLORS.b, false)} {aHighlightPoints.map((point, index) => ( ))} - {yTicks.map((tick) => ( + {yAxis.ticks.map((tick) => (
- {money(tick)} + {moneyForStep(tick, yStep)}
))} {plottedRows.map((row) => ( @@ -277,12 +354,46 @@ export async function GET( {row.target}
))} + {showRangeEndpoints && hasLeftExtension && ( +
+ {Math.round(xMin)} +
+ )} + {showRangeEndpoints && hasRightExtension && ( +
+ {Math.round(xMax)} +
+ )}
Interactivity (tok/s/user)
+ {showRangeEndpoints && ( +
+ Dashed segments extend to each SKU's operating envelope, where cost rises steeply +
+ )}
= {}): BenchmarkRow { + return { + hardware: 'h200', + framework: 'sglang', + model: 'dsr1', + precision: 'fp8', + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 8, + prefill_ep: 1, + prefill_dp_attention: false, + prefill_num_workers: 0, + decode_tp: 8, + decode_ep: 1, + decode_dp_attention: false, + decode_num_workers: 0, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: 1024, + osl: 1024, + conc: 128, + image: null, + metrics: { tput_per_gpu: 100, median_intvty: 30 }, + date: '2026-03-01', + run_url: null, + ...overrides, + }; +} + +function pairRows(): BenchmarkRow[] { + return [ + stubRow({ hardware: 'h200', conc: 16, metrics: { tput_per_gpu: 800, median_intvty: 10 } }), + stubRow({ hardware: 'h200', conc: 32, metrics: { tput_per_gpu: 600, median_intvty: 20 } }), + stubRow({ hardware: 'h200', conc: 64, metrics: { tput_per_gpu: 400, median_intvty: 30 } }), + stubRow({ hardware: 'h200', conc: 128, metrics: { tput_per_gpu: 200, median_intvty: 40 } }), + stubRow({ hardware: 'b200', conc: 16, metrics: { tput_per_gpu: 900, median_intvty: 10 } }), + stubRow({ hardware: 'b200', conc: 32, metrics: { tput_per_gpu: 700, median_intvty: 20 } }), + stubRow({ hardware: 'b200', conc: 64, metrics: { tput_per_gpu: 500, median_intvty: 30 } }), + stubRow({ hardware: 'b200', conc: 128, metrics: { tput_per_gpu: 250, median_intvty: 40 } }), + ]; +} + +describe('computeCompareImageRows', () => { + const range = { min: 10, max: 40 }; + + it('returns 17 evenly-spaced samples when no includeTargets are passed', () => { + const rows = computeCompareImageRows(pairRows(), 'h200', 'b200', '1k/1k', 'fp8', range); + expect(rows.length).toBe(17); + expect(rows.at(0)?.target).toBe(10); + expect(rows.at(-1)?.target).toBe(40); + }); + + it('inserts includeTargets as exact samples without dropping the even grid', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [17, 25, 33], + ); + const targets = rows.map((r) => r.target); + expect(targets).toContain(17); + expect(targets).toContain(25); + expect(targets).toContain(33); + // Endpoints from the even grid still present. + expect(targets).toContain(10); + expect(targets).toContain(40); + // Strictly increasing — required so curve-partition by target works. + for (let i = 1; i < targets.length; i++) { + expect(targets[i]).toBeGreaterThan(targets[i - 1]); + } + }); + + it('drops includeTargets that fall outside the interactivity range', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [-5, 9, 41, 1000], + ); + const targets = rows.map((r) => r.target); + expect(targets).not.toContain(-5); + expect(targets).not.toContain(9); + expect(targets).not.toContain(41); + expect(targets).not.toContain(1000); + // The even grid is unaffected when every includeTarget is rejected. + expect(rows.length).toBe(17); + }); + + it('dedupes includeTargets that already coincide with an even-grid sample', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [10, 40], + ); + expect(rows.length).toBe(17); + expect(rows.filter((r) => r.target === 10).length).toBe(1); + expect(rows.filter((r) => r.target === 40).length).toBe(1); + }); + + it('returns an empty array when the interactivity range is degenerate', () => { + expect( + computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + { min: 20, max: 20 }, + [20], + ), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 829a2985..391d6ca9 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -260,7 +260,12 @@ export function computeCompareTableData( /** Sample the same interpolated cost curve used for the comparison table for * server-rendered image assets. More samples make the static PNG read like the - * interactive roofline without requiring browser-based chart capture. */ + * interactive roofline without requiring browser-based chart capture. + * + * `includeTargets` are merged into the even sampling grid so callers can + * guarantee the curve has exact samples at specific targets (e.g. the plotted + * comparison dots), making it safe to partition the curve into solid / + * dashed segments without interpolation gaps at the boundary. */ export function computeCompareImageRows( rows: BenchmarkRow[], a: string, @@ -268,6 +273,7 @@ export function computeCompareImageRows( sequence: string | null, precision: string | null, interactivityRange: { min: number; max: number }, + includeTargets: number[] = [], ): SsrInterpolatedRow[] { if (!sequence || !precision || interactivityRange.max <= interactivityRange.min) return []; @@ -280,20 +286,26 @@ export function computeCompareImageRows( const sampleCount = 17; const span = interactivityRange.max - interactivityRange.min; - return Array.from({ length: sampleCount }, (_, index) => { - const target = interactivityRange.min + (span * index) / (sampleCount - 1); - return { - target, - a: - pointsA.length > 0 - ? interpolateForGPU(pointsA, target, 'interactivity_to_throughput', 'costh') - : null, - b: - pointsB.length > 0 - ? interpolateForGPU(pointsB, target, 'interactivity_to_throughput', 'costh') - : null, - }; - }); + const evenTargets = Array.from( + { length: sampleCount }, + (_, index) => interactivityRange.min + (span * index) / (sampleCount - 1), + ); + const clamped = includeTargets.filter( + (t) => t >= interactivityRange.min && t <= interactivityRange.max, + ); + const targets = [...new Set([...evenTargets, ...clamped])].toSorted((x, y) => x - y); + + return targets.map((target) => ({ + target, + a: + pointsA.length > 0 + ? interpolateForGPU(pointsA, target, 'interactivity_to_throughput', 'costh') + : null, + b: + pointsB.length > 0 + ? interpolateForGPU(pointsB, target, 'interactivity_to_throughput', 'costh') + : null, + })); } // --------------------------------------------------------------------------- From 5fc5c04814c1b7a91e60a84dd6ce91dc0e7347ec Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 19:45:01 -0700 Subject: [PATCH 2/7] fix(compare-per-dollar): render PNG natively at 2400x1350 for sharp glyphs The wrapper used `transform: scale(2)` to upsample a 1200x675 layout into the 2400x1350 ImageResponse. Satori rasterizes text glyphs at the source size before applying CSS transforms, so the bitmap got upscaled and the chart came out blurry. Multiplying every pixel constant by R=2 and rendering the wrapper at full size keeps strokes and text as native high-res vectors. --- .../performance-per-dollar.png/route.tsx | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx b/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx index a59cae64..775f287c 100644 --- a/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx +++ b/packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx @@ -14,14 +14,13 @@ import { export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; -const DISPLAY_SIZE = { width: 1200, height: 675 }; -const IMAGE_SCALE = 2; -const SIZE = { - width: DISPLAY_SIZE.width * IMAGE_SCALE, - height: DISPLAY_SIZE.height * IMAGE_SCALE, -}; -const CHART_FRAME = { left: 0, top: 18, width: 746, height: 382 }; -const CHART = { left: 96, top: 42, width: 630, height: 260 }; +// Render natively at high-DPI. CSS `transform: scale()` causes Satori to rasterize +// SVG/text at the source size and bitmap-upsample, which produces a blurry chart. +// Multiplying every pixel value by R keeps glyphs and strokes as vectors at full res. +const R = 2; +const SIZE = { width: 1200 * R, height: 675 * R }; +const CHART_FRAME = { left: 0, top: 18 * R, width: 746 * R, height: 382 * R }; +const CHART = { left: 96 * R, top: 42 * R, width: 630 * R, height: 260 * R }; const COLORS = { background: '#0d1117', panel: '#121a23', @@ -172,6 +171,8 @@ export async function GET( .map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) })); const workload = [sequence, precision?.toUpperCase()].filter(Boolean).join(' / '); const showRangeEndpoints = hasLeftExtension || hasRightExtension; + const svgWidth = 760 * R; + const svgHeight = 406 * R; function renderSeriesPath(points: Point[], stroke: string, dashed: boolean) { if (points.length < 2) return null; @@ -180,9 +181,9 @@ export async function GET( d={pointsPath(points)} fill="none" stroke={stroke} - strokeWidth="9" + strokeWidth={9 * R} strokeOpacity={dashed ? 0.55 : 1} - strokeDasharray={dashed ? '14 10' : undefined} + strokeDasharray={dashed ? `${14 * R} ${10 * R}` : undefined} strokeLinejoin="round" strokeLinecap="round" /> @@ -194,22 +195,20 @@ export async function GET( style={{ display: 'flex', flexDirection: 'column', - width: DISPLAY_SIZE.width, - height: DISPLAY_SIZE.height, - padding: '38px 46px 26px', + width: SIZE.width, + height: SIZE.height, + padding: `${38 * R}px ${46 * R}px ${26 * R}px`, background: COLORS.background, color: COLORS.text, fontFamily: 'Arial, sans-serif', - transform: `scale(${IMAGE_SCALE})`, - transformOrigin: 'top left', }} >
-
+
InferenceX Performance per Dollar
-
{parsed.model.label}
-
+
+ {parsed.model.label} +
+
{aLabel} vs {bLabel} | Cost per Million Tokens
@@ -228,29 +229,31 @@ export async function GET( display: 'flex', flexDirection: 'column', alignItems: 'flex-end', - border: `1px solid ${COLORS.border}`, - borderRadius: 12, - padding: '13px 17px', + border: `${R}px solid ${COLORS.border}`, + borderRadius: 12 * R, + padding: `${13 * R}px ${17 * R}px`, background: COLORS.panel, - gap: 5, + gap: 5 * R, }} > -
DEFAULT WORKLOAD
-
+
+ DEFAULT WORKLOAD +
+
{workload || 'Default comparison'}
-
+
Lower cost is better
-
-
+
+
@@ -272,7 +275,7 @@ export async function GET( y1={y} y2={y} stroke={COLORS.grid} - strokeWidth="2" + strokeWidth={2 * R} /> ); })} @@ -284,9 +287,9 @@ export async function GET( x1={x} x2={x} y1={CHART.top + CHART.height} - y2={CHART.top + CHART.height + 6} + y2={CHART.top + CHART.height + 6 * R} stroke={COLORS.muted} - strokeWidth="2" + strokeWidth={2 * R} /> ); })} @@ -301,10 +304,10 @@ export async function GET( key={`a-${index}`} cx={point.x} cy={point.y} - r="10" + r={10 * R} fill={COLORS.a} stroke={COLORS.background} - strokeWidth="4" + strokeWidth={4 * R} /> ))} {bHighlightPoints.map((point, index) => ( @@ -312,10 +315,10 @@ export async function GET( key={`b-${index}`} cx={point.x} cy={point.y} - r="10" + r={10 * R} fill={COLORS.b} stroke={COLORS.background} - strokeWidth="4" + strokeWidth={4 * R} /> ))} @@ -325,12 +328,12 @@ export async function GET( style={{ display: 'flex', position: 'absolute', - left: CHART_FRAME.left + 14, - top: scaleY(tick) - 9, - width: CHART.left - CHART_FRAME.left - 28, + left: CHART_FRAME.left + 14 * R, + top: scaleY(tick) - 9 * R, + width: CHART.left - CHART_FRAME.left - 28 * R, justifyContent: 'flex-end', color: COLORS.muted, - fontSize: 15, + fontSize: 15 * R, }} > {moneyForStep(tick, yStep)} @@ -342,12 +345,12 @@ export async function GET( style={{ display: 'flex', position: 'absolute', - left: scaleX(row.target) - 32, - top: CHART.top + CHART.height + 15, - width: 64, + left: scaleX(row.target) - 32 * R, + top: CHART.top + CHART.height + 15 * R, + width: 64 * R, justifyContent: 'center', color: COLORS.muted, - fontSize: 16, + fontSize: 16 * R, fontWeight: 600, }} > @@ -359,12 +362,12 @@ export async function GET( style={{ display: 'flex', position: 'absolute', - left: scaleX(xMin) - 4, - top: CHART.top + CHART.height + 16, - width: 56, + left: scaleX(xMin) - 4 * R, + top: CHART.top + CHART.height + 16 * R, + width: 56 * R, justifyContent: 'flex-start', color: COLORS.faint, - fontSize: 13, + fontSize: 13 * R, fontStyle: 'italic', }} > @@ -376,12 +379,12 @@ export async function GET( style={{ display: 'flex', position: 'absolute', - left: scaleX(xMax) - 52, - top: CHART.top + CHART.height + 16, - width: 56, + left: scaleX(xMax) - 52 * R, + top: CHART.top + CHART.height + 16 * R, + width: 56 * R, justifyContent: 'flex-end', color: COLORS.faint, - fontSize: 13, + fontSize: 13 * R, fontStyle: 'italic', }} > @@ -393,11 +396,11 @@ export async function GET( display: 'flex', position: 'absolute', left: CHART.left, - top: CHART.top + CHART.height + 38, + top: CHART.top + CHART.height + 38 * R, width: CHART.width, justifyContent: 'center', color: COLORS.muted, - fontSize: 15, + fontSize: 15 * R, fontWeight: 600, }} > @@ -409,11 +412,11 @@ export async function GET( display: 'flex', position: 'absolute', left: CHART.left, - top: CHART.top + CHART.height + 62, + top: CHART.top + CHART.height + 62 * R, width: CHART.width, justifyContent: 'center', color: COLORS.faint, - fontSize: 13, + fontSize: 13 * R, fontStyle: 'italic', }} > @@ -427,33 +430,33 @@ export async function GET( display: 'flex', flex: 1, flexDirection: 'column', - gap: 17, - paddingTop: 18, + gap: 17 * R, + paddingTop: 18 * R, }} > -
+
Matched Interactivity
-
- +
+ {aLabel} - + @@ -467,17 +470,17 @@ export async function GET( style={{ display: 'flex', flexDirection: 'column', - gap: 6, - border: `1px solid ${COLORS.border}`, - borderRadius: 10, - padding: '11px 13px', + gap: 6 * R, + border: `${R}px solid ${COLORS.border}`, + borderRadius: 10 * R, + padding: `${11 * R}px ${13 * R}px`, background: COLORS.panel, }} > -
+
{row.target} tok/s/user
-
+
{row.a ? money(row.a.cost) : 'N/A'} @@ -488,7 +491,7 @@ export async function GET(
)) ) : ( -
+
No matched cost data available.
)} @@ -499,8 +502,8 @@ export async function GET( style={{ display: 'flex', justifyContent: 'space-between', - paddingTop: 9, - fontSize: 15, + paddingTop: 9 * R, + fontSize: 15 * R, color: COLORS.muted, }} > From 43fdcc92f90fee22f91a45a2e0cda338822cf5ce Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 20:42:51 -0700 Subject: [PATCH 3/7] fix(compare): use Thing instead of Product in JSON-LD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's Product rich-result validation requires offers/review/aggregateRating, none of which apply to benchmark subject GPUs. Switch the per-GPU item type to schema.org Thing — vendor/category move into additionalProperty so no crawl info is lost — and the validator warning goes away. --- packages/app/src/lib/compare-ssr.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 391d6ca9..75702f9b 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -75,7 +75,7 @@ export function pickString(value: string | string[] | undefined): string | undef } // --------------------------------------------------------------------------- -// Pair summary (JSON-LD Product additionalProperty) +// Pair summary (JSON-LD additionalProperty) // --------------------------------------------------------------------------- export interface PairSummary { @@ -315,7 +315,7 @@ export function computeCompareImageRows( function jsonLdEntryFor(key: string, summary: PairSummary, position: number) { const meta = HW_REGISTRY[key]; const label = meta?.label ?? key.toUpperCase(); - const props: { name: string; value: string | number }[] = []; + const props: { name: string; value: string | number }[] = [{ name: 'Category', value: 'GPU' }]; if (meta) { props.push({ name: 'Vendor', value: meta.vendor }); props.push({ name: 'Architecture', value: meta.arch }); @@ -344,10 +344,8 @@ function jsonLdEntryFor(key: string, summary: PairSummary, position: number) { '@type': 'ListItem', position, item: { - '@type': 'Product', + '@type': 'Thing', name: label, - brand: { '@type': 'Brand', name: meta?.vendor ?? 'Unknown' }, - category: 'GPU', ...(props.length > 0 && { additionalProperty: props.map((p) => ({ '@type': 'PropertyValue', From d616c5c13d0292276baf0fbfc2428afad3893b95 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 20:45:15 -0700 Subject: [PATCH 4/7] fix(compare): switch row entries to Dataset so Dataset.hasPart validates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema.org Dataset.hasPart expects a CreativeWork (or subclass). Observation isn't a CreativeWork, so Google's structured-data validator rejected each row. Dataset accepts variableMeasured exactly like Observation, so the inner shape is unchanged — only the @type label moves. --- packages/app/src/lib/compare-ssr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 75702f9b..267d3a1d 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -824,7 +824,7 @@ export function buildJsonLd( ); } return { - '@type': 'Observation', + '@type': 'Dataset', name: `${model.label} comparison at ${row.target} tok/s/user interactivity`, variableMeasured: metrics.map((m) => ({ '@type': 'PropertyValue', From 4b6b0ce23f3fffd56d6f63634b8eb686f2148e25 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 20:46:29 -0700 Subject: [PATCH 5/7] fix(compare): add license and creator to Dataset JSON-LD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's Rich Results validator flags Dataset entries without a license URL and a creator. The repo (github.com/SemiAnalysisAI/InferenceX) is Apache-2.0 and the author is SemiAnalysis — use the canonical Apache license URL and the existing AUTHOR_NAME / AUTHOR_URL seo constants so the dataset card is eligible for rich-result rendering. --- packages/app/src/lib/compare-ssr.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 267d3a1d..071efbc2 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -10,7 +10,12 @@ * and JSON-LD shape — with a `variant` knob that swaps the headline * framing between the latency+throughput view and the per-dollar view. */ -import { HW_REGISTRY, sequenceToIslOsl } from '@semianalysisai/inferencex-constants'; +import { + AUTHOR_NAME, + AUTHOR_URL, + HW_REGISTRY, + sequenceToIslOsl, +} from '@semianalysisai/inferencex-constants'; import { FIXTURES_MODE, JSON_MODE, getDb } from '@semianalysisai/inferencex-db/connection'; import * as jsonProvider from '@semianalysisai/inferencex-db/json-provider'; import { @@ -854,6 +859,12 @@ export function buildJsonLd( name: datasetName, description: datasetDescription, url, + license: 'https://www.apache.org/licenses/LICENSE-2.0', + creator: { + '@type': 'Organization', + name: AUTHOR_NAME, + url: AUTHOR_URL, + }, ...(imageUrl && { image: { '@type': 'ImageObject', From ac9b5d75720debc1a252c1bc1b17b50a5bf552d5 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 20:51:14 -0700 Subject: [PATCH 6/7] feat(compare): enrich JSON-LD for Google Dataset Search and add breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three SEO-only additions to the compare slug pages: 1. Dataset metadata polish — datePublished / dateModified derived from the benchmark row dates for this pair, plus keywords, isAccessibleForFree, and measurementTechnique. These are the fields Google Dataset Search actually surfaces. 2. distribution: DataDownload — points at /api/v1/benchmarks?model=… so the Dataset isn't just described, it's downloadable in machine-readable form. 3. BreadcrumbList JSON-LD on every /compare/[slug] and /compare-per-dollar/ [slug] page (Home → GPU Comparisons / GPU Performance per Dollar → A vs B (Model)). Drives the Google SERP breadcrumb trail. No behavioral changes to the UI — only the SSR JSON-LD payload grows. --- .../app/compare-per-dollar/[slug]/page.tsx | 12 +++ packages/app/src/app/compare/[slug]/page.tsx | 13 ++++ packages/app/src/lib/compare-ssr.ts | 74 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/packages/app/src/app/compare-per-dollar/[slug]/page.tsx b/packages/app/src/app/compare-per-dollar/[slug]/page.tsx index 5e58e807..deede577 100644 --- a/packages/app/src/app/compare-per-dollar/[slug]/page.tsx +++ b/packages/app/src/app/compare-per-dollar/[slug]/page.tsx @@ -14,9 +14,11 @@ import { import { getAllComparableCompareSlugs } from '@/lib/compare-availability'; import { getGpuSpecs } from '@/lib/constants'; import { + buildBreadcrumbJsonLd, buildJsonLd, compareTableNarrative, computeCompareTableData, + dateRangeForPair, getCachedBenchmarks, KNOWN_MODELS, KNOWN_PRECISIONS, @@ -121,6 +123,7 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro const url = `${SITE_URL}/compare-per-dollar/${canonical}`; const imageUrl = `${url}/performance-per-dollar.png`; + const { oldest, newest } = dateRangeForPair(rows, parsed.a, parsed.b); const jsonLd = buildJsonLd( 'per-dollar', parsed.model, @@ -131,6 +134,14 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro summaryB, ssrRows, imageUrl, + oldest, + newest, + parsed.model.displayName, + ); + const breadcrumbJsonLd = buildBreadcrumbJsonLd( + 'per-dollar', + compareModelDisplayLabel(parsed.model, parsed.a, parsed.b), + url, ); const label = compareModelDisplayLabel(parsed.model, parsed.a, parsed.b); const aMeta = HW_REGISTRY[parsed.a]; @@ -155,6 +166,7 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro return ( <> + + newest) newest = row.date; + } + return { oldest, newest }; +} + export function buildJsonLd( variant: CompareJsonLdVariant, model: CompareModelSlug, @@ -783,6 +824,13 @@ export function buildJsonLd( summaryB: PairSummary, ssrRows: SsrInterpolatedRow[], imageUrl?: string, + /** ISO date of oldest benchmark row contributing to this dataset. */ + datePublished?: string, + /** ISO date of newest benchmark row — drives Google Dataset Search freshness. */ + dateModified?: string, + /** Display model name accepted by /api/v1/benchmarks?model=…, used to wire the + * Dataset's `distribution: DataDownload` to a real machine-readable export. */ + modelApiKey?: string, ) { const aLabel = HW_REGISTRY[a]?.label ?? a.toUpperCase(); const bLabel = HW_REGISTRY[b]?.label ?? b.toUpperCase(); @@ -860,11 +908,37 @@ export function buildJsonLd( description: datasetDescription, url, license: 'https://www.apache.org/licenses/LICENSE-2.0', + isAccessibleForFree: true, + measurementTechnique: + 'Open-source automated GPU CI/CD inference benchmark (github.com/SemiAnalysisAI/InferenceX)', + keywords: [ + 'AI inference benchmark', + 'GPU comparison', + variant === 'per-dollar' ? 'cost per million tokens' : 'inference latency', + variant === 'per-dollar' ? 'performance per dollar' : 'tokens per second', + model.label, + aLabel, + bLabel, + HW_REGISTRY[a]?.vendor, + HW_REGISTRY[b]?.vendor, + ] + .filter(Boolean) + .join(', '), + ...(datePublished && { datePublished }), + ...(dateModified && { dateModified }), creator: { '@type': 'Organization', name: AUTHOR_NAME, url: AUTHOR_URL, }, + ...(modelApiKey && { + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/json', + contentUrl: `${SITE_URL}/api/v1/benchmarks?model=${encodeURIComponent(modelApiKey)}`, + name: `${model.label} latest benchmark rows (JSON)`, + }, + }), ...(imageUrl && { image: { '@type': 'ImageObject', From f501dbb28b34123c40956143661b80b57fe77241 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Tue, 26 May 2026 20:53:51 -0700 Subject: [PATCH 7/7] fix(compare): dedup keywords so same-vendor pairs don't repeat vendor name H100 vs H200 was producing 'NVIDIA, NVIDIA' in the Dataset keywords list, and MI300X vs MI325X was producing 'AMD, AMD'. Wrap the keyword list in a Set so each value appears at most once. Cross-vendor pairs still get both 'NVIDIA, AMD'. --- packages/app/src/lib/compare-ssr.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 36edbe3c..e6e87dd7 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -912,18 +912,20 @@ export function buildJsonLd( measurementTechnique: 'Open-source automated GPU CI/CD inference benchmark (github.com/SemiAnalysisAI/InferenceX)', keywords: [ - 'AI inference benchmark', - 'GPU comparison', - variant === 'per-dollar' ? 'cost per million tokens' : 'inference latency', - variant === 'per-dollar' ? 'performance per dollar' : 'tokens per second', - model.label, - aLabel, - bLabel, - HW_REGISTRY[a]?.vendor, - HW_REGISTRY[b]?.vendor, - ] - .filter(Boolean) - .join(', '), + ...new Set( + [ + 'AI inference benchmark', + 'GPU comparison', + variant === 'per-dollar' ? 'cost per million tokens' : 'inference latency', + variant === 'per-dollar' ? 'performance per dollar' : 'tokens per second', + model.label, + aLabel, + bLabel, + HW_REGISTRY[a]?.vendor, + HW_REGISTRY[b]?.vendor, + ].filter(Boolean), + ), + ].join(', '), ...(datePublished && { datePublished }), ...(dateModified && { dateModified }), creator: {