Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/app/cypress/e2e/line-labels.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ describe('Line Labels Toggle', () => {
);
});

it('line labels render in the foreground, after the scatter points', () => {
// Labels were toggled on in the test above and remain on here.
cy.get('[data-testid="scatter-graph"] svg g.line-label').should('have.length.greaterThan', 0);

cy.get('[data-testid="scatter-graph"] svg').then(($svg) => {
const svg = $svg[0];
const dots = svg.querySelectorAll('.dot-group');
const labels = svg.querySelectorAll('g.line-label');
expect(dots.length, 'scatter dot groups exist').to.be.greaterThan(0);
expect(labels.length, 'line labels exist').to.be.greaterThan(0);

// Every label must paint after every dot group. Comparing the *last* dot
// group against the *first* label is sufficient: if the earliest label
// follows the latest dot in document order, all labels are in front.
const lastDot = dots.item(dots.length - 1)!;
const firstLabel = labels.item(0)!;
const relation = lastDot.compareDocumentPosition(firstLabel);
expect(
relation & Node.DOCUMENT_POSITION_FOLLOWING,
'line label follows the scatter points in DOM order (foreground)',
).to.be.greaterThan(0);
});
});

it('toggling Line Labels off removes label elements', () => {
cy.get('#scatter-line-labels').click();
cy.get('#scatter-line-labels').should('have.attr', 'data-state', 'unchecked');
Expand Down
40 changes: 38 additions & 2 deletions packages/app/src/components/inference/replay/ReplayPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { useInference } from '@/components/inference/InferenceContext';
import ScatterGraph from '@/components/inference/ui/ScatterGraph';
import type { ChartDefinition } from '@/components/inference/types';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
Expand All @@ -21,7 +23,7 @@ import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history';
import { track } from '@/lib/analytics';
import { cn } from '@/lib/utils';

import { buildReplayTimeline } from './buildReplayTimeline';
import { buildReplayTimeline, computeFullRunDomain } from './buildReplayTimeline';
import type { Mp4ExportError, Mp4ExportStage } from './exportMp4';
import { buildFrameData, dateAtFraction, shouldCommitFraction, spanMs } from './replayFrameData';
import { useReducedMotion } from './useReducedMotion';
Expand Down Expand Up @@ -63,7 +65,7 @@ export default function ReplayPanel({
xLabel,
}: ReplayPanelProps) {
const inference = useInference();
const { selectedModel, selectedSequence } = inference;
const { selectedModel, selectedSequence, activeHwTypes } = inference;

const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {};
const history = useBenchmarkHistory(selectedModel, isl, osl);
Expand All @@ -90,6 +92,15 @@ export default function ReplayPanel({
inference.selectedPrecisions,
]);

// Fixed axes for the whole run: take the extent across every step (not just
// the current frame) for the active hardware, so the axes stay put and the
// frontier visibly expands toward them over time instead of the chart
// refitting each frame. Recomputed when the legend's hw filter changes.
const fixedExtent = useMemo(
() => (timeline ? computeFullRunDomain(timeline, (hw) => activeHwTypes.has(hw)) : null),
[timeline, activeHwTypes],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed axes ignore legend override

Low Severity

computeFullRunDomain for fixed replay axes filters with activeHwTypes, while the embedded ScatterGraph plots and scales visible points using effectiveOfficialHwTypes (localOfficialOverride ?? activeHwTypes). After unofficial-run legend toggles set localOfficialOverride, fixed extents can include hardware the replay chart hides (or miss the visible set), so axes no longer match what is drawn.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac07c81. Configure here.

);

// Track the SVG's position inside our relative wrapper so the date overlay
// can anchor its bottom-right to the chart plot's top-right (the wrapper
// also contains the legend, so we can't anchor to the wrapper edge).
Expand Down Expand Up @@ -161,6 +172,10 @@ export default function ReplayPanel({
const [fraction, setFraction] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
// Fixed axes (default) freeze the coordinate space to the whole run so the
// frontier visibly expands over time; turning this off lets the axes refit to
// each frame's points.
const [fixedAxes, setFixedAxes] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<number | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
Expand Down Expand Up @@ -496,6 +511,9 @@ export default function ReplayPanel({
chartDefinition={chartDefinition}
transitionDuration={0}
niceAxes={false}
pinLineLabels
xExtentOverride={fixedAxes ? fixedExtent?.x : undefined}
yExtentOverride={fixedAxes ? fixedExtent?.y : undefined}
/>
<div
className="absolute -translate-y-full pointer-events-none text-2xl font-bold tabular-nums opacity-85 leading-none pb-1"
Expand Down Expand Up @@ -564,6 +582,24 @@ export default function ReplayPanel({
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch
id="replay-fixed-axes"
data-testid="replay-fixed-axes"
checked={fixedAxes}
onCheckedChange={(checked) => {
setFixedAxes(checked);
track('inference_replay_fixed_axes_toggled', { enabled: checked });
}}
/>
<Label
htmlFor="replay-fixed-axes"
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer whitespace-nowrap"
title="Keep the axes fixed across the whole run so you can see the frontier improve over time, or let them refit to each frame."
>
Fixed axes
</Label>
</div>
<Button
size="sm"
variant="default"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest';
import type { BenchmarkRow } from '@/lib/api';
import type { ChartDefinition } from '@/components/inference/types';

import { buildReplayTimeline, computeStepDomain } from '../buildReplayTimeline';
import {
buildReplayTimeline,
computeFullRunDomain,
computeStepDomain,
} from '../buildReplayTimeline';

const ALL_HW = () => true;

Expand Down Expand Up @@ -153,6 +157,51 @@ describe('buildReplayTimeline', () => {
expect(mi355xOnly.x[1]).toBeLessThan(50); // padded around 5
});

it('computeFullRunDomain spans every step, not just one frame', () => {
const rows = [
baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 100, median_intvty: 10 }, conc: 8 }),
baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 200, median_intvty: 20 }, conc: 8 }),
baseRow({
date: '2025-02-01',
metrics: { tput_per_gpu: 5000, median_intvty: 200 },
conc: 16,
}),
];
const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']);
const full = computeFullRunDomain(t, ALL_HW);
// The fixed extent reaches the run's largest observation (step 1's conc=16
// config) even though it isn't present at step 0 — that's what keeps the
// axes constant while the early frontier sits small in the corner.
expect(full.x[1]).toBeGreaterThanOrEqual(200);
expect(full.y[1]).toBeGreaterThanOrEqual(5000);
// And it never undershoots the per-step boxes.
const d0 = computeStepDomain(t, 0, ALL_HW);
expect(full.x[1]).toBeGreaterThanOrEqual(d0.x[1]);
expect(full.y[1]).toBeGreaterThanOrEqual(d0.y[1]);
});

it('computeFullRunDomain respects the hw filter', () => {
const rows = [
baseRow({
hardware: 'mi355x',
framework: 'sglang',
date: '2025-01-01',
metrics: { tput_per_gpu: 50, median_intvty: 5 },
}),
baseRow({
hardware: 'b200',
framework: 'trt',
date: '2025-02-01',
metrics: { tput_per_gpu: 5000, median_intvty: 400 },
}),
];
const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']);
const everything = computeFullRunDomain(t, ALL_HW);
const mi355xOnly = computeFullRunDomain(t, (hw) => hw.startsWith('mi355x'));
expect(everything.x[1]).toBeGreaterThanOrEqual(400);
expect(mi355xOnly.x[1]).toBeLessThan(50); // padded around 5, ignores b200
});

it('separates configs that differ in concurrency or tp', () => {
const rows = [
baseRow({ conc: 32 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,31 @@ export function computeStepDomain(
return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) };
}

// Bounding box across ALL steps for configs passing `hwFilter`. Unlike
// computeStepDomain (one step), this is the fixed extent used for the entire
// replay so the axes stay constant and the frontier visibly marches toward them
// over time instead of the axes refitting to each frame.
export function computeFullRunDomain(
timeline: ReplayTimeline,
hwFilter: (hwKey: string) => boolean,
): StepDomain {
let xMin = Infinity;
let xMax = -Infinity;
let yMin = Infinity;
let yMax = -Infinity;
for (const c of timeline.configs) {
if (!hwFilter(c.hwKey)) continue;
for (const v of c.stepValues) {
if (!v.visible) continue;
if (v.x < xMin) xMin = v.x;
if (v.x > xMax) xMax = v.x;
if (v.y < yMin) yMin = v.y;
if (v.y > yMax) yMax = v.y;
}
}
return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) };
}

const buildPointConfigId = (point: InferenceData): string => {
let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`;
if (point.disagg) key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`;
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/components/inference/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,24 @@ export interface ScatterGraphProps {
* playback).
*/
niceAxes?: boolean;
/**
* Pin each line label to a stable anchor along its roofline so it tracks the
* line smoothly instead of re-running the per-frame greedy placement (which
* makes labels teleport between candidate positions as the lines animate).
* Defaults to false. The replay panel passes true so labels keep a positional
* "affinity" across frames. Trades the static chart's per-frame de-overlap for
* positional stability — appropriate while the chart is animating.
*/
pinLineLabels?: boolean;
/**
* Fixed x/y data extents `[min, max]` to base the axes on, instead of fitting
* to the currently rendered points. The normal domain padding (and log /
* zero-baseline handling) is still applied on top. Replay passes the whole
* run's extent so the axes stay constant across the animation and you can see
* the frontier expand toward them over time.
*/
xExtentOverride?: [number, number];
yExtentOverride?: [number, number];
}
/**
* @file types.ts
Expand Down
Loading
Loading