diff --git a/packages/app/cypress/e2e/collectivex.cy.ts b/packages/app/cypress/e2e/collectivex.cy.ts index ebdc623f7..939da5fa7 100644 --- a/packages/app/cypress/e2e/collectivex.cy.ts +++ b/packages/app/cypress/e2e/collectivex.cy.ts @@ -523,12 +523,49 @@ describe('CollectiveX kv-transfer card', () => { 'contain.text', `${kvDataset.run.measured_cases}/${kvDataset.run.requested_cases}`, ); + // The runs table distinguishes the run's suites: this run carries both. + cy.get(`[data-testid="collectivex-run-suite-ep-${kvDataset.run.run_id}"]`).should('be.visible'); + cy.get(`[data-testid="collectivex-run-suite-kv-${kvDataset.run.run_id}"]`).should('be.visible'); }); - it('renders no kv card for an EP-only run', () => { + it('plots the kv chart and switches metric, axis, and page size', () => { + installRuns([kvDataset]); + installRun(kvDataset); + openCollectiveX(); + // Default view: aggregate GB/s vs batch at the largest ISL, page 64, pull. + // The measured fixture case carries paged-64 rows at batch 1 and 16. + cy.get('[data-testid="collectivex-kv-chart"]').should('be.visible'); + cy.get('[data-testid="collectivex-kv-chart"] circle').should('have.length', 2); + cy.get('[data-testid="collectivex-kv-chart"]').should( + 'contain.text', + 'Aggregate pull bandwidth at p50 (GB/s)', + ); + // Metric toggle swaps the y axis to burst latency. + cy.get('[data-testid="collectivex-kv-metric-toggle"]').contains('button', 'ms').click(); + cy.get('[data-testid="collectivex-kv-chart"]').should( + 'contain.text', + 'Burst completion latency p50 (ms)', + ); + // ISL on the x axis pins batch 1: one paged-64 row in the fixture. + cy.get('[data-testid="collectivex-kv-xaxis-toggle"]').contains('button', 'ISL').click(); + cy.get('[data-testid="collectivex-kv-chart"] circle').should('have.length', 1); + // Page 16 keeps a single batch-1 row. + cy.get('[data-testid="collectivex-kv-page-toggle"]').contains('button', '16').click(); + cy.get('[data-testid="collectivex-kv-chart"] circle').should('have.length', 1); + // The kv section renders above the EP explorer chart. + cy.get('[data-testid="collectivex-kv-table"]').then(($kv) => { + cy.get('[data-testid="collectivex-main-chart"]').then(($chart) => { + expect($kv[0].compareDocumentPosition($chart[0]) & 4).to.equal(4); + }); + }); + }); + + it('renders no kv card and no KV suite badge for an EP-only run', () => { installRuns(); installRun(); openCollectiveX(); cy.get('[data-testid="collectivex-kv-table"]').should('not.exist'); + cy.get(`[data-testid="collectivex-run-suite-ep-${runId}"]`).should('be.visible'); + cy.get(`[data-testid="collectivex-run-suite-kv-${runId}"]`).should('not.exist'); }); }); diff --git a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx index 6c3c1c519..a448a6371 100644 --- a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx +++ b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx @@ -26,7 +26,7 @@ import { useLocale } from '@/lib/use-locale'; import { CollectiveXChart } from './CollectiveXChart'; import { CollectiveXInventory } from './CollectiveXInventory'; -import { CollectiveXKvTable } from './CollectiveXKvTable'; +import { CollectiveXKvSection } from './CollectiveXKvSection'; import { CollectiveXRunsTable } from './CollectiveXRunsTable'; import { collectiveXColorKey, @@ -862,6 +862,9 @@ export default function CollectiveXDisplay() {

{t.noSeries}

)} + {/* KV-transfer cases lead for kv-only runs: the EP chart below is + legitimately empty for them and must not bury the selected data. */} + - `${dataset.run.run_id}:${dataset.run.run_attempt}`).join(',')}`} datasets={datasets} diff --git a/packages/app/src/components/collectivex/CollectiveXKvChart.tsx b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx new file mode 100644 index 000000000..e1a701aa6 --- /dev/null +++ b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx @@ -0,0 +1,199 @@ +'use client'; + +import * as d3 from 'd3'; +import { useMemo } from 'react'; + +import { D3Chart } from '@/lib/d3-chart/D3Chart'; + +import { + type CollectiveXKvChartPoint, + type CollectiveXKvChartSelection, + type CollectiveXKvRunCase, + collectiveXKvChartPoints, + collectiveXRunDasharray, +} from './data'; + +interface CollectiveXKvChartProps { + chartId: string; + cases: CollectiveXKvRunCase[]; + colors: Record; + selection: CollectiveXKvChartSelection; + caption?: React.ReactNode; + legendElement?: React.ReactNode; + testId?: string; +} + +const X_LABELS: Record = { + batch: 'Requests per burst (log)', + isl: 'Input sequence length, tokens (log)', +}; + +function yLabel(selection: CollectiveXKvChartSelection): string { + return selection.y === 'bandwidth' + ? `Aggregate ${selection.op} bandwidth at p50 (GB/s)` + : 'Burst completion latency p50 (ms)'; +} + +function paddedDomain(values: number[]): [number, number] { + if (values.length === 0) return [1, 10]; + const min = d3.min(values) ?? 1; + const max = d3.max(values) ?? 1; + return min === max ? [min / 2, max * 2] : [min / 1.15, max * 1.15]; +} + +function formatCompact(value: number): string { + if (value >= 1e3) return `${(value / 1e3).toFixed(value < 1e4 ? 1 : 0)}k`; + if (value >= 10) return value.toFixed(0); + if (value >= 1) return value.toFixed(1); + return value.toFixed(2); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function CollectiveXKvChart({ + chartId, + cases, + colors, + selection, + caption, + legendElement, + testId, +}: CollectiveXKvChartProps) { + const points = useMemo(() => collectiveXKvChartPoints(cases, selection), [cases, selection]); + const runIndexBySeries = useMemo( + () => new Map(cases.map((kase) => [`${kase.run_id}:${kase.case_id}`, kase.run_index])), + [cases], + ); + const lines = useMemo(() => { + const result: Record = {}; + for (const point of points) { + (result[point.seriesId] ??= []).push({ x: point.x, y: point.y }); + } + for (const line of Object.values(result)) { + line.sort((a, b) => a.x - b.x); + } + return result; + }, [points]); + const colorBySeries = useMemo( + () => new Map(points.map((point) => [point.seriesId, point.colorKey])), + [points], + ); + + const xDomain = useMemo(() => paddedDomain(points.map((point) => point.x)), [points]); + const yDomain = useMemo(() => paddedDomain(points.map((point) => point.y)), [points]); + const xTickValues = useMemo( + () => [...new Set(points.map((point) => point.x))].toSorted((a, b) => a - b), + [points], + ); + + const noDataOverlay = + points.length === 0 ? ( +
+

+ No measured kv rows match the selected page size and direction. +

+
+ ) : undefined; + + return ( + + chartId={chartId} + data={points} + height={420} + margin={{ top: 24, right: 20, bottom: 62, left: 78 }} + watermark="logo" + testId={testId} + grabCursor + instructions="Shift+Scroll to zoom · Drag to pan · Double-click to reset · Click a point to pin tooltip" + xScale={{ type: 'log', domain: xDomain, nice: false }} + yScale={{ type: 'log', domain: yDomain, nice: false }} + xAxis={{ + label: X_LABELS[selection.x], + tickCount: 6, + tickValues: xTickValues, + tickFormat: (value) => formatCompact(Number(value)), + }} + yAxis={{ + label: yLabel(selection), + tickCount: 5, + tickFormat: (value) => formatCompact(Number(value)), + }} + layers={[ + { + type: 'line', + key: 'collectivex-kv-lines', + lines, + config: { + getColor: (key) => colors[colorBySeries.get(key) ?? ''] ?? '#888', + getStrokeDasharray: (key) => collectiveXRunDasharray(runIndexBySeries.get(key) ?? 0), + strokeWidth: 2.25, + curve: d3.curveLinear, + }, + }, + { + type: 'point', + key: 'collectivex-kv-points', + data: points, + config: { + getCx: () => 0, + getCy: () => 0, + getX: (point) => point.x, + getY: (point) => point.y, + getColor: (point) => colors[point.colorKey] ?? '#888', + getRadius: () => 3.5, + stroke: 'var(--background)', + strokeWidth: 1, + keyFn: (point) => `${point.seriesId}-${point.x}`, + maxPoints: Infinity, + }, + }, + ]} + zoom={{ + enabled: true, + axes: 'both', + scaleExtent: [1, 20], + resetEventName: `collectivex_zoom_reset_${chartId}`, + }} + tooltip={{ + rulerType: 'crosshair', + attachToLayer: 1, + content: (point, isPinned) => { + const color = colors[point.colorKey] ?? '#888'; + const { row } = point; + const value = + selection.y === 'bandwidth' + ? `${point.y.toFixed(point.y >= 100 ? 0 : 2)} GB/s` + : `${point.y.toFixed(point.y >= 100 ? 0 : 1)} ms`; + return `
+ ${isPinned ? '
Click elsewhere to dismiss
' : ''} +
${escapeHtml(point.seriesLabel)}
+
${row.op} · page ${row.page_tokens} · batch ${row.batch} · ISL ${row.isl.toLocaleString('en-US')}: ${value}
+
Latency p50 / p95 / min / max: ${row.latency_ms.p50.toFixed(1)} / ${row.latency_ms.p95.toFixed(1)} / ${row.latency_ms.min.toFixed(1)} / ${row.latency_ms.max.toFixed(1)} ms
+
${row.descs.toLocaleString('en-US')} descriptors/request · ${(row.req_bytes / 1e6).toFixed(1)} MB/request · prep ${row.prep_ms.toFixed(1)} ms
+
verify: ${row.verify_passed ? 'passed' : 'FAILED'}
+
`; + }, + getRulerX: (point, scale) => + (scale as d3.ScaleLinear | d3.ScaleLogarithmic)(point.x), + getRulerY: (point, scale) => scale(point.y), + onHoverStart: (selectionEl) => { + selectionEl.attr('r', 6); + }, + onHoverEnd: (selectionEl) => { + selectionEl.attr('r', 3.5); + }, + }} + transitionDuration={200} + legendElement={legendElement} + noDataOverlay={noDataOverlay} + caption={caption} + /> + ); +} diff --git a/packages/app/src/components/collectivex/CollectiveXKvSection.tsx b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx new file mode 100644 index 000000000..3c5175e68 --- /dev/null +++ b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx @@ -0,0 +1,355 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import ChartLegend from '@/components/ui/chart-legend'; +import { type DataTableColumn, DataTable } from '@/components/ui/data-table'; +import { Label } from '@/components/ui/label'; +import { SegmentedToggle } from '@/components/ui/segmented-toggle'; +import { useThemeColors } from '@/hooks/useThemeColors'; +import { track } from '@/lib/analytics'; +import { useLocale } from '@/lib/use-locale'; + +import { CollectiveXKvChart } from './CollectiveXKvChart'; +import { + type CollectiveXKvChartSelection, + type CollectiveXKvRunCase, + collectiveXKvCell, + collectiveXKvColorKey, + collectiveXKvLegendLabel, + collectiveXRunDasharray, +} from './data'; +import type { CollectiveXDataset, CollectiveXOutcome } from './types'; + +const STRINGS = { + en: { + heading: 'KV-cache transfer', + description: + 'Prefill-to-decode KV handoff (2 nodes x 1 GPU, DeepSeek-V4-Pro cache as vLLM allocates it). ' + + 'Paged rows move per-request layer-major descriptor lists over randomized block tables; ' + + 'bulk is the single-descriptor wire ceiling. GB/s is burst-aggregate pull at the largest ISL; ' + + 'b1/bmax are requests posted per burst.', + batchCaption: 'at the largest measured ISL', + islCaption: 'at batch 1', + yControl: 'Metric', + xControl: 'X axis', + pageControl: 'Page size', + opControl: 'Direction', + }, + zh: { + heading: 'KV 缓存传输', + description: + '预填充到解码的 KV 交接(2 节点 x 1 GPU,按 vLLM 为 DeepSeek-V4-Pro 分配的缓存布局)。' + + '分页行按随机块表以逐层描述符列表搬运每个请求;bulk 为单描述符线速上限。' + + 'GB/s 为最大 ISL 处按突发聚合的 pull 带宽;b1/bmax 表示每次突发提交的请求数。', + batchCaption: '取最大实测 ISL', + islCaption: '取批大小 1', + yControl: '指标', + xControl: 'X 轴', + pageControl: '页大小', + opControl: '方向', + }, +} as const; + +const OUTCOME_CLASS: Record = { + success: 'border-emerald-600/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + unsupported: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', + failed: 'border-red-700/50 bg-red-700/10 text-red-800 dark:text-red-300', + invalid: 'border-red-600/40 bg-red-500/10 text-red-700 dark:text-red-300', + diagnostic: 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', + pending: 'border-zinc-500/40 bg-zinc-500/5 text-muted-foreground', +}; + +function formatGbps(value: number | null | undefined): string { + return value === null || value === undefined ? '-' : value.toFixed(value >= 100 ? 0 : 2); +} + +function cellsOf(row: CollectiveXKvRunCase) { + return { + p64b1: collectiveXKvCell(row.rows, 'paged', 64, 'min'), + p64bmax: collectiveXKvCell(row.rows, 'paged', 64, 'max'), + p16b1: collectiveXKvCell(row.rows, 'paged', 16, 'min'), + bulk: collectiveXKvCell(row.rows, 'bulk', null, 'min'), + }; +} + +export function CollectiveXKvSection({ + datasets, + runIndexById, +}: { + datasets: CollectiveXDataset[]; + /** Selection-order style index per run id, shared with the EP explorer so + * the same run keeps the same dash pattern on both charts. */ + runIndexById: ReadonlyMap; +}) { + const locale = useLocale(); + const strings = STRINGS[locale === 'zh' ? 'zh' : 'en']; + const [yAxis, setYAxis] = useState('bandwidth'); + const [xAxis, setXAxis] = useState('batch'); + const [pageTokens, setPageTokens] = useState<'64' | '16'>('64'); + const [op, setOp] = useState('pull'); + // Legend toggles are keyed to the current series set: when checked runs + // change, the stored selection is stale and every series starts active + // again (the EP explorer resets the same way). + const [seriesSelection, setSeriesSelection] = useState<{ + ids: Set; + signature: string; + } | null>(null); + const [legendExpanded, setLegendExpanded] = useState(false); + + const rows = useMemo( + () => + datasets.flatMap((dataset, index) => + (dataset.kv ?? []).map((item) => ({ + ...item, + run_id: dataset.run.run_id, + run_index: runIndexById.get(dataset.run.run_id) ?? index, + })), + ), + [datasets, runIndexById], + ); + const measuredCases = useMemo(() => rows.filter((row) => row.rows.length > 0), [rows]); + const seriesSignature = useMemo( + () => + measuredCases + .map((kase) => `${kase.run_id}:${kase.case_id}`) + .toSorted() + .join('|'), + [measuredCases], + ); + const activeIds = useMemo( + () => + seriesSelection && seriesSelection.signature === seriesSignature + ? seriesSelection.ids + : new Set(measuredCases.map((kase) => `${kase.run_id}:${kase.case_id}`)), + [measuredCases, seriesSelection, seriesSignature], + ); + const activeCases = useMemo( + () => measuredCases.filter((kase) => activeIds.has(`${kase.run_id}:${kase.case_id}`)), + [activeIds, measuredCases], + ); + + const colorKeys = useMemo( + () => [...new Set(measuredCases.map(collectiveXKvColorKey))], + [measuredCases], + ); + const { resolveColor, getCssColor } = useThemeColors({ + highContrast: false, + activeKeys: colorKeys, + hcKeys: colorKeys, + hcVendorKeyFor: (key) => key.split('_')[0], + }); + const colors = useMemo( + () => Object.fromEntries(colorKeys.map((key) => [key, getCssColor(resolveColor(key, key))])), + [colorKeys, getCssColor, resolveColor], + ); + + const legendItems = useMemo( + () => + measuredCases.map((kase) => { + const seriesId = `${kase.run_id}:${kase.case_id}`; + return { + name: seriesId, + label: collectiveXKvLegendLabel(kase), + color: colors[collectiveXKvColorKey(kase)] ?? 'var(--muted-foreground)', + lineDasharray: collectiveXRunDasharray(kase.run_index), + isActive: activeIds.has(seriesId), + title: `#${kase.run_id} · ${kase.workload} · ${kase.topology.topology_class}`, + onClick: () => { + const next = new Set(activeIds); + if (next.has(seriesId)) next.delete(seriesId); + else next.add(seriesId); + setSeriesSelection({ ids: next, signature: seriesSignature }); + track('collectivex_kv_series_toggled', { series: seriesId }); + }, + }; + }), + [activeIds, colors, measuredCases], + ); + + const columns = useMemo[]>( + () => [ + { + header: 'Run', + cell: (row) => #{row.run_id}, + sortValue: (row) => Number(row.run_id), + className: 'whitespace-nowrap', + }, + { header: 'SKU', cell: (row) => row.sku.toUpperCase(), sortValue: (row) => row.sku }, + { + header: 'Backend', + cell: (row) => row.backend, + sortValue: (row) => row.backend, + className: 'whitespace-nowrap', + }, + { header: 'Fabric', cell: (row) => row.fabric, sortValue: (row) => row.fabric }, + { header: 'Workload', cell: (row) => row.workload, sortValue: (row) => row.workload }, + { header: 'Precision', cell: (row) => row.precision, sortValue: (row) => row.precision }, + { + header: 'Outcome', + cell: (row) => ( +
+ + {row.outcome} + + {(row.detail || row.reason) && ( +

{row.detail ?? row.reason}

+ )} +
+ ), + sortValue: (row) => `${row.outcome} ${row.reason ?? ''}`, + }, + { + header: 'Bulk GB/s', + cell: (row) => formatGbps(cellsOf(row).bulk?.gbps_p50), + sortValue: (row) => cellsOf(row).bulk?.gbps_p50 ?? -1, + className: 'text-right tabular-nums', + }, + { + header: 'p64 GB/s b1', + cell: (row) => formatGbps(cellsOf(row).p64b1?.gbps_p50), + sortValue: (row) => cellsOf(row).p64b1?.gbps_p50 ?? -1, + className: 'text-right tabular-nums', + }, + { + header: 'p64 GB/s bmax', + cell: (row) => { + const cell = cellsOf(row).p64bmax; + if (!cell) return '-'; + return `${formatGbps(cell.gbps_p50)} (b${cell.batch})`; + }, + sortValue: (row) => cellsOf(row).p64bmax?.gbps_p50 ?? -1, + className: 'text-right tabular-nums whitespace-nowrap', + }, + { + header: 'p16 GB/s b1', + cell: (row) => formatGbps(cellsOf(row).p16b1?.gbps_p50), + sortValue: (row) => cellsOf(row).p16b1?.gbps_p50 ?? -1, + className: 'text-right tabular-nums', + }, + { + header: 'Handoff ms', + cell: (row) => { + const cell = cellsOf(row).p64b1; + return cell ? cell.latency_ms.p50.toFixed(1) : '-'; + }, + sortValue: (row) => cellsOf(row).p64b1?.latency_ms.p50 ?? -1, + className: 'text-right tabular-nums', + }, + ], + [], + ); + + if (rows.length === 0) return null; + const measured = rows.filter((row) => row.outcome === 'success').length; + const selection: CollectiveXKvChartSelection = { + x: xAxis, + y: yAxis, + op, + pageTokens: Number(pageTokens), + }; + return ( + +

{strings.heading}

+

+ {rows.length} cases · {measured} measured · {strings.description} +

+ {measuredCases.length > 0 && ( + <> +
+
+ + { + setYAxis(value); + track('collectivex_kv_metric_changed', { metric: value }); + }} + ariaLabel="CollectiveX kv metric" + testId="collectivex-kv-metric-toggle" + options={[ + { value: 'bandwidth', label: 'GB/s' }, + { value: 'latency', label: 'ms' }, + ]} + /> +
+
+ + { + setXAxis(value); + track('collectivex_kv_xaxis_changed', { axis: value }); + }} + ariaLabel="CollectiveX kv x axis" + testId="collectivex-kv-xaxis-toggle" + options={[ + { value: 'batch', label: 'Batch' }, + { value: 'isl', label: 'ISL' }, + ]} + /> +
+
+ + +
+
+ + +
+
+
+ + {op} · page {pageTokens} ·{' '} + {xAxis === 'batch' ? strings.batchCaption : strings.islCaption} +

+ } + legendElement={ + + } + /> +
+ + )} + +
+ ); +} diff --git a/packages/app/src/components/collectivex/CollectiveXKvTable.tsx b/packages/app/src/components/collectivex/CollectiveXKvTable.tsx deleted file mode 100644 index 88a23c12b..000000000 --- a/packages/app/src/components/collectivex/CollectiveXKvTable.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; - -import { Badge } from '@/components/ui/badge'; -import { Card } from '@/components/ui/card'; -import { type DataTableColumn, DataTable } from '@/components/ui/data-table'; -import { useLocale } from '@/lib/use-locale'; - -import { collectiveXKvCell } from './data'; -import type { CollectiveXDataset, CollectiveXKvCase, CollectiveXOutcome } from './types'; - -type CollectiveXRunKvCase = CollectiveXKvCase & { run_id: string }; - -const STRINGS = { - en: { - heading: 'KV-cache transfer', - description: - 'Prefill-to-decode KV handoff (2 nodes x 1 GPU, DeepSeek-V4-Pro cache as vLLM allocates it). ' + - 'Paged rows move per-request layer-major descriptor lists over randomized block tables; ' + - 'bulk is the single-descriptor wire ceiling. GB/s is burst-aggregate pull at the largest ISL; ' + - 'b1/bmax are requests posted per burst.', - }, - zh: { - heading: 'KV 缓存传输', - description: - '预填充到解码的 KV 交接(2 节点 x 1 GPU,按 vLLM 为 DeepSeek-V4-Pro 分配的缓存布局)。' + - '分页行按随机块表以逐层描述符列表搬运每个请求;bulk 为单描述符线速上限。' + - 'GB/s 为最大 ISL 处按突发聚合的 pull 带宽;b1/bmax 表示每次突发提交的请求数。', - }, -} as const; - -const OUTCOME_CLASS: Record = { - success: 'border-emerald-600/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', - unsupported: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', - failed: 'border-red-700/50 bg-red-700/10 text-red-800 dark:text-red-300', - invalid: 'border-red-600/40 bg-red-500/10 text-red-700 dark:text-red-300', - diagnostic: 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', - pending: 'border-zinc-500/40 bg-zinc-500/5 text-muted-foreground', -}; - -function formatGbps(value: number | null | undefined): string { - return value === null || value === undefined ? '-' : value.toFixed(value >= 100 ? 0 : 2); -} - -function cellsOf(row: CollectiveXRunKvCase) { - return { - p64b1: collectiveXKvCell(row.rows, 'paged', 64, 'min'), - p64bmax: collectiveXKvCell(row.rows, 'paged', 64, 'max'), - p16b1: collectiveXKvCell(row.rows, 'paged', 16, 'min'), - bulk: collectiveXKvCell(row.rows, 'bulk', null, 'min'), - }; -} - -export function CollectiveXKvTable({ datasets }: { datasets: CollectiveXDataset[] }) { - const locale = useLocale(); - const strings = STRINGS[locale === 'zh' ? 'zh' : 'en']; - const rows = useMemo( - () => - datasets.flatMap((dataset) => - (dataset.kv ?? []).map((item) => ({ ...item, run_id: dataset.run.run_id })), - ), - [datasets], - ); - const columns = useMemo[]>( - () => [ - { - header: 'Run', - cell: (row) => #{row.run_id}, - sortValue: (row) => Number(row.run_id), - className: 'whitespace-nowrap', - }, - { header: 'SKU', cell: (row) => row.sku.toUpperCase(), sortValue: (row) => row.sku }, - { - header: 'Backend', - cell: (row) => row.backend, - sortValue: (row) => row.backend, - className: 'whitespace-nowrap', - }, - { header: 'Fabric', cell: (row) => row.fabric, sortValue: (row) => row.fabric }, - { header: 'Workload', cell: (row) => row.workload, sortValue: (row) => row.workload }, - { header: 'Precision', cell: (row) => row.precision, sortValue: (row) => row.precision }, - { - header: 'Outcome', - cell: (row) => ( -
- - {row.outcome} - - {(row.detail || row.reason) && ( -

{row.detail ?? row.reason}

- )} -
- ), - sortValue: (row) => `${row.outcome} ${row.reason ?? ''}`, - }, - { - header: 'Bulk GB/s', - cell: (row) => formatGbps(cellsOf(row).bulk?.gbps_p50), - sortValue: (row) => cellsOf(row).bulk?.gbps_p50 ?? -1, - className: 'text-right tabular-nums', - }, - { - header: 'p64 GB/s b1', - cell: (row) => formatGbps(cellsOf(row).p64b1?.gbps_p50), - sortValue: (row) => cellsOf(row).p64b1?.gbps_p50 ?? -1, - className: 'text-right tabular-nums', - }, - { - header: 'p64 GB/s bmax', - cell: (row) => { - const cell = cellsOf(row).p64bmax; - if (!cell) return '-'; - return `${formatGbps(cell.gbps_p50)} (b${cell.batch})`; - }, - sortValue: (row) => cellsOf(row).p64bmax?.gbps_p50 ?? -1, - className: 'text-right tabular-nums whitespace-nowrap', - }, - { - header: 'p16 GB/s b1', - cell: (row) => formatGbps(cellsOf(row).p16b1?.gbps_p50), - sortValue: (row) => cellsOf(row).p16b1?.gbps_p50 ?? -1, - className: 'text-right tabular-nums', - }, - { - header: 'Handoff ms', - cell: (row) => { - const cell = cellsOf(row).p64b1; - return cell ? cell.latency_ms.p50.toFixed(1) : '-'; - }, - sortValue: (row) => cellsOf(row).p64b1?.latency_ms.p50 ?? -1, - className: 'text-right tabular-nums', - }, - ], - [], - ); - if (rows.length === 0) return null; - const measured = rows.filter((row) => row.outcome === 'success').length; - return ( - -

{strings.heading}

-

- {rows.length} cases · {measured} measured · {strings.description} -

- -
- ); -} diff --git a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx index 6ea114e96..54271549e 100644 --- a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx +++ b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx @@ -25,6 +25,7 @@ const STRINGS = { shown: 'Shown', run: 'Run', result: 'Result', + suites: 'Suites', cases: 'Measured cases', points: 'Terminal points', skus: 'SKUs', @@ -42,6 +43,7 @@ const STRINGS = { shown: 'Shown', run: 'Run', result: 'Result', + suites: 'Suites', cases: 'Measured cases', points: 'Terminal points', skus: 'SKUs', @@ -56,6 +58,11 @@ const STRINGS = { }, } as const; +const SUITE_BADGE_CLASSES = { + ep: 'border-sky-600/40 bg-sky-500/10 text-sky-700 dark:text-sky-300', + kv: 'border-violet-600/40 bg-violet-500/10 text-violet-700 dark:text-violet-300', +} as const; + const CONCLUSION_CLASSES: Record = { success: 'border-emerald-600/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', failure: 'border-red-600/40 bg-red-500/10 text-red-700 dark:text-red-300', @@ -92,12 +99,13 @@ export function CollectiveXRunsTable({ data-testid="collectivex-runs-table" className="mt-2 max-h-[28rem] overflow-auto rounded-md border" > - +
+ @@ -112,6 +120,11 @@ export function CollectiveXRunsTable({ const deleting = deletingRunIds.has(run.run_id); const conclusion = run.conclusion ?? t.pending; const selectedRunIndex = selectedRunIndexById.get(run.run_id); + // Summaries stored before the kv suite carry no kv_cases: EP-only. + const kvRequested = run.kv_cases?.requested ?? 0; + const kvMeasured = run.kv_cases?.measured ?? 0; + const epRequested = run.requested_cases - kvRequested; + const epMeasured = run.measured_cases - kvMeasured; const lineDasharray = selectedRunIndex === undefined ? null : collectiveXRunDasharray(selectedRunIndex); return ( @@ -191,6 +204,34 @@ export function CollectiveXRunsTable({ {conclusion} + diff --git a/packages/app/src/components/collectivex/data.test.ts b/packages/app/src/components/collectivex/data.test.ts index 4777bdee3..9741c9cf8 100644 --- a/packages/app/src/components/collectivex/data.test.ts +++ b/packages/app/src/components/collectivex/data.test.ts @@ -12,6 +12,8 @@ import { metricValue, seriesMatchesSelection, type CollectiveXSeriesSelection, + collectiveXKvChartPoints, + type CollectiveXKvRunCase, collectiveXKvCell, } from './data'; import type { CollectiveXKvRow, CollectiveXPercentiles, CollectiveXSeries } from './types'; @@ -282,7 +284,10 @@ describe('chartPoints', () => { }); }); -const kvRow = (overrides: Partial): CollectiveXKvRow => ({ +const kvRow = ({ + latency_p50, + ...overrides +}: Partial & { latency_p50?: number }): CollectiveXKvRow => ({ kind: 'paged', isl: 32768, page_tokens: 64, @@ -294,6 +299,17 @@ const kvRow = (overrides: Partial): CollectiveXKvRow => ({ latency_ms: { p50: 24.8, p95: 26, min: 24.1, max: 26.4, n: 24 }, gbps_p50: 7.39, verify_passed: true, + ...(latency_p50 === undefined + ? {} + : { + latency_ms: { + p50: latency_p50, + p95: latency_p50 * 1.05, + min: latency_p50 * 0.98, + max: latency_p50 * 1.1, + n: 24, + }, + }), ...overrides, }); @@ -319,3 +335,84 @@ describe('collectiveXKvCell', () => { expect(collectiveXKvCell(rows, 'bulk', null, 'min')?.gbps_p50).toBe(89.41); }); }); + +describe('collectiveXKvChartPoints', () => { + const kase = ( + rows: (Partial & { latency_p50?: number })[], + overrides = {}, + ): CollectiveXKvRunCase => ({ + case_id: 'gb200-nixl-kv-dsv4-rdma-xfer-ep2-paged-fp8', + label: 'gb200 · nixl · rdma · kv-dsv4 · fp8', + disposition: 'runnable', + sku: 'gb200', + vendor: 'nvidia', + backend: 'nixl', + fabric: 'rdma', + workload: 'kv-dsv4', + precision: 'fp8', + topology: { + ep_size: 2, + nodes: 2, + gpus_per_node: 1, + scale_up_domain: 72, + scale_up_transport: 'mnnvl', + scale_out_transport: 'rdma', + topology_class: 'gb200-kv-rdma', + }, + outcome: 'success', + reason: null, + detail: null, + rows: rows.map(kvRow), + run_id: '318', + run_index: 1, + ...overrides, + }); + + it('plots batch scaling at the largest measured ISL', () => { + const points = collectiveXKvChartPoints( + [ + kase([ + { isl: 4096, batch: 1, gbps_p50: 5 }, + { isl: 32768, batch: 1, gbps_p50: 7.39 }, + { isl: 32768, batch: 16, gbps_p50: 15.12 }, + { isl: 32768, batch: 16, op: 'push', gbps_p50: 99 }, + { isl: 32768, batch: 1, page_tokens: 16, gbps_p50: 2.72 }, + ]), + ], + { x: 'batch', y: 'bandwidth', op: 'pull', pageTokens: 64 }, + ); + expect(points.map((point) => [point.x, point.y])).toEqual([ + [1, 7.39], + [16, 15.12], + ]); + expect(points[0].seriesId).toBe('318:gb200-nixl-kv-dsv4-rdma-xfer-ep2-paged-fp8'); + expect(points[0].seriesLabel).toContain('#318'); + }); + + it('plots ISL scaling at batch 1 with latency as the metric', () => { + const points = collectiveXKvChartPoints( + [ + kase([ + { isl: 512, batch: 1, latency_p50: 0.5 }, + { isl: 32768, batch: 1, latency_p50: 24.8 }, + { isl: 32768, batch: 16, latency_p50: 193.7 }, + ]), + ], + { x: 'isl', y: 'latency', op: 'pull', pageTokens: 64 }, + ); + expect(points.map((point) => [point.x, point.y])).toEqual([ + [512, 0.5], + [32768, 24.8], + ]); + }); + + it('skips cases with no rows for the selected family', () => { + const points = collectiveXKvChartPoints([kase([{ kind: 'bulk', page_tokens: null }])], { + x: 'batch', + y: 'bandwidth', + op: 'pull', + pageTokens: 64, + }); + expect(points).toEqual([]); + }); +}); diff --git a/packages/app/src/components/collectivex/data.ts b/packages/app/src/components/collectivex/data.ts index 53512a535..c74b432ea 100644 --- a/packages/app/src/components/collectivex/data.ts +++ b/packages/app/src/components/collectivex/data.ts @@ -1,6 +1,7 @@ import type { CollectiveXChartPoint, CollectiveXComponent, + CollectiveXKvCase, CollectiveXKvRow, CollectiveXMode, CollectiveXOperation, @@ -222,3 +223,62 @@ export function collectiveXKvCell( atIsl.reduce((best, row) => (better(row.batch, best.batch) ? row : best)); return batch === 'min' ? pick((a, b) => a < b) : pick((a, b) => a > b); } + +/** A kv case namespaced by its run, with the run's selection-order style index. */ +export type CollectiveXKvRunCase = CollectiveXKvCase & { run_id: string; run_index: number }; + +export interface CollectiveXKvChartSelection { + x: 'batch' | 'isl'; + y: 'bandwidth' | 'latency'; + op: 'pull' | 'push'; + pageTokens: number; +} + +export interface CollectiveXKvChartPoint { + seriesId: string; + seriesLabel: string; + colorKey: string; + x: number; + y: number; + row: CollectiveXKvRow; +} + +export function collectiveXKvColorKey(kase: CollectiveXKvCase): string { + return `${kase.vendor ?? 'unknown'}_${kase.sku}_${kase.backend}_${kase.fabric}_${kase.precision}`; +} + +export function collectiveXKvLegendLabel(kase: CollectiveXKvCase): string { + return `${kase.sku.toUpperCase()} · ${kase.backend} · ${kase.fabric} · ${kase.precision}`; +} + +/** + * Chart points for the kv view. Batch on the x axis reads at the largest + * measured ISL (the bandwidth-bound point, where concurrency scaling is the + * story); ISL on the x axis reads at batch 1 (a single request's handoff). + * Paged rows only: the single-descriptor bulk ceiling stays a table column. + */ +export function collectiveXKvChartPoints( + cases: readonly CollectiveXKvRunCase[], + selection: CollectiveXKvChartSelection, +): CollectiveXKvChartPoint[] { + return cases.flatMap((kase) => { + const matching = kase.rows.filter( + (row) => + row.kind === 'paged' && row.op === selection.op && row.page_tokens === selection.pageTokens, + ); + if (matching.length === 0) return []; + const rows = + selection.x === 'batch' + ? matching.filter((row) => row.isl === Math.max(...matching.map((item) => item.isl))) + : matching.filter((row) => row.batch === 1); + const seriesId = `${kase.run_id}:${kase.case_id}`; + return rows.map((row) => ({ + seriesId, + seriesLabel: `#${kase.run_id} · ${collectiveXKvLegendLabel(kase)}`, + colorKey: collectiveXKvColorKey(kase), + x: selection.x === 'batch' ? row.batch : row.isl, + y: selection.y === 'bandwidth' ? row.gbps_p50 : row.latency_ms.p50, + row, + })); + }); +}
{t.shown} {t.run} {t.result}{t.suites} {t.cases} {t.points} {t.skus} +
+ {epRequested > 0 && ( + + EP + + )} + {kvRequested > 0 && ( + + KV + + )} +
+
{run.measured_cases}/{run.requested_cases}