diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index 8f3abfb5..268f059c 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -6,7 +6,7 @@ import { useEphemeralUrlState } from '@/hooks/useUrlState'; import { rememberChartStateInUrl } from '@/lib/url-state'; import * as d3 from 'd3'; import dynamic from 'next/dynamic'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTheme } from 'next-themes'; import { @@ -69,6 +69,26 @@ import { matchKnownConfigIssues, pointMatchesIssue } from '@/lib/known-issues'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; +import { + clampIsoX, + clearPerfRulers, + computeIsoXRulerGeometry, + computePerfRulerLabelLayouts, + deletePerfRuler, + EMPTY_PERF_RULER_STATE, + intersectPathAtX, + isPerfRulerCurveVisible, + movePerfRulerIsoX, + nextPerfRulerState, + pathXExtent, + perfRulerCurveSet, + prunePerfRulers, + renderPerfRulers, + type PerfRulerEndInput, + type PerfRulerGeometry, + type PerfRulerRenderEntry, + type PerfRulerState, +} from '@/lib/d3-chart/layers/perf-ruler'; import { countPowerTiers, MeasuredPowerSummary, @@ -90,6 +110,23 @@ const FixedSequenceLogDialog = dynamic(() => const CHART_MARGIN = { top: 24, right: 10, bottom: 60, left: 60 }; +// Roofline paths in this chart carry the class `roofline-` where key is +// `${date}_${hwKey}_${precision}` (see `renderRooflines`). The perf ruler +// identifies curves by that class token. +const ROOFLINE_CLASS_PREFIX = 'roofline-'; + +// Scales as currently drawn: the base render scales rescaled through the +// active zoom transform (identity when the chart is not zoomed). +const currentZoomRenderContext = (svg: SVGSVGElement, ctx: RenderContext): RenderContext => { + const transform = d3.zoomTransform(svg); + if (transform.k === 1 && transform.x === 0 && transform.y === 0) return ctx; + return { + ...ctx, + xScale: transform.rescaleX(ctx.xScale as ContinuousScale), + yScale: transform.rescaleY(ctx.yScale as ContinuousScale), + }; +}; + // Label text combines the hw config (display label) and the date so // both dimensions of the GPU comparison view are legible on the chart, // not only the legend. Falls back to the raw hwKey if the config @@ -110,7 +147,11 @@ const GPU_STRINGS = { parallelismLabels: 'Parallelism Labels', concurrencyLabels: '# Concurrent Sessions', lineLabels: 'Line Labels', + perfRuler: 'Perf Ruler', + perfRulerInfo: + 'Click two curves to place a vertical ruler, then drag it to measure the performance multiple between them at any x value — across dates of the same chip config or across chip configs. Repeat to add more rulers (up to 8); hover a ruler and click × to delete it. Turning the toggle off clears all rulers.', resetFilter: 'Reset filter', + clearPerfRulers: (count: number) => `Clear rulers (${count})`, quickFilters: (count: number) => (count > 0 ? `Quick Filters (${count})` : 'Quick Filters'), noData: 'No data available', noDataHint: 'Please change the model, sequence, precision, date range or chip selection.', @@ -123,7 +164,11 @@ const GPU_STRINGS = { parallelismLabels: '并行配置标签', concurrencyLabels: '并发会话数', lineLabels: '曲线标签', + perfRuler: '性能标尺', + perfRulerInfo: + '先点击两条曲线放置垂直标尺,再拖动标尺,测量任意横坐标下两条曲线之间的性能倍数——既可比较同一芯片配置的不同日期,也可比较不同芯片配置。重复操作可添加多把标尺(最多 8 把);悬停标尺并点击 × 可删除该标尺。关闭开关将清除所有标尺。', resetFilter: '重置筛选', + clearPerfRulers: (count: number) => `清除标尺(${count})`, quickFilters: (count: number) => (count > 0 ? `快捷筛选(${count})` : '快捷筛选'), noData: '暂无数据', noDataHint: '请调整模型、序列长度、精度、日期范围或芯片选项。', @@ -663,6 +708,399 @@ const GPUGraph = React.memo( paletteIdentity, ]); + // ── Perf ruler (opt-in: click two curves, drag the ruler to any iso-x) ── + // Same curve-to-curve ISO-X semantics as ScatterGraph, applied to the + // date/chip comparison view: each measurement is two rendered roofline + // paths (class tokens `roofline-__`) plus an + // iso-x stored in DATA space so it survives zoom and metric changes. + // Any two curves may be paired — two dates of the same chip config, two + // chip configs on the same date, or a mix — which is the point of this + // view: quantify the multiple between comparison series at a glance. + const [perfRulerMode, setPerfRulerMode] = useState(false); + const [perfRulerState, setPerfRulerState] = useState(EMPTY_PERF_RULER_STATE); + // Draw passes read mode/state through refs so toggling off clears the + // rulers in the same pre-paint layout pass (no lingering frame). + const perfRulerModeRef = useRef(perfRulerMode); + perfRulerModeRef.current = perfRulerMode; + const perfRulerStateRef = useRef(perfRulerState); + perfRulerStateRef.current = perfRulerState; + // Live per-ruler iso-x overrides while dragging (committed on drag end). + // Cleared in an effect when committed state changes, not per render, so + // an unrelated re-render mid-drag cannot snap the dragged ruler back. + const perfRulerLiveIsoXRef = useRef(new Map()); + useLayoutEffect(() => { + perfRulerLiveIsoXRef.current.clear(); + }, [perfRulerState]); + + // Scales/group from the most recent draw pass — click and drag handlers + // convert between pixel and data space with the exact scales the chart + // is currently drawn with. + const perfRulerDrawCtxRef = useRef<{ + zoomGroup: d3.Selection; + xScale: ContinuousScale; + yScale: ContinuousScale; + width: number; + height: number; + } | null>(null); + // Forward ref: the drag frame needs to redraw, but drawPerfRuler is + // defined below (it also attaches the drag behavior — benign cycle). + const drawPerfRulerRef = useRef< + | (( + zoomGroup: d3.Selection, + xScale: ContinuousScale, + yScale: ContinuousScale, + width: number, + height: number, + ) => void) + | null + >(null); + + // Completion clamp: the stored iso-x must lie inside the curve pair's + // overlapping x range, or the completed ruler could render nowhere (no + // line and no drag handle to recover it). Null rejects the measurement + // and keeps the draft anchored. + const clampPerfRulerIsoXToOverlap = useCallback( + (curveA: string, curveB: string, isoX: number): number | null => { + const ctx = perfRulerDrawCtxRef.current; + if (!ctx) return null; + const [nodeA, nodeB] = [curveA, curveB].map((cls) => + ctx.zoomGroup.select(`.${CSS.escape(cls)}`).node(), + ); + if (!nodeA || !nodeB || typeof nodeA.getPointAtLength !== 'function') return null; + const extentA = pathXExtent(nodeA); + const extentB = pathXExtent(nodeB); + if (!extentA || !extentB) return null; + const clamped = clampIsoX(Number(ctx.xScale(isoX)), extentA, extentB); + return clamped === null ? null : Number(ctx.xScale.invert(clamped)); + }, + [], + ); + + // Curve click (widened hit strokes): iso-x is the click's x pixel + // through the CURRENT rendered x scale, stored in data space. + const handlePerfRulerCurveClick = useCallback( + (curve: string, pixelX: number) => { + const ctx = perfRulerDrawCtxRef.current; + if (!ctx) return; + track('gpu_timeseries_perf_ruler_curve_clicked', { curve }); + const isoX = Number(ctx.xScale.invert(pixelX)); + setPerfRulerState((prev) => + nextPerfRulerState(prev, { curve, isoX }, clampPerfRulerIsoXToOverlap), + ); + chartRef.current?.dismissTooltip(); + chartRef.current?.hideTooltip(); + }, + [clampPerfRulerIsoXToOverlap], + ); + const perfRulerCurveClickRef = useRef(handlePerfRulerCurveClick); + perfRulerCurveClickRef.current = handlePerfRulerCurveClick; + + // Points sit on curves: a ruler-mode click on a data point behaves like + // clicking the point's (date, chip, precision) curve at that point's x. + // Ruler-mode clicks measure INSTEAD of pinning the tooltip, so drop the + // pin the shared click handler applied just before this callback ran. + const handlePerfRulerPointClick = useCallback( + (point: InferenceData) => { + const ctx = perfRulerDrawCtxRef.current; + if (!ctx) return; + const curve = `${ROOFLINE_CLASS_PREFIX}${point.date}_${String(point.hwKey)}_${point.precision}`; + // Single-point series render no roofline path — nothing to measure. + if (ctx.zoomGroup.select(`.${CSS.escape(curve)}`).empty()) return; + setPerfRulerState((prev) => + nextPerfRulerState(prev, { curve, isoX: point.x }, clampPerfRulerIsoXToOverlap), + ); + chartRef.current?.dismissTooltip(); + chartRef.current?.hideTooltip(); + }, + [clampPerfRulerIsoXToOverlap], + ); + + // Read by the long-lived D3 click closure in the tooltip config, which is + // captured at render time — refs over closures. + const perfRulerRef = useRef({ mode: perfRulerMode, onPointClick: handlePerfRulerPointClick }); + perfRulerRef.current = { mode: perfRulerMode, onPointClick: handlePerfRulerPointClick }; + + // Turning the toggle off clears ALL rulers and any in-progress selection + // (the switch handler also clears synchronously; this covers programmatic + // mode changes). `clearPerfRulers` bails out with the same reference + // when there is nothing to clear. + useEffect(() => { + if (!perfRulerMode) setPerfRulerState(clearPerfRulers); + }, [perfRulerMode]); + + // Invisible widened hit strokes over every rendered roofline path make + // the curves themselves clickable in ruler mode. This chart renders + // rooflines straight into the zoom group (no `.rooflines-layer` wrapper), + // so the hit layer is inserted right after the LAST roofline path: above + // the visible strokes, below the dot-groups (which the renderer raises to + // the end), so point hover/click behavior is untouched. The layer only + // exists while ruler mode is on. + const syncPerfRulerHitPaths = useCallback( + (zoomGroup: d3.Selection) => { + let hitLayer = zoomGroup.select('.perf-ruler-hits'); + const rooflineNodes = zoomGroup + .selectAll('.roofline-path') + .nodes(); + const lastRoofline = rooflineNodes.at(-1); + if (!perfRulerModeRef.current || !lastRoofline) { + hitLayer.remove(); + return; + } + if (hitLayer.empty()) { + const node = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + node.setAttribute('class', 'perf-ruler-hits'); + lastRoofline.after(node); + hitLayer = d3.select(node) as typeof hitLayer; + } else if (hitLayer.node()!.previousElementSibling !== lastRoofline) { + // A data re-render may have appended new roofline paths after the + // hit layer; keep the hit strokes directly above every roofline. + lastRoofline.after(hitLayer.node()!); + } + interface HitEntry { + curve: string; + d: string; + } + const entries: HitEntry[] = []; + for (const node of rooflineNodes) { + // The identity token is the curve-specific class, e.g. + // `roofline-2026-08-31_MI355X_fp4`. + const curve = [...node.classList].find((cls) => cls !== 'roofline-path'); + const d = node.getAttribute('d'); + // Legend hover dims curves to a small non-zero opacity (still + // measurable); anything at opacity 0 must not be clickable. + if (!curve || !d || !isPerfRulerCurveVisible(node.style.opacity)) continue; + entries.push({ curve, d }); + } + const selected = perfRulerCurveSet(perfRulerStateRef.current); + hitLayer + .selectAll('.perf-ruler-hit') + .data(entries, (e) => e.curve) + .join('path') + .attr('class', 'perf-ruler-hit') + .attr('fill', 'none') + .attr('d', (e) => e.d) + .attr('stroke', 'var(--primary)') + // Selected curves get a faint halo as feedback; unselected hit + // strokes are fully transparent (`pointer-events: stroke` still + // hit-tests the invisible stroke geometry). + .attr('stroke-opacity', (e) => (selected.has(e.curve) ? 0.18 : 0)) + .attr('stroke-width', 13) + .style('pointer-events', 'stroke') + .style('cursor', 'crosshair') + .on('click', (event: MouseEvent, e: HitEntry) => { + event.stopPropagation(); + const [pixelX] = d3.pointer(event, zoomGroup.node()); + perfRulerCurveClickRef.current(e.curve, pixelX); + }); + }, + [], + ); + + // Horizontal drag on a ruler line fine-tunes that ruler's iso-x. + // rAF-throttled: each frame clamps the pointer x to the curve pair's + // overlapping x range, updates the live iso-x, and redraws; the value + // commits to React state on drag end. d3.drag stops mousedown + // propagation itself, so dragging a ruler never pans the chart. + const perfRulerDragTargetRef = useRef<{ id: number; pixelX: number } | null>(null); + const perfRulerDragFrameRef = useRef(null); + const applyPerfRulerDragFrame = useCallback(() => { + const ctx = perfRulerDrawCtxRef.current; + const target = perfRulerDragTargetRef.current; + if (!ctx || !target) return; + const ruler = perfRulerStateRef.current.rulers.find((r) => r.id === target.id); + if (!ruler) return; + const [nodeA, nodeB] = [ruler.curveA, ruler.curveB].map((cls) => + ctx.zoomGroup.select(`.${CSS.escape(cls)}`).node(), + ); + if (!nodeA || !nodeB || typeof nodeA.getPointAtLength !== 'function') return; + const extentA = pathXExtent(nodeA); + const extentB = pathXExtent(nodeB); + if (!extentA || !extentB) return; + const clamped = clampIsoX(target.pixelX, extentA, extentB); + if (clamped === null) return; + perfRulerLiveIsoXRef.current.set(target.id, Number(ctx.xScale.invert(clamped))); + drawPerfRulerRef.current?.(ctx.zoomGroup, ctx.xScale, ctx.yScale, ctx.width, ctx.height); + }, []); + const applyPerfRulerDragFrameRef = useRef(applyPerfRulerDragFrame); + applyPerfRulerDragFrameRef.current = applyPerfRulerDragFrame; + + const perfRulerDrag = useMemo( + () => + d3 + .drag() + .on( + 'drag', + ( + event: d3.D3DragEvent, + entry: PerfRulerRenderEntry, + ) => { + perfRulerDragTargetRef.current = { id: entry.id, pixelX: event.x }; + if (perfRulerDragFrameRef.current === null) { + perfRulerDragFrameRef.current = requestAnimationFrame(() => { + perfRulerDragFrameRef.current = null; + applyPerfRulerDragFrameRef.current(); + }); + } + }, + ) + .on('end', () => { + // Flush the pending frame, then commit this ruler's iso-x. + if (perfRulerDragFrameRef.current !== null) { + cancelAnimationFrame(perfRulerDragFrameRef.current); + perfRulerDragFrameRef.current = null; + applyPerfRulerDragFrameRef.current(); + } + const target = perfRulerDragTargetRef.current; + perfRulerDragTargetRef.current = null; + if (!target) return; + const isoX = perfRulerLiveIsoXRef.current.get(target.id); + if (isoX === undefined) return; + setPerfRulerState((prev) => movePerfRulerIsoX(prev, target.id, isoX)); + }), + [], + ); + + // Draw (or clear) the hit strokes and all rulers for the current draw + // pass. Stable callback: render/zoom passes read mode, rulers, and live + // iso-x values through refs, so the custom layer needs no perf-ruler + // dependencies and a ruler interaction never rebuilds the chart. + const drawPerfRuler = useCallback( + ( + zoomGroup: d3.Selection, + xScale: ContinuousScale, + yScale: ContinuousScale, + width: number, + height: number, + ) => { + perfRulerDrawCtxRef.current = { zoomGroup, xScale, yScale, width, height }; + syncPerfRulerHitPaths(zoomGroup); + const state = perfRulerStateRef.current; + const entries: PerfRulerRenderEntry[] = []; + if (perfRulerModeRef.current && state.rulers.length > 0) { + // Both ends of each ruler are interpolated on the RENDERED paths so + // the ruler matches the drawn curves exactly at any zoom level + // (rooflines redraw before this layer runs). A ruler whose curve is + // missing this frame (legend-hidden series drop their paths in this + // chart) or does not span the iso-x draws nothing but KEEPS its + // state, so re-showing the series brings it back. + const geometries: (PerfRulerGeometry | null)[] = state.rulers.map((ruler) => { + const isoX = perfRulerLiveIsoXRef.current.get(ruler.id) ?? ruler.isoX; + const pixelX = xScale(isoX); + const ends: PerfRulerEndInput[] = []; + for (const cls of [ruler.curveA, ruler.curveB]) { + const node = zoomGroup.select(`.${CSS.escape(cls)}`).node(); + if (!node || typeof node.getPointAtLength !== 'function') break; + if (!isPerfRulerCurveVisible(node.style.opacity)) break; + const hit = intersectPathAtX(node, pixelX); + if (!hit) break; + ends.push({ py: hit.y, rawY: yScale.invert(hit.y) }); + } + return ends.length === 2 ? computeIsoXRulerGeometry(pixelX, ends[0], ends[1]) : null; + }); + // Lay out all labels together so overlapping labels nudge apart. + const layouts = computePerfRulerLabelLayouts(geometries, { + chartWidth: width, + chartHeight: height, + }); + for (const [index, ruler] of state.rulers.entries()) { + const geometry = geometries[index]; + const layout = layouts[index]; + if (geometry && layout) entries.push({ id: ruler.id, geometry, layout }); + } + } + renderPerfRulers(zoomGroup, entries, { + color: 'var(--primary)', + halo: 'var(--background)', + onDelete: (id) => { + track('gpu_timeseries_perf_ruler_deleted'); + setPerfRulerState((prev) => deletePerfRuler(prev, id)); + }, + }); + // (Re)attach the horizontal drag behavior to the (possibly fresh) + // drag handles the render pass just joined — their datum (the + // render entry) tells the drag which ruler it moves. + const dragHandles = zoomGroup.selectAll( + '.perf-ruler .pr-drag', + ); + if (!dragHandles.empty()) dragHandles.call(perfRulerDrag); + }, + [syncPerfRulerHitPaths, perfRulerDrag], + ); + drawPerfRulerRef.current = drawPerfRuler; + + // Custom layer: full renders (data/metric passes) redraw the rulers after + // the rooflines, and onZoom keeps them glued to the curves during + // pan/zoom. Lives inside the zoom group so it is clipped and PNG-exported + // like any other mark. Stable identity — it reads everything via refs. + const perfRulerLayer: CustomLayerConfig = useMemo( + () => ({ + type: 'custom', + key: 'perf-ruler', + render: (zoomGroup, ctx) => + drawPerfRuler( + zoomGroup, + (ctx.renderedXScale ?? ctx.xScale) as ContinuousScale, + (ctx.renderedYScale ?? ctx.yScale) as ContinuousScale, + ctx.width, + ctx.height, + ), + onZoom: (zoomGroup, ctx) => + drawPerfRuler( + zoomGroup, + ctx.newXScale as ContinuousScale, + ctx.newYScale as ContinuousScale, + ctx.width, + ctx.height, + ), + }), + [drawPerfRuler], + ); + + // Render context from the last D3 render — lets the ruler effect redraw + // with the same layout/scales the chart was drawn with. + const lastRenderCtxRef = useRef(null); + const getDisplaySelection = useCallback(() => { + const svg = chartRef.current?.getSvgElement?.(); + const ctx = lastRenderCtxRef.current; + if (!svg || !ctx) return null; + const zoomGroup = d3.select(svg).select('.zoom-group'); + return zoomGroup.empty() ? null : { svg, ctx, zoomGroup }; + }, []); + + // Perf-ruler decorations: refresh the curve hit strokes and redraw all + // rulers whenever the mode, ruler state, or the plotted data change. + // Narrow mutation scope — only the hit layer and the ruler groups are + // touched. Rulers are pruned by DATA existence, not DOM presence: a + // series hidden via the legend drops its path from this chart but keeps + // its roofline entry, so its rulers survive until the series (a date, a + // chip config, or a precision) actually leaves the comparison. + useLayoutEffect(() => { + const display = getDisplaySelection(); + if (!display) return; + const zoomCtx = currentZoomRenderContext(display.svg, display.ctx); + drawPerfRuler( + display.zoomGroup, + zoomCtx.xScale as ContinuousScale, + zoomCtx.yScale as ContinuousScale, + display.ctx.width, + display.ctx.height, + ); + setPerfRulerState((prev) => + prunePerfRulers(prev, (cls) => { + if (!cls.startsWith(ROOFLINE_CLASS_PREFIX)) return false; + const points = rooflines[cls.slice(ROOFLINE_CLASS_PREFIX.length)]; + return points !== undefined && points.length >= 2; + }), + ); + }, [ + getDisplaySelection, + perfRulerMode, + perfRulerState, + drawPerfRuler, + dataIdentity, + rooflines, + ]); + // Dismiss tooltip when pinned point's combo is hidden useEffect(() => { const pp = chartRef.current?.getPinnedPoint() as InferenceData | null; @@ -812,6 +1250,7 @@ const GPUGraph = React.memo( keyFn: (point) => `${point.date}:${scatterPointConfigId(point)}`, }, lineLabelLayer, + perfRulerLayer, knownIssueLayer, ]} zoom={{ @@ -863,6 +1302,20 @@ const GPUGraph = React.memo( getShapeKeyForPrecision(d.precision, selectedPrecisions), ), onPointClick: (d: InferenceData) => { + // Ruler mode: a point click measures its curve instead of pinning + // the tooltip (the handler un-pins what the shared click applied). + const ruler = perfRulerRef.current; + if (ruler.mode) { + track('gpu_timeseries_data_point_clicked', { + id: d.id, + hw: String(d.hwKey), + x: d.x, + y: d.y, + perfRuler: true, + }); + ruler.onPointClick(d); + return; + } track('gpu_timeseries_data_point_clicked', { id: d.id, hw: String(d.hwKey), @@ -920,6 +1373,9 @@ const GPUGraph = React.memo( attachToLayer: 1, }} onRender={(ctx: RenderContext) => { + // Remembered so the perf-ruler effect can redraw against the same + // layout and scales the chart was last drawn with. + lastRenderCtxRef.current = ctx; // Apply log tick format on initial render (needs the built scale) if (logScale) { const yScale = (ctx.renderedYScale ?? ctx.yScale) as d3.ScaleLogarithmic< @@ -1056,8 +1512,37 @@ const GPUGraph = React.memo( if (c && !showPointLabels) setShowPointLabels(true); }, }, + { + id: 'gpu-perf-ruler', + label: legendT.perfRuler, + advanced: true, + checked: perfRulerMode, + infoTooltip: legendT.perfRulerInfo, + onCheckedChange: (c) => { + setPerfRulerMode(c); + // Clear synchronously with the mode flip so the rulers vanish + // in the same layout pass (the effect below also clears, for + // programmatic mode changes). + if (!c) setPerfRulerState(clearPerfRulers); + track('gpu_timeseries_perf_ruler_toggled', { enabled: c }); + }, + }, ]} actions={[ + ...(perfRulerState.rulers.length > 0 + ? [ + { + id: 'gpu-clear-perf-rulers', + label: legendT.clearPerfRulers(perfRulerState.rulers.length), + onClick: () => { + track('gpu_timeseries_perf_ruler_cleared', { + count: perfRulerState.rulers.length, + }); + setPerfRulerState(clearPerfRulers); + }, + }, + ] + : []), { id: 'gpu-reset-filter', label: legendT.resetFilter,