Skip to content

Commit e1e2219

Browse files
cquil11claudeadibarra
authored
feat(replay): foreground line labels, stable anchors, and fixed axes (#434)
* fix(scatter): keep line labels in the foreground Inference performance scatter charts create line labels inside the rooflines layer, which renders before the dot groups so roofline paths stay behind the points. That left the labels painted under the scatter points and overlay marks. Add a trailing custom layer that re-raises every `.line-label` to the end of the zoomGroup after all layers render (and on zoom), so they always read as foreground — mirroring GPUGraph, whose line-label layer already renders last. `.raise()` only changes z-order; label placement and the existing de-overlap (hide-on-collision for interactivity, vertical nudge for TTFT/E2EL) are untouched, so labels still never overlap one another. Selects overlay line labels (`overlay-*`) too, so unofficial-run overlays get the same treatment. Adds an E2E assertion that visible labels follow the dot groups in DOM order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(replay): pin line labels to a stable anchor during replay In "Replay over time", line labels were re-placed every frame by the greedy placement algorithm, so they teleported between candidate positions (and the TTFT/E2EL vertical nudge reshuffled them) as the rooflines animated — visually noisy. Give each label a positional "affinity": add an opt-in `pinLineLabels` prop (set by ReplayPanel, like `transitionDuration`/`niceAxes`). When pinned, each label remembers a data-space x anchor the first time its series is seen and, on every later frame, resolves that anchor to the nearest current point on the line — so the label tracks the same spot as the line moves instead of hopping. The TTFT/E2EL de-overlap nudge is skipped while pinned so endpoint labels stay glued to their (smoothly moving) endpoints. Anchors are pruned when a series disappears. The static (non-pinned) chart is unchanged: the default branch of the shared placeLabel helper keeps the exact greedy-place + hide-on-collision behavior, preserving the no-overlap guarantee. Works for both official and overlay (`?unofficialrun=`) line labels. Extracts pointNearestX into its own module with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(replay): fixed axes across the run, with a toggle By default the replay axes now stay fixed to the whole run's extent (for the active hardware) instead of refitting to each frame, so you can watch the frontier expand toward a constant coordinate space over time. A "Fixed axes" switch in the replay controls flips back to per-frame (dynamic) axes that hug the current frontier. - buildReplayTimeline: add computeFullRunDomain(timeline, hwFilter) — the bounding box across every step for the filtered configs. - ScatterGraph: add optional xExtentOverride/yExtentOverride; when set, the axis domain is based on them (normal padding + log/zero-baseline handling still applied) instead of the current points. Undefined for every non-replay caller, so the static chart is unchanged. - ReplayPanel: compute the full-run extent for the active hardware and pass it when "Fixed axes" is on (default); pass undefined when off. Unit tests cover computeFullRunDomain (spans all steps, respects the hw filter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(inference): keep line labels foregrounded and pinned without the identity zoom replay * test(inference): cover line-label foreground after zoom and replay foreground + fixed-axes toggle * test(inference): drop flaky zoom-reset cleanup from foreground-after-zoom spec * test(inference): make fixed-axes E2E robust to which axis grows (compare x|y pair, assert fixed stays constant) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: adibarra <93070681+adibarra@users.noreply.github.com>
1 parent 51c3f34 commit e1e2219

10 files changed

Lines changed: 518 additions & 148 deletions

File tree

packages/app/cypress/e2e/inference-replay.cy.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ const openReplayDialog = () => {
77
cy.get('[data-testid="export-mp4-button"]').first().click();
88
};
99

10+
const setReplayScrubber = (v: number) =>
11+
cy.get('[data-testid="replay-scrubber"]').then(($el) => {
12+
const el = $el[0] as HTMLInputElement;
13+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')!.set!;
14+
setter.call(el, String(v));
15+
el.dispatchEvent(new Event('input', { bubbles: true }));
16+
el.dispatchEvent(new Event('change', { bubbles: true }));
17+
});
18+
19+
// Combined "<last-x-tick>|<last-y-tick>" signature so a change in EITHER axis is
20+
// detected. The run can grow in x, y, or both between frames, so asserting on
21+
// the y-axis alone would falsely fail when only x expands.
22+
const replayAxisExtent = () =>
23+
cy.get('[data-testid="replay-panel-chart-0"] svg').then(($svg) => {
24+
const svg = $svg[0];
25+
const lastTick = (sel: string) => {
26+
const els = [...svg.querySelectorAll(sel)];
27+
return els.length > 0 ? (els.at(-1)!.textContent ?? '').trim() : '';
28+
};
29+
return `${lastTick('g.x-axis text')}|${lastTick('g.y-axis text')}`;
30+
});
31+
1032
describe('Inference Replay', () => {
1133
before(() => {
1234
cy.window().then((win) => {
@@ -125,6 +147,75 @@ describe('Inference Replay', () => {
125147
});
126148
});
127149

150+
it('renders line labels in the foreground during replay', () => {
151+
cy.get('body').then(($body) => {
152+
if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return;
153+
// Enable line labels inside the replay panel (scoped — the parent chart
154+
// renders the same control behind the dialog).
155+
cy.get('[data-testid="replay-panel-chart-0"]').within(() => {
156+
cy.get('[data-testid="scatter-line-labels"]').then(($el) => {
157+
if ($el.attr('data-state') !== 'checked') cy.wrap($el).click();
158+
});
159+
});
160+
cy.get('[data-testid="replay-panel-chart-0"] svg g.line-label', { timeout: 6000 }).should(
161+
'have.length.greaterThan',
162+
0,
163+
);
164+
// The shared-renderer foreground raise must apply to the replay chart too.
165+
cy.get('[data-testid="replay-panel-chart-0"] svg').then(($svg) => {
166+
const svg = $svg[0];
167+
const dots = svg.querySelectorAll('.dot-group');
168+
const labels = svg.querySelectorAll('g.line-label');
169+
if (dots.length === 0 || labels.length === 0) return;
170+
const lastDot = dots.item(dots.length - 1)!;
171+
const firstLabel = labels.item(0)!;
172+
expect(
173+
lastDot.compareDocumentPosition(firstLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
174+
'replay line label follows the scatter points (foreground)',
175+
).to.be.greaterThan(0);
176+
});
177+
});
178+
});
179+
180+
it('Fixed axes stay constant across frames; toggling off refits per frame', () => {
181+
cy.get('body').then(($body) => {
182+
if ($body.find('[data-testid="replay-scrubber"]').length === 0) {
183+
cy.log('Replay history fixture has < 2 dates; skipping fixed-axes check');
184+
return;
185+
}
186+
// Fixed axes is the default — the extent is the whole-run box, so the first
187+
// and last frame share the same axes (this is the feature's core invariant,
188+
// independent of which axis the frontier grows along).
189+
cy.get('[data-testid="replay-fixed-axes"]').should('have.attr', 'data-state', 'checked');
190+
setReplayScrubber(0);
191+
cy.wait(300);
192+
replayAxisExtent().then((fixedAtStart) => {
193+
setReplayScrubber(1_000_000); // clamps to the scrubber max → last frame
194+
cy.wait(300);
195+
replayAxisExtent().then((fixedAtEnd) => {
196+
expect(fixedAtEnd, 'fixed axes are identical at the first and last frame').to.equal(
197+
fixedAtStart,
198+
);
199+
200+
// Turn fixed axes off → the first frame refits to just that frame's
201+
// (smaller) frontier, so the extent differs from the whole-run box in
202+
// at least one axis (compared as an x|y pair, not y alone).
203+
cy.get('[data-testid="replay-fixed-axes"]').click();
204+
setReplayScrubber(0);
205+
cy.wait(300);
206+
replayAxisExtent().then((dynamicAtStart) => {
207+
expect(
208+
dynamicAtStart,
209+
'per-frame axes at the first frame differ from the whole-run fixed extent',
210+
).not.to.equal(fixedAtStart);
211+
});
212+
// Restore the default.
213+
cy.get('[data-testid="replay-fixed-axes"]').click();
214+
});
215+
});
216+
});
217+
});
218+
128219
it('closes the modal', () => {
129220
cy.get('body').then(($body) => {
130221
if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return;

packages/app/cypress/e2e/line-labels.cy.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,75 @@ describe('Line Labels Toggle', () => {
3939
);
4040
});
4141

42+
it('line labels render in the foreground, after the scatter points', () => {
43+
// Labels were toggled on in the test above and remain on here.
44+
cy.get('[data-testid="scatter-graph"] svg g.line-label').should('have.length.greaterThan', 0);
45+
46+
cy.get('[data-testid="scatter-graph"] svg').then(($svg) => {
47+
const svg = $svg[0];
48+
const dots = svg.querySelectorAll('.dot-group');
49+
const labels = svg.querySelectorAll('g.line-label');
50+
expect(dots.length, 'scatter dot groups exist').to.be.greaterThan(0);
51+
expect(labels.length, 'line labels exist').to.be.greaterThan(0);
52+
53+
// Every label must paint after every dot group. Comparing the *last* dot
54+
// group against the *first* label is sufficient: if the earliest label
55+
// follows the latest dot in document order, all labels are in front.
56+
const lastDot = dots.item(dots.length - 1)!;
57+
const firstLabel = labels.item(0)!;
58+
const relation = lastDot.compareDocumentPosition(firstLabel);
59+
expect(
60+
relation & Node.DOCUMENT_POSITION_FOLLOWING,
61+
'line label follows the scatter points in DOM order (foreground)',
62+
).to.be.greaterThan(0);
63+
});
64+
});
65+
66+
it('line labels stay in the foreground after zooming', () => {
67+
// Regression guard: the foreground raise must run on every render (in the
68+
// shared renderer), not rely on a zoom-transform replay re-firing onZoom.
69+
// Ensure labels are on (a previous test may have left them on).
70+
cy.get('#scatter-line-labels').then(($el) => {
71+
if ($el.attr('data-state') !== 'checked') cy.wrap($el).click();
72+
});
73+
cy.get('[data-testid="scatter-graph"] svg g.line-label').should('have.length.greaterThan', 0);
74+
75+
// The chart requires Shift for wheel zoom (so bare scroll doesn't hijack
76+
// the page). Dispatch a few shift+wheel events over the plot to zoom in.
77+
cy.get('[data-testid="scatter-graph"] svg').then(($svg) => {
78+
const svg = $svg[0];
79+
const r = svg.getBoundingClientRect();
80+
for (let i = 0; i < 3; i++) {
81+
svg.dispatchEvent(
82+
new WheelEvent('wheel', {
83+
deltaY: -240,
84+
clientX: r.x + r.width / 2,
85+
clientY: r.y + 150,
86+
shiftKey: true,
87+
bubbles: true,
88+
cancelable: true,
89+
}),
90+
);
91+
}
92+
});
93+
cy.wait(300);
94+
95+
cy.get('[data-testid="scatter-graph"] svg').then(($svg) => {
96+
const svg = $svg[0];
97+
const dots = svg.querySelectorAll('.dot-group');
98+
const labels = svg.querySelectorAll('g.line-label');
99+
expect(labels.length, 'line labels still exist after zoom').to.be.greaterThan(0);
100+
const lastDot = dots.item(dots.length - 1)!;
101+
const firstLabel = labels.item(0)!;
102+
expect(
103+
lastDot.compareDocumentPosition(firstLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
104+
'line label still follows the scatter points after zoom (foreground)',
105+
).to.be.greaterThan(0);
106+
});
107+
// No zoom reset needed: the next test toggles labels off (zoom-agnostic) and
108+
// the later tests re-visit the page fresh.
109+
});
110+
42111
it('toggling Line Labels off removes label elements', () => {
43112
cy.get('#scatter-line-labels').click();
44113
cy.get('#scatter-line-labels').should('have.attr', 'data-state', 'unchecked');

packages/app/src/components/inference/replay/ReplayPanel.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { useInference } from '@/components/inference/InferenceContext';
1010
import ScatterGraph from '@/components/inference/ui/ScatterGraph';
1111
import type { ChartDefinition } from '@/components/inference/types';
1212
import { Button } from '@/components/ui/button';
13+
import { Label } from '@/components/ui/label';
14+
import { Switch } from '@/components/ui/switch';
1315
import {
1416
Select,
1517
SelectContent,
@@ -21,7 +23,7 @@ import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history';
2123
import { track } from '@/lib/analytics';
2224
import { cn } from '@/lib/utils';
2325

24-
import { buildReplayTimeline } from './buildReplayTimeline';
26+
import { buildReplayTimeline, computeFullRunDomain } from './buildReplayTimeline';
2527
import type { Mp4ExportError, Mp4ExportStage } from './exportMp4';
2628
import { buildFrameData, dateAtFraction, shouldCommitFraction, spanMs } from './replayFrameData';
2729
import { useReducedMotion } from './useReducedMotion';
@@ -63,7 +65,7 @@ export default function ReplayPanel({
6365
xLabel,
6466
}: ReplayPanelProps) {
6567
const inference = useInference();
66-
const { selectedModel, selectedSequence } = inference;
68+
const { selectedModel, selectedSequence, activeHwTypes } = inference;
6769

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

95+
// Fixed axes for the whole run: take the extent across every step (not just
96+
// the current frame) for the active hardware, so the axes stay put and the
97+
// frontier visibly expands toward them over time instead of the chart
98+
// refitting each frame. Recomputed when the legend's hw filter changes.
99+
const fixedExtent = useMemo(
100+
() => (timeline ? computeFullRunDomain(timeline, (hw) => activeHwTypes.has(hw)) : null),
101+
[timeline, activeHwTypes],
102+
);
103+
93104
// Track the SVG's position inside our relative wrapper so the date overlay
94105
// can anchor its bottom-right to the chart plot's top-right (the wrapper
95106
// also contains the legend, so we can't anchor to the wrapper edge).
@@ -161,6 +172,10 @@ export default function ReplayPanel({
161172
const [fraction, setFraction] = useState(0);
162173
const [playing, setPlaying] = useState(false);
163174
const [speed, setSpeed] = useState(1);
175+
// Fixed axes (default) freeze the coordinate space to the whole run so the
176+
// frontier visibly expands over time; turning this off lets the axes refit to
177+
// each frame's points.
178+
const [fixedAxes, setFixedAxes] = useState(true);
164179
const [isExporting, setIsExporting] = useState(false);
165180
const [exportProgress, setExportProgress] = useState<number | null>(null);
166181
const [exportError, setExportError] = useState<string | null>(null);
@@ -496,6 +511,9 @@ export default function ReplayPanel({
496511
chartDefinition={chartDefinition}
497512
transitionDuration={0}
498513
niceAxes={false}
514+
pinLineLabels
515+
xExtentOverride={fixedAxes ? fixedExtent?.x : undefined}
516+
yExtentOverride={fixedAxes ? fixedExtent?.y : undefined}
499517
/>
500518
<div
501519
className="absolute -translate-y-full pointer-events-none text-2xl font-bold tabular-nums opacity-85 leading-none pb-1"
@@ -564,6 +582,24 @@ export default function ReplayPanel({
564582
))}
565583
</SelectContent>
566584
</Select>
585+
<div className="flex items-center gap-2">
586+
<Switch
587+
id="replay-fixed-axes"
588+
data-testid="replay-fixed-axes"
589+
checked={fixedAxes}
590+
onCheckedChange={(checked) => {
591+
setFixedAxes(checked);
592+
track('inference_replay_fixed_axes_toggled', { enabled: checked });
593+
}}
594+
/>
595+
<Label
596+
htmlFor="replay-fixed-axes"
597+
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer whitespace-nowrap"
598+
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."
599+
>
600+
Fixed axes
601+
</Label>
602+
</div>
567603
<Button
568604
size="sm"
569605
variant="default"

packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest';
33
import type { BenchmarkRow } from '@/lib/api';
44
import type { ChartDefinition } from '@/components/inference/types';
55

6-
import { buildReplayTimeline, computeStepDomain } from '../buildReplayTimeline';
6+
import {
7+
buildReplayTimeline,
8+
computeFullRunDomain,
9+
computeStepDomain,
10+
} from '../buildReplayTimeline';
711

812
const ALL_HW = () => true;
913

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

160+
it('computeFullRunDomain spans every step, not just one frame', () => {
161+
const rows = [
162+
baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 100, median_intvty: 10 }, conc: 8 }),
163+
baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 200, median_intvty: 20 }, conc: 8 }),
164+
baseRow({
165+
date: '2025-02-01',
166+
metrics: { tput_per_gpu: 5000, median_intvty: 200 },
167+
conc: 16,
168+
}),
169+
];
170+
const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']);
171+
const full = computeFullRunDomain(t, ALL_HW);
172+
// The fixed extent reaches the run's largest observation (step 1's conc=16
173+
// config) even though it isn't present at step 0 — that's what keeps the
174+
// axes constant while the early frontier sits small in the corner.
175+
expect(full.x[1]).toBeGreaterThanOrEqual(200);
176+
expect(full.y[1]).toBeGreaterThanOrEqual(5000);
177+
// And it never undershoots the per-step boxes.
178+
const d0 = computeStepDomain(t, 0, ALL_HW);
179+
expect(full.x[1]).toBeGreaterThanOrEqual(d0.x[1]);
180+
expect(full.y[1]).toBeGreaterThanOrEqual(d0.y[1]);
181+
});
182+
183+
it('computeFullRunDomain respects the hw filter', () => {
184+
const rows = [
185+
baseRow({
186+
hardware: 'mi355x',
187+
framework: 'sglang',
188+
date: '2025-01-01',
189+
metrics: { tput_per_gpu: 50, median_intvty: 5 },
190+
}),
191+
baseRow({
192+
hardware: 'b200',
193+
framework: 'trt',
194+
date: '2025-02-01',
195+
metrics: { tput_per_gpu: 5000, median_intvty: 400 },
196+
}),
197+
];
198+
const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']);
199+
const everything = computeFullRunDomain(t, ALL_HW);
200+
const mi355xOnly = computeFullRunDomain(t, (hw) => hw.startsWith('mi355x'));
201+
expect(everything.x[1]).toBeGreaterThanOrEqual(400);
202+
expect(mi355xOnly.x[1]).toBeLessThan(50); // padded around 5, ignores b200
203+
});
204+
156205
it('separates configs that differ in concurrency or tp', () => {
157206
const rows = [
158207
baseRow({ conc: 32 }),

packages/app/src/components/inference/replay/buildReplayTimeline.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@ export function computeStepDomain(
5656
return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) };
5757
}
5858

59+
// Bounding box across ALL steps for configs passing `hwFilter`. Unlike
60+
// computeStepDomain (one step), this is the fixed extent used for the entire
61+
// replay so the axes stay constant and the frontier visibly marches toward them
62+
// over time instead of the axes refitting to each frame.
63+
export function computeFullRunDomain(
64+
timeline: ReplayTimeline,
65+
hwFilter: (hwKey: string) => boolean,
66+
): StepDomain {
67+
let xMin = Infinity;
68+
let xMax = -Infinity;
69+
let yMin = Infinity;
70+
let yMax = -Infinity;
71+
for (const c of timeline.configs) {
72+
if (!hwFilter(c.hwKey)) continue;
73+
for (const v of c.stepValues) {
74+
if (!v.visible) continue;
75+
if (v.x < xMin) xMin = v.x;
76+
if (v.x > xMax) xMax = v.x;
77+
if (v.y < yMin) yMin = v.y;
78+
if (v.y > yMax) yMax = v.y;
79+
}
80+
}
81+
return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) };
82+
}
83+
5984
const buildPointConfigId = (point: InferenceData): string => {
6085
let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`;
6186
if (point.disagg) key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`;

packages/app/src/components/inference/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,24 @@ export interface ScatterGraphProps {
481481
* playback).
482482
*/
483483
niceAxes?: boolean;
484+
/**
485+
* Pin each line label to a stable anchor along its roofline so it tracks the
486+
* line smoothly instead of re-running the per-frame greedy placement (which
487+
* makes labels teleport between candidate positions as the lines animate).
488+
* Defaults to false. The replay panel passes true so labels keep a positional
489+
* "affinity" across frames. Trades the static chart's per-frame de-overlap for
490+
* positional stability — appropriate while the chart is animating.
491+
*/
492+
pinLineLabels?: boolean;
493+
/**
494+
* Fixed x/y data extents `[min, max]` to base the axes on, instead of fitting
495+
* to the currently rendered points. The normal domain padding (and log /
496+
* zero-baseline handling) is still applied on top. Replay passes the whole
497+
* run's extent so the axes stay constant across the animation and you can see
498+
* the frontier expand toward them over time.
499+
*/
500+
xExtentOverride?: [number, number];
501+
yExtentOverride?: [number, number];
484502
/**
485503
* Stable run numbering (entry string `date~rRunId` → 1-based number) shared with
486504
* the comparison changelog so legend labels match it exactly. Numbers index ALL

0 commit comments

Comments
 (0)