diff --git a/torchci/components/benchmark/compilers/common.tsx b/torchci/components/benchmark/compilers/common.tsx index e5f0126f8e..c0620e6c15 100644 --- a/torchci/components/benchmark/compilers/common.tsx +++ b/torchci/components/benchmark/compilers/common.tsx @@ -95,3 +95,6 @@ export const DISPLAY_KEYS_TO_HIGHLIGHT: { [k: string]: string } = { None: DEFAULT_HIGHLIGHT_KEY, Max_autotune: "max_autotune", }; + +export const COMPILERS_DTYPES_V2 = ["amp", "float16", "bfloat16", "none"]; +export const COMPILERS_MODES_V2 = ["training", "inference", "none"]; diff --git a/torchci/components/benchmark/v3/BenchmarkRegressionPage.tsx b/torchci/components/benchmark/v3/BenchmarkRegressionPage.tsx new file mode 100644 index 0000000000..ef2f24ec55 --- /dev/null +++ b/torchci/components/benchmark/v3/BenchmarkRegressionPage.tsx @@ -0,0 +1,30 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { BenchmarkDashboardStoreProvider } from "lib/benchmark/store/benchmark_dashboard_provider"; +import BenchmarkSideBar from "./components/benchmarkSideBar/BenchmarkSideBar"; +import { getConfig } from "./configs/configBook"; +dayjs.extend(utc); + +export default function BenchmarkRegressionPage({ + benchmarkId, + initial, +}: { + benchmarkId: string; + initial: any; +}) { + const config = getConfig(benchmarkId); + + // get dynamic componenet if any registered, otherwise use default + const Comp = config.getDataRenderComponent(); + + return ( + +
+ +
+ +
+
+
+ ); +} diff --git a/torchci/components/benchmark/v3/components/benchmarkSideBar/BenchmarkSideBar.tsx b/torchci/components/benchmark/v3/components/benchmarkSideBar/BenchmarkSideBar.tsx new file mode 100644 index 0000000000..231ea8c0b5 --- /dev/null +++ b/torchci/components/benchmark/v3/components/benchmarkSideBar/BenchmarkSideBar.tsx @@ -0,0 +1,147 @@ +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { Box, Divider, IconButton, Typography } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; +import { CommitWorflowSelectSection } from "./components/CommitWorkfowSelectSection"; +import { SideBarMainSection } from "./components/SideBarMainSection"; + +const SIDEBAR_WIDTH = 300; // expanded width +const RAIL_WIDTH = 44; // collapsed width +const WIDTH_MS = 300; +const FADE_MS = 200; + +const styles = { + container: (open: boolean) => (theme: any) => ({ + width: open + ? { + sm: "250px", + md: "300px", + lg: "350px", + } + : RAIL_WIDTH, + transition: theme.transitions.create("width", { + duration: WIDTH_MS, + }), + flexShrink: 0, + }), + + inner: { + position: "sticky", + top: 0, + height: "100dvh", + borderRight: 1, + borderColor: "divider", + bgcolor: "background.paper", + display: "flex", + flexDirection: "column", + overflow: "hidden", + p: 1, // keep padding constant to avoid layout shift + }, + + headerRow: { + display: "flex", + alignItems: "center", + marginBottom: 2, + }, + + title: { whiteSpace: "nowrap" }, + toggleBox: { marginLeft: "auto" }, + content: (visible: boolean) => ({ + opacity: visible ? 1 : 0, + transform: visible ? "translateX(0)" : "translateX(-6px)", + transition: `opacity ${FADE_MS}ms ease, transform ${FADE_MS}ms ease`, + pointerEvents: visible ? "auto" : "none", + }), + + collapsedPlaceholder: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, +}; + +/** + * Benchmark sidebar (left rail) + * can be collapsed to a rail with a toggle button + */ +export default function BenchmarkSideBar() { + const [open, setOpen] = useState(true); + + // initial = open → first paint shows content when open + const [contentMounted, setContentMounted] = useState(open); + const [contentVisible, setContentVisible] = useState(open); + + const prevOpenRef = useRef(open); + const toggle = () => setOpen((o) => !o); + + useEffect(() => { + // Only run this logic when open actually changes (not on first render) + if (prevOpenRef.current === open) return; + + if (open) { + // Opening: mount first, keep hidden; will show after width transition ends + setContentMounted(true); + setContentVisible(false); + } else { + // Closing: hide immediately (no flash), keep mounted until width transition ends + setContentVisible(false); + } + + prevOpenRef.current = open; + }, [open]); + + const handleTransitionEnd = (e: React.TransitionEvent) => { + if (e.propertyName !== "width") return; + + if (open) { + // Finished expanding → reveal + setContentVisible(true); + } else { + // Finished collapsing → unmount + setContentMounted(false); + } + }; + + const SidebarTitleAndToggle = () => ( + + {open && ( + + Search + + )} + + + {open ? : } + + + + ); + + return ( + + + {/* Top bar (always visible) */} + + {/* Content: visible immediately on first open; deferred on toggled open; faded on close */} + {contentMounted ? ( + + + + + + ) : ( + + )} + + + ); +} diff --git a/torchci/components/benchmark/v3/components/benchmarkSideBar/components/BranchDropdown.tsx b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/BranchDropdown.tsx new file mode 100644 index 0000000000..6801cb973d --- /dev/null +++ b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/BranchDropdown.tsx @@ -0,0 +1,104 @@ +import { Alert } from "@mui/material"; +import { Box } from "@mui/system"; +import { UMDenseDropdown } from "components/uiModules/UMDenseComponents"; + +type BranchDropdownsProps = { + type: string; + lBranch: string; + rBranch: string; + setLBranch: (val: string) => void; + setRBranch: (val: string) => void; + branchOptions?: string[]; +}; + +const styles = { + missingBranch: { + width: 1, + wordBreak: "break-word", + whiteSpace: "normal", + padding: 0.2, + // ↓ shrink the text inside the Alert + "& .MuiAlert-message": { + fontSize: "0.7rem", // ~13px + lineHeight: 1.4, + }, + + // (optional) shrink the icon to match the smaller text + "& .MuiAlert-icon": { + padding: 0.8, // tighten spacing + "& svg": { fontSize: 20 }, + }, + }, +}; + +/** + * + * BranchDropdown UI component + * @param {string} type - type of the dropdown, can be "comparison" or "single". + * @param {string} lBranch - left branch + * @param {string} rBranch - right branch + * @param {function} setLBranch - set left branch + * @param {function} setRBranch - set right branch + * + * @returns + */ +const SectionShell: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + {children} + +); + +export function BranchDropdowns({ + type, + lBranch, + rBranch, + setLBranch, + setRBranch, + branchOptions, +}: BranchDropdownsProps) { + const empty = !branchOptions || branchOptions.length === 0; + + return ( + + {empty ? ( + + No branch is found, please select other features. + + ) : type === "comparison" ? ( + <> + + + + ) : ( + { + setLBranch(val); + setRBranch(val); + }} + dtypes={branchOptions} + label="Branch" + /> + )} + + ); +} diff --git a/torchci/components/benchmark/v3/components/benchmarkSideBar/components/CommitWorkfowSelectSection.tsx b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/CommitWorkfowSelectSection.tsx new file mode 100644 index 0000000000..ec912a6194 --- /dev/null +++ b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/CommitWorkfowSelectSection.tsx @@ -0,0 +1,185 @@ +import { Typography } from "@mui/material"; +import { Stack } from "@mui/system"; +import { QueryParameterConverterInputs } from "components/benchmark/v3/configs/utils/dataBindingRegistration"; +import { UMDenseCommitDropdown } from "components/uiModules/UMDenseComponents"; +import { useBenchmarkCommitsData } from "lib/benchmark/api_helper/compilers/type"; +import { useDashboardSelector } from "lib/benchmark/store/benchmark_dashboard_provider"; +import { BenchmarkCommitMeta } from "lib/benchmark/store/benchmark_regression_store"; +import { useEffect, useState } from "react"; +import { BenchmarkUIConfigBook } from "../../../configs/configBook"; + +/** + * + * @returns + * + */ +export function CommitWorflowSelectSection() { + const { + benchmarkId, + committedTime, + committedFilters, + lcommit, + rcommit, + committedLBranch, + committedRBranch, + setLCommit, + setRCommit, + } = useDashboardSelector((s) => ({ + benchmarkId: s.benchmarkId, + committedTime: s.committedTime, + committedFilters: s.committedFilters, + lcommit: s.lcommit, + rcommit: s.rcommit, + committedLBranch: s.committedLbranch, + committedRBranch: s.committedRbranch, + setLCommit: s.setLCommit, + setRCommit: s.setRCommit, + })); + + const [leftList, setLeftList] = useState([]); + const [rightList, setRightList] = useState([]); + const [autoLeftSha, setAutoLeftSha] = useState(null); + const [autoRightSha, setAutoRightSha] = useState(null); + + const config = BenchmarkUIConfigBook.instance.get(benchmarkId); + const dataBinding = + BenchmarkUIConfigBook.instance.getDataBinding(benchmarkId); + const required_filter_fields = config?.required_filter_fields ?? []; + + const ready = + !!committedTime?.start && + !!committedTime?.end && + !!committedLBranch && + committedLBranch.length > 0 && + !!committedRBranch && + committedRBranch.length > 0 && + required_filter_fields.every((k) => !!committedFilters[k]); + + // Fetch data + const branches = [ + ...new Set( + [committedLBranch, committedRBranch].filter((b) => b.length > 0) + ), + ]; + + // Convert to query params + const params = dataBinding.toQueryParams({ + branches, + timeRange: committedTime, + filters: committedFilters, + } as QueryParameterConverterInputs); + if (!params) { + throw new Error(`Failed to convert to query params for ${benchmarkId}`); + } + + const queryParams: any | null = ready ? params : null; + + // Fetch data + const { data, isLoading, error } = useBenchmarkCommitsData( + benchmarkId, + queryParams + ); + + useEffect(() => { + if (!ready) { + setLeftList([]); + setRightList([]); + setLCommit(null); + setRCommit(null); + } + }, [ready, setLCommit, setRCommit]); + + useEffect(() => { + if (isLoading || !data) return; + + const groups = data?.data?.branch ?? []; + const branchMap = convertToBranchMap(groups); + + const L: BenchmarkCommitMeta[] = branchMap[committedLBranch] ?? []; + const R: BenchmarkCommitMeta[] = branchMap[committedRBranch] ?? []; + + // update list + setLeftList(L); + setRightList(R); + + // update auto + if (L.length === 0) { + if (lcommit) setLCommit(null); + } + if (R.length === 0) { + setAutoRightSha(null); + if (rcommit) setRCommit(null); + } + if (L.length === 0 || R.length === 0) return; + + // check if user has selected a commit that is not in the list + const lSelected = lcommit?.workflow_id ?? null; + const rSelected = rcommit?.workflow_id ?? null; + const lHas = !!lSelected && L.some((c) => c.workflow_id === lSelected); + const rHas = !!rSelected && R.some((c) => c.workflow_id === rSelected); + + // rule left pick first workflow, right pick last workflow id + const nextAutoL = lHas ? lSelected : L[0]?.workflow_id ?? null; + const nextAutoR = rHas ? rSelected : R[R.length - 1]?.workflow_id ?? null; + + if (!lHas) { + setLCommit( + nextAutoL ? L.find((c) => c.workflow_id === nextAutoL) ?? null : null + ); + } + if (!rHas) { + setRCommit( + nextAutoR ? R.find((c) => c.workflow_id === nextAutoR) ?? null : null + ); + } + }, [ + isLoading, + data, + committedLBranch, + committedRBranch, + lcommit?.workflow_id, + rcommit?.workflow_id, + setLCommit, + setRCommit, + ]); + + if (error) return
Error: {error.message}
; + if (isLoading || !data) return null; + + return ( + + Commits + + + + ); +} + +export const convertToBranchMap = ( + raw: any[] +): Record => { + return raw.reduce((acc, g) => { + const branch = g?.group_info?.branch ?? "unknown"; + acc[branch] = g.rows.map((r: any) => ({ + commit: r.commit, + workflow_id: String(r.workflow_id), + date: r.date, + branch, + })); + return acc; + }, {} as Record); +}; diff --git a/torchci/components/benchmark/v3/components/benchmarkSideBar/components/DefaultSideBarMetricsDropdowns.tsx b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/DefaultSideBarMetricsDropdowns.tsx new file mode 100644 index 0000000000..5d69136924 --- /dev/null +++ b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/DefaultSideBarMetricsDropdowns.tsx @@ -0,0 +1,3 @@ +export default function DefaultMetricsDropdowns() { + return
Not supported yet: comming soon
; +} diff --git a/torchci/components/benchmark/v3/components/benchmarkSideBar/components/SideBarMainSection.tsx b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/SideBarMainSection.tsx new file mode 100644 index 0000000000..a05efd802d --- /dev/null +++ b/torchci/components/benchmark/v3/components/benchmarkSideBar/components/SideBarMainSection.tsx @@ -0,0 +1,184 @@ +// components/Sidebar.tsx +"use client"; +import Divider from "@mui/material/Divider"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { QueryParameterConverterInputs } from "components/benchmark/v3/configs/utils/dataBindingRegistration"; +import { UMDateButtonPicker } from "components/uiModules/UMDateRangePicker"; +import { UMDenseButtonLight } from "components/uiModules/UMDenseComponents"; +import dayjs from "dayjs"; +import { useBenchmarkCommitsData } from "lib/benchmark/api_helper/compilers/type"; +import { useDashboardSelector } from "lib/benchmark/store/benchmark_dashboard_provider"; +import { useEffect, useRef } from "react"; +import { BenchmarkUIConfigBook } from "../../../configs/configBook"; +import { BranchDropdowns } from "./BranchDropdown"; + +const styles = { + root: { + marginBottom: 2, + }, +}; +/** + * section of benchmark side bar that affect the data fetching and rendering, + * including time range, metric filters, and branch selection + * + * @returns + * + */ +export function SideBarMainSection() { + // 1) Read benchmarkId (low-churn) to fetch config + const benchmarkId = useDashboardSelector((s) => s.benchmarkId); + const config = BenchmarkUIConfigBook.instance.get(benchmarkId); + const dataBinding = + BenchmarkUIConfigBook.instance.getDataBinding(benchmarkId); + const required_filter_fields = config?.required_filter_fields ?? []; + + // 2) One selector (with shallow inside useDashboardSelector) for the rest + const { + stagedTime, + stagedFilters, + stagedLbranch, + stagedRbranch, + setStagedTime, + setStagedLBranch, + setStagedRBranch, + + committedTime, + committedFilters, + committedLbranch, + committedRbranch, + + commitMainOptions, + revertMainOptions, + } = useDashboardSelector((s) => ({ + stagedTime: s.stagedTime, + stagedFilters: s.stagedFilters, + stagedLbranch: s.stagedLbranch, + stagedRbranch: s.stagedRbranch, + setStagedTime: s.setStagedTime, + setStagedLBranch: s.setStagedLBranch, + setStagedRBranch: s.setStagedRBranch, + + committedTime: s.committedTime, + committedFilters: s.committedFilters, + committedLbranch: s.committedLbranch, + committedRbranch: s.committedRbranch, + + commitMainOptions: s.commitMainOptions, + revertMainOptions: s.revertMainOptions, + })); + + // trick to record the sig of the branches from previous rendering + const branchSigRef = useRef(""); + + const ready = + !!stagedTime?.start && + !!stagedTime?.end && + required_filter_fields.every((k) => !!committedFilters[k]); + + const params = BenchmarkUIConfigBook.instance + .getDataBinding(benchmarkId) + ?.toQueryParams({ + timeRange: stagedTime, + filters: stagedFilters, + } as QueryParameterConverterInputs); + if (!params) { + throw new Error(`Failed to convert to query params for ${benchmarkId}`); + } + + const queryParams: any | null = ready ? params : null; + + const { + data: commitsData, + isLoading: isCommitsLoading, + error: commitsError, + } = useBenchmarkCommitsData(benchmarkId, queryParams); + + const branches = commitsData?.metadata?.branches ?? []; + + // update staged branches option if they are not in the list + useEffect(() => { + const sig = branches.join("|"); + // trick to avoid infinite rendering + if (branchSigRef.current === sig) return; + branchSigRef.current = sig; + + if (branches.length === 0) return; + + if (!stagedLbranch || !branches.includes(stagedLbranch)) { + setStagedLBranch(branches[0] ?? null); + } + if (!stagedRbranch || !branches.includes(stagedRbranch)) { + setStagedRBranch(branches[branches.length - 1] ?? null); + } + }, [branches]); + + const DropdownComp = dataBinding?.getFilterOptionComponent(); + + const dirty = + stagedTime.start.valueOf() !== committedTime.start.valueOf() || + stagedTime.end.valueOf() !== committedTime.end.valueOf() || + stagedLbranch !== committedLbranch || + stagedRbranch !== committedRbranch || + JSON.stringify(stagedFilters) !== JSON.stringify(committedFilters); + + // indicates no branches found based on the time range and options + const noData = branches && branches.length === 0; + + const disableApply = !dirty || noData || isCommitsLoading; + + return ( + + + setStagedTime({ start, end }) + } + start={stagedTime.start} + end={stagedTime.end} + gap={0} + /> + + {/* Dropdown filters */} + Filters + + + + {!isCommitsLoading && !commitsError && ( + + )} + + Click apply to submit your changes + + + {/* Apply / Revert */} + + + Revert + + + Apply + + + + ); +} diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkChartSection.tsx b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkChartSection.tsx new file mode 100644 index 0000000000..28776f71e5 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkChartSection.tsx @@ -0,0 +1,96 @@ +import { Paper, Typography } from "@mui/material"; +import { Box } from "@mui/system"; +import { useMemo } from "react"; +import { + BenchmarkChartSectionConfig, + BenchmarkTimeSeriesInput, + makeGroupKeyAndLabel, + passesFilter, +} from "../helper"; +import BenchmarkTimeSeriesChartGroup from "./BenchmarkTimeSeriesChartGroup"; + +const styles = { + container: { + flexGrow: 1, + }, + groupBox: { + margin: 1, + }, + paper: { + p: 2, + elevation: 2, + borderRadius: 2, + }, +}; + +export default function BenchmarkChartSection({ + data = [], + chartSectionConfig, + onChange, +}: { + data?: BenchmarkTimeSeriesInput[]; + chartSectionConfig: BenchmarkChartSectionConfig; + onChange?: (payload: any) => void; +}) { + const filtered = useMemo(() => { + if (!data) { + return []; + } + return data.filter((s) => + passesFilter(s.group_info || {}, chartSectionConfig.filterByFieldValues) + ); + }, [data, chartSectionConfig.filterByFieldValues]); + + const groupMap = useMemo(() => { + const m = new Map< + string, + { key: string; labels: string[]; items: BenchmarkTimeSeriesInput[] } + >(); + for (const s of filtered) { + const gi = s.group_info || {}; + const { key, labels } = makeGroupKeyAndLabel( + gi, + chartSectionConfig.groupByFields + ); + if (!m.has(key)) m.set(key, { key, labels, items: [] }); + m.get(key)!.items.push(s); + } + return m; + }, [filtered, chartSectionConfig.groupByFields]); + + if (!data || data.length == 0) { + return <>; + } + + return ( + + {Array.from(groupMap.entries()).map(([key, data]) => { + if (!data) return null; + const op = chartSectionConfig.chartGroup?.renderOptions; + const title = data.labels.join(" "); + + let renderOptions = chartSectionConfig.chartGroup?.renderOptions; + if (op && op.pass_section_title) { + renderOptions = { + ...renderOptions, + titleSuffix: `/${title}`, + }; + } + return ( + + + {title.toUpperCase()} + { + onChange?.(payload); + }} + /> + + + ); + })} + + ); +} diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/BenchmarkTimeSeriesChart.tsx b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/BenchmarkTimeSeriesChart.tsx new file mode 100644 index 0000000000..e4611dc7a9 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/BenchmarkTimeSeriesChart.tsx @@ -0,0 +1,301 @@ +// MultiPassrateTimeSeries.tsx +import { Box } from "@mui/material"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import * as echarts from "echarts"; +import ReactECharts from "echarts-for-react"; +import React, { useMemo, useRef, useState } from "react"; +import { BenchmarkTimeSeriesInput, RawTimeSeriesPoint } from "../../helper"; +import { ChartSelectionControl } from "./ChartSelectionControl"; +import { echartRenderingOptions } from "./RenderingOptions"; +import { toEchartTimeSeriesData } from "./type"; + +dayjs.extend(utc); + +type ConfirmPayload = { + seriesIndex: number; + seriesName: string; + groupInfo: Record; + left: RawTimeSeriesPoint; + right: RawTimeSeriesPoint; +}; + +type Props = { + timeseries: BenchmarkTimeSeriesInput[]; + renderOptions?: { + height?: string | number; + lineMapping?: Record; + }; + defaultSelectMode?: boolean; + /** Called when user clicks Confirm with L/R selected for a single series. */ + onConfirm?: (sel: ConfirmPayload) => void; +}; + +const DEFAULT_HEIGHT = 200; + +const BenchmarkTimeSeriesChart: React.FC = ({ + timeseries, + renderOptions, + defaultSelectMode = false, + onConfirm = () => {}, +}) => { + const chartRef = useRef(null); + + // Selection state + const [selectMode, setSelectMode] = useState(defaultSelectMode); + const [selectedSeriesIdx, setSelectedSeriesIdx] = useState( + null + ); + const [leftIdx, setLeftIdx] = useState(null); + const [rightIdx, setRightIdx] = useState(null); + + const seriesDatas = useMemo( + () => timeseries.map((s) => toEchartTimeSeriesData(s)), + [timeseries] + ); + + const tooltipFormatter: NonNullable< + echarts.TooltipComponentOption["formatter"] + > = ((raw: unknown) => { + const p = Array.isArray(raw) ? raw[0] : (raw as any); + const meta = p?.data?.meta as RawTimeSeriesPoint | undefined; + if (!meta) return ""; + + const t = dayjs + .utc(meta.granularity_bucket) + .format("YYYY-MM-DD HH:mm [UTC]"); + const pct = meta.value.toFixed(3); + const commitShort = meta.commit.slice(0, 7); + + let value = pct; + const rule = renderOptions?.lineMapping?.[meta.metric]; + if (rule) { + value = renderByRule( + rule?.type ?? "default", + rule?.scale ? rule?.scale : 1, + pct + ); + } + return [ + `
${t}
`, + `
${p?.data?.legend_name}
`, + `${meta.metric}: ${value}
`, + `commit ${commitShort} · workflow ${meta.workflow_id} · branch ${meta.branch}`, + ].join(""); + }) as any; + + function resetSelection() { + setSelectedSeriesIdx(null); + setLeftIdx(null); + setRightIdx(null); + } + + function handlePointClick(seriesIndex: number, dataIndex: number) { + if (!selectMode) return; + // Lock to a series on first click + if (selectedSeriesIdx == null) { + setSelectedSeriesIdx(seriesIndex); + setLeftIdx(dataIndex); + setRightIdx(null); + return; + } + + // Must stay within the locked series + if (seriesIndex !== selectedSeriesIdx) return; + + if (leftIdx == null) { + setLeftIdx(dataIndex); + } else if (rightIdx == null) { + // keep chronological order L <= R + if (dataIndex < leftIdx) { + setRightIdx(leftIdx); + setLeftIdx(dataIndex); + } else { + setRightIdx(dataIndex); + } + } else { + // replace the closer one + const dL = Math.abs(dataIndex - leftIdx); + const dR = Math.abs(dataIndex - rightIdx); + if (dL <= dR) setLeftIdx(dataIndex); + else setRightIdx(dataIndex); + } + } + + // Build line series first (indices 0..N-1 map to logical timeseries) + const lineSeries: echarts.SeriesOption[] = useMemo(() => { + return seriesDatas.map((data, idx) => { + const isSelected = selectedSeriesIdx === idx; + const mlData: any[] = []; + + const isOther = selectedSeriesIdx != null && !isSelected; + const baseOpacity = selectedSeriesIdx == null ? 1 : isSelected ? 1 : 0.12; + if (isSelected && leftIdx != null && data[leftIdx]) { + mlData.push({ + xAxis: data[leftIdx].value[0], + label: { formatter: "L", position: "insideEndTop" }, + lineStyle: { type: "solid", width: 2 }, + }); + } + + if (isSelected && rightIdx != null && data[rightIdx]) { + mlData.push({ + xAxis: data[rightIdx].value[0], + label: { formatter: "R", position: "insideEndTop" }, + lineStyle: { type: "solid", width: 2 }, + }); + } + + return { + name: timeseries[idx]?.legend_name ?? `Series ${idx + 1}`, + type: "line", + showSymbol: true, + symbolSize: 4, + data, + silent: !!isOther, + lineStyle: { + opacity: baseOpacity, // line transparency + }, + itemStyle: { + opacity: baseOpacity, // dot transparency + }, + ...(mlData.length + ? { markLine: { data: mlData, symbol: "none" } } + : {}), + } as echarts.SeriesOption; + }); + }, [seriesDatas, timeseries, selectedSeriesIdx, leftIdx, rightIdx]); + + // Highlight overlays appended after all lines + const overlaySeries: echarts.SeriesOption[] = useMemo(() => { + if (selectedSeriesIdx == null) return []; + const data = seriesDatas[selectedSeriesIdx] || []; + const sel: any[] = []; + if (leftIdx != null && data[leftIdx]) sel.push(data[leftIdx]); + if (rightIdx != null && data[rightIdx]) sel.push(data[rightIdx]); + if (!sel.length) return []; + return [ + { + name: `sel-${selectedSeriesIdx}`, + type: "effectScatter", + z: 5, + rippleEffect: { scale: 2.1 }, + symbolSize: 4, + data: sel, + } as echarts.SeriesOption, + ]; + }, [seriesDatas, selectedSeriesIdx, leftIdx, rightIdx]); + + const legendSelected = useMemo(() => { + if (selectedSeriesIdx == null) return undefined; // 不锁定时不干预 legend + const m: Record = {}; + timeseries.forEach((s, i) => { + const name = s.legend_name ?? `Series ${i + 1}`; + m[name] = i === selectedSeriesIdx; // 只选中被锁定的那条 + }); + return m; + }, [selectedSeriesIdx, timeseries]); + + // 合成 option + const option: echarts.EChartsOption = useMemo(() => { + return { + ...echartRenderingOptions, + legend: { + ...echartRenderingOptions.legend, + ...(legendSelected ? { selected: legendSelected } : {}), + }, + tooltip: { + trigger: "item", + triggerOn: "mousemove|click", + formatter: tooltipFormatter, + }, + series: [...lineSeries, ...overlaySeries], + }; + }, [lineSeries, overlaySeries, legendSelected]); + + const onEvents = { + click: (p: any) => { + if (!p || p.seriesType !== "line") return; + if (typeof p.seriesIndex !== "number" || typeof p.dataIndex !== "number") + return; + handlePointClick(p.seriesIndex, p.dataIndex); + }, + }; + + const hasBoth = + selectedSeriesIdx != null && leftIdx != null && rightIdx != null; + const currentSeriesName = + selectedSeriesIdx != null + ? timeseries[selectedSeriesIdx].legend_name ?? + `Series ${selectedSeriesIdx + 1}` + : null; + const currentGroupInfo = + selectedSeriesIdx != null ? timeseries[selectedSeriesIdx].group_info : null; + + const leftMeta = + selectedSeriesIdx != null && leftIdx != null + ? (seriesDatas[selectedSeriesIdx][leftIdx].meta as RawTimeSeriesPoint) + : null; + const rightMeta = + selectedSeriesIdx != null && rightIdx != null + ? (seriesDatas[selectedSeriesIdx][rightIdx].meta as RawTimeSeriesPoint) + : null; + + function confirm() { + if (!hasBoth) return; + onConfirm({ + seriesIndex: selectedSeriesIdx!, + seriesName: currentSeriesName!, + groupInfo: currentGroupInfo || {}, + left: leftMeta!, + right: rightMeta!, + }); + } + + return ( + + {/* Selection controls */} + { + setSelectMode(v); + if (!v) resetSelection(); + }} + leftMeta={leftMeta} + rightMeta={rightMeta} + onClear={resetSelection} + onConfirm={confirm} + confirmDisabled={!hasBoth} + clearDisabled={!leftMeta && !rightMeta} + /> + {/* Echart controls */} + + + ); +}; +export default BenchmarkTimeSeriesChart; + +function renderByRule(rule: string, scale: number, data: any) { + switch (rule) { + case "percent": + return `${(data * scale).toFixed(2)}%`; + default: + return data; + } +} diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/ChartSelectionControl.tsx b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/ChartSelectionControl.tsx new file mode 100644 index 0000000000..55ea6c9615 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/ChartSelectionControl.tsx @@ -0,0 +1,91 @@ +import { Button, FormControlLabel, Switch, Typography } from "@mui/material"; +import { Stack } from "@mui/system"; +import dayjs from "dayjs"; +import { RawTimeSeriesPoint } from "../../helper"; + +type SelectionControlsProps = { + selectMode: boolean; + setSelectMode: (v: boolean) => void; + + leftMeta: RawTimeSeriesPoint | null; + rightMeta: RawTimeSeriesPoint | null; + + onClear: () => void; + onConfirm: () => void; + + confirmDisabled: boolean; + clearDisabled: boolean; +}; + +export const ChartSelectionControl: React.FC = ({ + selectMode, + setSelectMode, + leftMeta, + rightMeta, + onClear, + onConfirm, + confirmDisabled, + clearDisabled, +}) => { + return ( + + setSelectMode(e.target.checked)} + /> + } + label="Select mode" + /> + + L:  + {leftMeta ? ( + <> + {dayjs.utc(leftMeta.granularity_bucket).format("MM-DD HH:mm")} ·{" "} + {leftMeta.commit.slice(0, 7)} + + ) : ( + + )} + + + + R:  + {rightMeta ? ( + <> + {dayjs.utc(rightMeta.granularity_bucket).format("MM-DD HH:mm")} ·{" "} + {rightMeta.commit.slice(0, 7)} + + ) : ( + + )} + + + + + + + + ); +}; diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/RenderingOptions.ts b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/RenderingOptions.ts new file mode 100644 index 0000000000..13d17e4ab5 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/RenderingOptions.ts @@ -0,0 +1,100 @@ +import dayjs from "dayjs"; +const MAX_LEGEND_NAME = 20; + +export const echartRenderingOptions: echarts.EChartsOption = { + animation: false, + legend: { + type: "scroll", // scrollable if many series + orient: "vertical", // vertical legend + right: 10, + top: 20, + bottom: 20, + formatter: (name: string) => + name.length > MAX_LEGEND_NAME + ? name.slice(0, MAX_LEGEND_NAME) + "…" + : name, + selectedMode: true, + selector: [ + { + type: "all", + title: "All", + }, + { + type: "inverse", + title: "Inv", + }, + ], + }, + grid: { + left: 10, + right: 180, // reserve extra space on the right + top: 40, + bottom: 5, + containLabel: true, + }, + xAxis: { + type: "time", + axisLabel: { + formatter: (v: number) => dayjs.utc(v).format("MM-DD"), + }, + }, + yAxis: { + type: "value", + min: "dataMin", + max: "dataMax", + splitNumber: 5, + axisLabel: { + formatter: (v: number) => `${v.toFixed(2)}`, + }, + }, +}; + +/** + * use stable scale to pick + const globalExtents = useMemo(() => { + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + for (const d of seriesDatas) { + for (const p of d) { + const x = p.value[0] as number; + const y = p.value[1] as number; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + const padY = Math.max((maxY - minY) * 0.05, 1e-6); + return { + minX, + maxX, + minY: minY - padY, + maxY: maxY + padY, + }; + }, [seriesDatas]); + + const option: echarts.EChartsOption = useMemo(() => { + return { + ...echartRenderingOptions, + tooltip: { + trigger: "item", + triggerOn: "mousemove|click", + formatter: tooltipFormatter, + }, + xAxis: { + ...(echartRenderingOptions as any).xAxis, + min: globalExtents.minX === globalExtents.maxX ? 0 : globalExtents.minX, + max: globalExtents.maxX, + }, + yAxis: { + ...(echartRenderingOptions as any).yAxis, + min: globalExtents.minY === globalExtents.maxY ? 0 : globalExtents.minY, + max: globalExtents.maxY, + scale: true, + }, + series: [...lineSeries, ...overlaySeries], + }; + }, [lineSeries, overlaySeries]); +*/ diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/type.ts b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/type.ts new file mode 100644 index 0000000000..30a17058f9 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChart/type.ts @@ -0,0 +1,42 @@ +export type RawTimeSeriesPoint = { + granularity_bucket: string; + value: number; + commit: string; + workflow_id?: string; + branch?: string; + name?: string; + metric?: string; +}; + +export type BenchmarkTimeSeriesInput = { + legend_name?: string; + group_info?: Record; + data: RawTimeSeriesPoint[]; +}; + +export type BenchmarkTimeSeriesConfirmPayload = { + seriesIndex: number; + seriesName: string; + groupInfo: Record; + left: RawTimeSeriesPoint; + right: RawTimeSeriesPoint; +}; + +export const toEchartTimeSeriesData = (data: BenchmarkTimeSeriesInput) => { + // sort by time asc and keep meta on each item + return data.data + .slice() + .sort( + (a, b) => + new Date(a.granularity_bucket).getTime() - + new Date(b.granularity_bucket).getTime() + ) + .map((p) => ({ + value: [new Date(p.granularity_bucket).getTime(), p.value] as [ + number, + number + ], + legend_name: data.legend_name, + meta: p, + })); +}; diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChartGroup.tsx b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChartGroup.tsx new file mode 100644 index 0000000000..3bebd07007 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/components/BenchmarkTimeSeriesChartGroup.tsx @@ -0,0 +1,93 @@ +import { Grid, Typography } from "@mui/material"; +import { Box } from "@mui/system"; +import { useMemo } from "react"; +import { + BenchmarkTimeSeriesInput, + ChartGroupConfig, + makeGroupKeyAndLabel, + passesFilter, +} from "../helper"; +import BenchmarkTimeSeriesChart from "./BenchmarkTimeSeriesChart/BenchmarkTimeSeriesChart"; + +type Props = { + data: any[]; + chartGroup: ChartGroupConfig; + defaultSelectMode?: boolean; + onConfirm?: (payload: any) => void; +}; + +// ---- Real React component with hooks (internal) ---- +export default function BenchmarkTimeSeriesChartGroup({ + data, + chartGroup, + defaultSelectMode = false, + onConfirm = () => {}, +}: Props) { + const filtered = useMemo( + () => + data.filter((s) => + passesFilter(s.group_info || {}, chartGroup.filterByFieldValues) + ), + [data, chartGroup.filterByFieldValues] + ); + const groups = useMemo(() => { + const m = new Map< + string, + { labels: string[]; items: BenchmarkTimeSeriesInput[] } + >(); + for (const s of filtered) { + const gi = s.group_info || {}; + const gbf = chartGroup.groupByFields ?? []; + const { key, labels } = makeGroupKeyAndLabel(gi, gbf); + if (!m.has(key)) m.set(key, { labels, items: [] }); + if (chartGroup.lineKey) { + const { key: _, labels: name_labels } = makeGroupKeyAndLabel( + gi, + chartGroup.lineKey + ); + s.legend_name = name_labels.join("|"); + } + m.get(key)!.items.push(s); + } + return Array.from(m.entries()).map(([key, { labels, items }]) => ({ + key, + labels, + items, + })); + }, [filtered, chartGroup.groupByFields]); + + if (groups.length === 0) { + return ( + + + No data after filter. + + + ); + } + + return ( + + {groups.map((g) => { + const groupSeries = g.items.map((s) => ({ ...s })); + return ( + + + {g.labels.join(" ")} + {chartGroup.renderOptions?.titleSuffix} + + + + ); + })} + + ); +} diff --git a/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/helper.tsx b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/helper.tsx new file mode 100644 index 0000000000..7932e9725e --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/components/benchmarkTimeSeries/helper.tsx @@ -0,0 +1,66 @@ +export type BenchmarkChartSectionConfig = { + titleMapping?: Record; + groupByFields: string[]; + filterByFieldValues?: Record>; + chartGroup: ChartGroupConfig; +}; + +export type ChartGroupConfig = { + type: "line"; + titleMapping?: Record; + groupByFields?: string[]; + filterByFieldValues?: Record>; + lineKey?: string[]; + renderOptions?: any; + chart?: ChartConfig; +}; + +export type ChartConfig = { + renderOptions?: any; +}; + +export type RawTimeSeriesPoint = { + metric: string; + value: number; + legend_name: string; + granularity_bucket: string; + workflow_id: string; + commit: string; + branch: string; + [key: string]: string | number; +}; + +/** Input structure: multiple lines, each with group_info and data points. */ +export type BenchmarkTimeSeriesInput = { + group_info: Record; + legend_name: string; + data: RawTimeSeriesPoint[]; +}; + +function toStr(v: unknown): string { + if (v == null) return ""; + return String(v); +} + +export function passesFilter( + gi: Record, + filter?: Record> +): boolean { + if (!filter) return true; + for (const [k, allowed] of Object.entries(filter)) { + if (!allowed || allowed.length === 0) continue; + const val = gi?.[k]; + if (!allowed.map(toStr).includes(toStr(val))) return false; + } + return true; +} + +export function makeGroupKeyAndLabel( + gi: Record, + fields: string[] +): { key: string; labels: string[] } { + if (!fields.length) return { key: "__ALL__", labels: [] }; + const parts = fields.map((f) => `${f}=${toStr(gi?.[f])}`); + const labels = fields.map((f) => `${toStr(gi?.[f])}`); + return { key: parts.join("|"), labels }; +} diff --git a/torchci/components/benchmark/v3/components/dataRender/fanout/FanoutBenchmarkTimeSeriesChartSection.tsx b/torchci/components/benchmark/v3/components/dataRender/fanout/FanoutBenchmarkTimeSeriesChartSection.tsx new file mode 100644 index 0000000000..c3aeb7b272 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/fanout/FanoutBenchmarkTimeSeriesChartSection.tsx @@ -0,0 +1,27 @@ +import BenchmarkChartSection from "../components/benchmarkTimeSeries/components/BenchmarkChartSection"; +import { + BenchmarkChartSectionConfig, + BenchmarkTimeSeriesInput, +} from "../components/benchmarkTimeSeries/helper"; + +export default function FanoutBenchmarkTimeSeriesChartSection({ + data = [], + config, + onChange, +}: { + data?: BenchmarkTimeSeriesInput[]; + config: any; + onChange?: (payload: any) => void; +}) { + return ( +
+ { + onChange?.(payload); + }} + /> +
+ ); +} diff --git a/torchci/components/benchmark/v3/components/dataRender/fanout/defaultFanoutRenderContent.tsx b/torchci/components/benchmark/v3/components/dataRender/fanout/defaultFanoutRenderContent.tsx new file mode 100644 index 0000000000..ab56bf4e58 --- /dev/null +++ b/torchci/components/benchmark/v3/components/dataRender/fanout/defaultFanoutRenderContent.tsx @@ -0,0 +1,98 @@ +import { getConfig } from "components/benchmark/v3/configs/configBook"; +import { getFanoutRenderComponent } from "components/benchmark/v3/configs/utils/fanoutRegistration"; +import LoadingPage from "components/common/LoadingPage"; +import { useBenchmarkData } from "lib/benchmark/api_helper/compilers/type"; +import { useDashboardSelector } from "lib/benchmark/store/benchmark_dashboard_provider"; +import { useState } from "react"; + +/** + * The default fanout component fetches pre-processed data for chart, + * table and other components in one api + * @returns + */ +export function DefaultFanoutRenderContent() { + const { + benchmarkId, + committedTime, + committedFilters, + committedLbranch: committedLBranch, + committedRbranch: committedRBranch, + } = useDashboardSelector((s) => ({ + benchmarkId: s.benchmarkId, + committedTime: s.committedTime, + committedFilters: s.committedFilters, + committedLbranch: s.committedLbranch, + committedRbranch: s.committedRbranch, + })); + const [payload, setPayload] = useState(null); + const config = getConfig(benchmarkId); + const requiredFilters = config.dataBinding.raw.required_filter_fields; + const dataRender = config.raw.dataRender; + + const branches = [ + ...new Set( + [committedLBranch, committedRBranch].filter((b) => b.length > 0) + ), + ]; + + const ready = + !!committedTime?.start && + !!committedTime?.end && + !!committedLBranch && + !!committedRBranch && + requiredFilters.every((k: string) => !!committedFilters[k]); + + // convert to the query params + const params = config.dataBinding.toQueryParams({ + timeRange: committedTime, + branches, + filters: committedFilters, + }); + const queryParams: any | null = ready ? params : null; + + // fetch the bechmark data + const { + data: resp, + isLoading, + error, + } = useBenchmarkData(benchmarkId, queryParams); + if (isLoading) { + return ; + } + if (error) { + return
Error: {error.message}
; + } + + if (!dataRender?.renders) { + return
no data render
; + } + + if (!resp?.data?.data) { + return
no data
; + } + + const fanoutUIConfigs = dataRender.renders; + const multidata = resp.data.data; + + return ( +
+ {fanoutUIConfigs.map((fanoutUIConfig, index) => { + const { Component, data_path } = + getFanoutRenderComponent(fanoutUIConfig); + if (!data_path) { + return ( +
unable to fetch fanout component {fanoutUIConfig.type}
+ ); + } + return ( + + ); + })} +
+ ); +} diff --git a/torchci/components/benchmark/v3/configs/configBook.tsx b/torchci/components/benchmark/v3/configs/configBook.tsx new file mode 100644 index 0000000000..2b023dd4c7 --- /dev/null +++ b/torchci/components/benchmark/v3/configs/configBook.tsx @@ -0,0 +1,125 @@ +import { DefaultFanoutRenderContent } from "../components/dataRender/fanout/defaultFanoutRenderContent"; +import { NotFoundComponent, resolveComponent } from "./configRegistration"; +import { + CompilerPrecomputeBenchmarkUIConfig, + COMPILTER_PRECOMPUTE_BENCHMARK_ID, +} from "./teams/compilers/config"; +import { + DataBinding, + DataBindingConfig, +} from "./utils/dataBindingRegistration"; + +export type UIRenderConfig = { + type: string; // type of the component to render + config: any; // config of the component to render +}; + +export type DataRenderOption = { + type: string; + id?: string; // id of the component to render, this is used when type is 'component' + renders?: UIRenderConfig[]; // this is used when type is predefined type such as 'default-fanout' +}; + +export type BenchmarkUIConfig = { + benchmarkId: string; + apiId: string; + benchmarkName: string; + dataBinding: DataBindingConfig; // data binding config + dataRender?: DataRenderOption; // either binds a component or a converter function to render data + required_filter_fields?: readonly string[]; // required filter fields +}; + +export class BenchmarkUI { + private _benchmarkId: string; + private _config: BenchmarkUIConfig; + private _dataBinding: DataBinding; + + constructor(config: BenchmarkUIConfig) { + this._benchmarkId = config.benchmarkId; + this._config = config; + this._dataBinding = new DataBinding(config.dataBinding); + } + + get benchmarkId(): string { + return this._benchmarkId; + } + + get raw(): BenchmarkUIConfig { + return this._config; + } + + get dataBinding(): DataBinding { + return this._dataBinding; + } + + getDataRenderComponent = (): React.ComponentType => { + const dr = this._config.dataRender; + if (!dr || dr.type !== "component") return DefaultFanoutRenderContent; + + const Comp = resolveComponent(dr.id); + if (Comp) return Comp; + + // inline fallback component to satisfy the return type + const Missing: React.FC = () => ( + + ); + return Missing; + }; +} + +export class BenchmarkUIConfigBook { + private static _instance: BenchmarkUIConfigBook | null = null; + private readonly configs: Record; + + private constructor() { + this.configs = { + [COMPILTER_PRECOMPUTE_BENCHMARK_ID]: CompilerPrecomputeBenchmarkUIConfig, + // add more configs here ... + }; + } + + /** Get the global singleton instance */ + static get instance(): BenchmarkUIConfigBook { + if (!this._instance) { + this._instance = new BenchmarkUIConfigBook(); + } + return this._instance; + } + + get(id: string): BenchmarkUIConfig | undefined { + const config = this.configs[id]; + return config; + } + + getConfigInstance(id: string): BenchmarkUI { + const config = this.get(id); + if (!config) { + throw new Error(`No config found for id ${id}`); + } + return new BenchmarkUI(config); + } + + listIds(): string[] { + return Object.keys(this.configs); + } + + listAll(): BenchmarkUIConfig[] { + return Object.values(this.configs); + } + + getDataBinding(id: string): DataBinding { + const config = this.get(id); + if (!config) { + throw new Error(`No config found for id ${id}, cannot get data binding`); + } + return new DataBinding(config.dataBinding); + } +} + +export function getBenchmarkBook(): BenchmarkUIConfigBook { + return BenchmarkUIConfigBook.instance; +} + +export function getConfig(id: string): BenchmarkUI { + return BenchmarkUIConfigBook.instance.getConfigInstance(id); +} diff --git a/torchci/components/benchmark/v3/configs/configRegistration.tsx b/torchci/components/benchmark/v3/configs/configRegistration.tsx new file mode 100644 index 0000000000..21ed130209 --- /dev/null +++ b/torchci/components/benchmark/v3/configs/configRegistration.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { CompilerSearchBarDropdowns } from "./teams/compilers/CompilerSearchBarDropdowns"; +import { compilerQueryParameterConverter } from "./teams/compilers/config"; +import { QueryParameterConverter } from "./utils/dataBindingRegistration"; + +export const COMPONENT_REGISTRY: Record> = { + CompilerSearchBarDropdowns, +}; + +// register converters for data params, this is +export const CONVERTER_REGISTRY: Record = { + compilerQueryParameterConverter, +}; + +export function resolveComponent( + name: string | undefined | null +): React.ComponentType | undefined { + if (typeof name !== "string") return undefined; + return COMPONENT_REGISTRY[name]; // OK: string-indexed record +} + +export const NotFoundComponent: React.FC<{ name: string }> = ({ name }) => ( +
Component not found: {name}
+); diff --git a/torchci/components/benchmark/v3/configs/teams/compilers/CompilerSearchBarDropdowns.tsx b/torchci/components/benchmark/v3/configs/teams/compilers/CompilerSearchBarDropdowns.tsx new file mode 100644 index 0000000000..85d911c170 --- /dev/null +++ b/torchci/components/benchmark/v3/configs/teams/compilers/CompilerSearchBarDropdowns.tsx @@ -0,0 +1,41 @@ +import { + COMPILERS_DTYPES_V2, + DISPLAY_NAMES_TO_DEVICE_NAMES, +} from "components/benchmark/compilers/common"; +import { + UMDenseDropdown, + UMDenseModePicker, +} from "components/uiModules/UMDenseComponents"; +import { useDashboardSelector } from "lib/benchmark/store/benchmark_dashboard_provider"; + +export function CompilerSearchBarDropdowns() { + const { stagedFilters, setStagedFilter } = useDashboardSelector((s) => ({ + stagedFilters: s.stagedFilters, + setStagedFilter: s.setStagedFilter, + })); + return ( + <> + setStagedFilter("mode", val)} + setDType={(val: string) => setStagedFilter("dtype", val)} + /> + + val === "notset" + ? setStagedFilter("dtype", "") + : setStagedFilter("dtype", val) + } + dtypes={COMPILERS_DTYPES_V2} + label="Precision" + /> + setStagedFilter("deviceName", val)} + dtypes={Object.keys(DISPLAY_NAMES_TO_DEVICE_NAMES)} + label="Device" + /> + + ); +} diff --git a/torchci/components/benchmark/v3/configs/teams/compilers/config.ts b/torchci/components/benchmark/v3/configs/teams/compilers/config.ts new file mode 100644 index 0000000000..64a11e28f2 --- /dev/null +++ b/torchci/components/benchmark/v3/configs/teams/compilers/config.ts @@ -0,0 +1,104 @@ +import { + DEFAULT_DEVICE_NAME, + DISPLAY_NAMES_TO_ARCH_NAMES, + DISPLAY_NAMES_TO_DEVICE_NAMES, +} from "components/benchmark/compilers/common"; +import { SUITES } from "components/benchmark/compilers/SuitePicker"; +import { DEFAULT_MODE, MODES } from "components/benchmark/ModeAndDTypePicker"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { REQUIRED_COMPLIER_LIST_COMMITS_KEYS } from "lib/benchmark/api_helper/compilers/type"; +import { BenchmarkUIConfig } from "../../configBook"; +import { + QueryParameterConverter, + QueryParameterConverterInputs, +} from "../../utils/dataBindingRegistration"; +dayjs.extend(utc); + +export const compilerQueryParameterConverter: QueryParameterConverter = ( + inputs: QueryParameterConverterInputs +) => { + const i = inputs; + const f = i.filters; + return { + commits: i.commits ?? [], + branches: i.branches ?? [], + compilers: [], + arch: DISPLAY_NAMES_TO_ARCH_NAMES[f.deviceName], + device: DISPLAY_NAMES_TO_DEVICE_NAMES[f.deviceName], + dtype: f.dtype === "none" ? "" : f.dtype, + granularity: "hour", + mode: f.mode, + startTime: dayjs.utc(i.timeRange.start).format("YYYY-MM-DDTHH:mm:ss"), + stopTime: dayjs.utc(i.timeRange.end).format("YYYY-MM-DDTHH:mm:ss"), + suites: f.suite ?? Object.keys(SUITES), + }; +}; + +export const COMPILTER_PRECOMPUTE_BENCHMARK_ID = "compiler_precompute"; + +// The initial config for the compiler benchmark regression page +export const COMPILTER_PRECOMPUTE_BENCHMARK_INITIAL = { + benchmarkId: COMPILTER_PRECOMPUTE_BENCHMARK_ID, + // (elainewy): todo change this to json-friend config + time: { + start: dayjs.utc().startOf("day").subtract(7, "day"), + end: dayjs.utc().endOf("day"), + }, + filters: { + repo: "pytorch/pytorch", + benchmarkName: "compiler", + backend: "", + mode: DEFAULT_MODE, + dtype: MODES[DEFAULT_MODE], + deviceName: DEFAULT_DEVICE_NAME, + device: "cuda", + arch: "h100", + }, + lbranch: "main", + rbranch: "main", +}; + +// main config for the compiler benchmark regression page +export const CompilerPrecomputeBenchmarkUIConfig: BenchmarkUIConfig = { + benchmarkId: COMPILTER_PRECOMPUTE_BENCHMARK_ID, + apiId: COMPILTER_PRECOMPUTE_BENCHMARK_ID, + benchmarkName: "Compiler Inductor Regression Tracking", + dataBinding: { + initial: COMPILTER_PRECOMPUTE_BENCHMARK_INITIAL, + required_filter_fields: REQUIRED_COMPLIER_LIST_COMMITS_KEYS, + filter_options: { + customizedDropdown: { + type: "component", + id: "CompilerSearchBarDropdowns", + }, + }, + query_params: { + type: "converter", + id: "compilerDataRenderConverter", + }, + }, + dataRender: { + type: "fanout", + renders: [ + { + type: "FanoutBenchmarkTimeSeriesChartSection", + config: { + groupByFields: ["suite"], + chartGroup: { + type: "line", + groupByFields: ["metric"], + lineKey: ["compiler"], + chart: { + renderOptions: { + lineMapping: { + passrate: { type: "percent", scale: 100 }, + }, + }, + }, + }, + }, + }, + ], + }, +}; diff --git a/torchci/components/benchmark/v3/configs/typeProcess.tsx b/torchci/components/benchmark/v3/configs/typeProcess.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/torchci/components/benchmark/v3/configs/utils/dataBindingRegistration.tsx b/torchci/components/benchmark/v3/configs/utils/dataBindingRegistration.tsx new file mode 100644 index 0000000000..10dad3fa17 --- /dev/null +++ b/torchci/components/benchmark/v3/configs/utils/dataBindingRegistration.tsx @@ -0,0 +1,148 @@ +import dayjs, { Dayjs } from "dayjs"; +import { TimeRange } from "lib/benchmark/store/benchmark_regression_store"; +import DefaultMetricsDropdowns from "../../components/benchmarkSideBar/components/DefaultSideBarMetricsDropdowns"; +import { NotFoundComponent, resolveComponent } from "../configRegistration"; +import { compilerQueryParameterConverter } from "../teams/compilers/config"; + +export type DataBindingConfig = { + initial: BenchmarkUiParameters; + required_filter_fields: readonly string[]; + filter_options?: FilterOptionsConfiguration; + query_params?: QueryParamsConfig; +}; + +export type FilterOptionsConfiguration = { + customizedDropdown?: { + type: string; + id: string; + }; +}; + +export type BenchmarkUiParameters = { + benchmarkId: string; + time: { start: Dayjs; end: Dayjs }; + filters: Record; + lbranch: string; + rbranch: string; + lcommit?: string; + rcommit?: string; + [key: string]: any; +}; + +export type QueryParamsConfig = { + type: string; // type of the query params process user to choose, such as 'converter' + id: string; // id of the registered convert use to process query params +}; + +/* ----------------------- Converter function signatures --------------------- */ +export type QueryParameterConverterInputs = { + timeRange: TimeRange; + branches?: string[]; + commits?: string[]; + filters: Record; + [key: string]: any; +}; + +export type QueryParameterConverter = ( + inputs: QueryParameterConverterInputs +) => any; + +/* ---------------------------- Default converter ---------------------------- */ +export const getDefaultDataConverter: QueryParameterConverter = (i) => { + return { + ...i.filters, + branches: i.branches ?? [], + commits: i.commits ?? [], + startTime: dayjs.utc(i.timeRange.start).format("YYYY-MM-DDTHH:mm:ss"), + stopTime: dayjs.utc(i.timeRange.end).format("YYYY-MM-DDTHH:mm:ss"), + }; +}; + +/* ------------------------------ Registry (fixed) --------------------------- */ +export const CONVERTER_REGISTRY: Record = { + compilerQueryParameterConverter, + default: getDefaultDataConverter, +}; + +/* ============================ The Binding Class ============================ */ +export class DataBinding { + private readonly cfg: Required; + private readonly converters: Readonly< + Record + > = CONVERTER_REGISTRY; + private readonly defaultConverter: QueryParameterConverter = + getDefaultDataConverter; + + constructor(cfg: DataBindingConfig) { + if (!cfg.initial) throw new Error("initial params are required"); + if (cfg.initial.benchmarkId.length === 0) + throw new Error("benchmarkId is required"); + + const filled: Required = { + initial: { + ...cfg.initial, + time: cfg.initial?.time ?? { + start: dayjs.utc().startOf("day").subtract(7, "day"), + end: dayjs.utc().endOf("day"), + }, + filters: cfg.initial?.filters ?? {}, + lbranch: cfg.initial?.lbranch ?? "", + rbranch: cfg.initial?.rbranch ?? "", + }, + required_filter_fields: cfg.required_filter_fields ?? [], + filter_options: cfg.filter_options ?? {}, + query_params: cfg.query_params ?? { + type: "converter", + id: "default", + }, + }; + this.cfg = filled; + } + + get raw(): DataBindingConfig { + return this.cfg; + } + + /** Return the default UI parameters block from config (deep read-only). */ + get initialParams(): Readonly { + return this.cfg.initial; + } + + /** + * render the filter options component based on the filter_options config + */ + getFilterOptionComponent = (): React.ComponentType => { + const dr = this.cfg.filter_options?.customizedDropdown; + if (!dr || dr.type != "component") return DefaultMetricsDropdowns; + const Comp = resolveComponent(dr.id); + if (Comp) return Comp; + // inline fallback component to satisfy the return type + const Missing: React.FC = () => ; + return Missing; + }; + + /** Pick a converter by name; fall back to default. */ + getConverter(): QueryParameterConverter | undefined { + if ( + !this.cfg.query_params?.type || + this.cfg.query_params.type != "converter" + ) { + return; + } + const id = this.cfg.query_params?.id; + if (!id) return; + const conv = this.converters[id]; + if (conv) return conv; + return this.defaultConverter; + } + + /** + * Build backend query params from UI parameters, using a named converter + * (or the default if name is omitted/unknown). + */ + toQueryParams(inputs: QueryParameterConverterInputs): any { + const conv = this.getConverter(); + if (!conv) return undefined; + return conv(inputs); + } +} diff --git a/torchci/components/benchmark/v3/configs/utils/fanoutRegistration.tsx b/torchci/components/benchmark/v3/configs/utils/fanoutRegistration.tsx new file mode 100644 index 0000000000..590f2dcd9c --- /dev/null +++ b/torchci/components/benchmark/v3/configs/utils/fanoutRegistration.tsx @@ -0,0 +1,78 @@ +import FanoutBenchmarkTimeSeriesChartSection from "../../components/dataRender/fanout/FanoutBenchmarkTimeSeriesChartSection"; + +/** ---------------- Types ---------------- */ +export type FanoutComponentProps = { + data?: any[]; + config: any; // your UIRenderConfig slice + onChange?: (payload: any) => void; +}; +export type FanoutComponent = React.ComponentType; + +export type FanoutComponentConfig = { + Component: FanoutComponent; + /** optional: path to the data in the payload */ + data_path?: string; +}; + +/** ---------------- Fixed components (examples/placeholders) ---------------- */ +// Replace with your actual import + +const ErrorFanoutComponent: FanoutComponent = ({ config }) => { + console.warn( + "Rendering default fallback fanout component. Bad config:", + config + ); + return ( +
+ ⚠ Unknown fanout component type. Please check config. +
+ ); +}; + +/** ---------------- Immutable class ---------------- */ +export class FanoutRegistry { + /** singleton instance */ + private static _instance: FanoutRegistry | null = null; + + /** read-only registry */ + readonly map: Readonly>; + + /** read-only fallback */ + readonly fallback: Readonly; + + private constructor() { + const registry: Record = { + FanoutBenchmarkTimeSeriesChartSection: { + Component: FanoutBenchmarkTimeSeriesChartSection, + data_path: "time_series", + }, + }; + + this.map = Object.freeze({ ...registry }); + this.fallback = Object.freeze({ Component: ErrorFanoutComponent }); + Object.freeze(this); // freeze the instance so it can't be mutated + } + + /** get the singleton */ + static get instance(): FanoutRegistry { + if (!this._instance) this._instance = new FanoutRegistry(); + return this._instance; + } + + /** lookup a config; fall back to default */ + get(type: string): FanoutComponentConfig { + return this.map[type] ?? this.fallback; + } + + /** list all supported component types */ + listTypes(): string[] { + return Object.keys(this.map); + } +} + +/** ---------------- Helper function (optional) ---------------- */ +export function getFanoutRenderComponent(config: { + type: string; +}): FanoutComponentConfig { + return FanoutRegistry.instance.get(config.type); +} diff --git a/torchci/components/uiModules/UMDateRangePicker.tsx b/torchci/components/uiModules/UMDateRangePicker.tsx index bfe8db5b42..b74451dd72 100644 --- a/torchci/components/uiModules/UMDateRangePicker.tsx +++ b/torchci/components/uiModules/UMDateRangePicker.tsx @@ -3,35 +3,39 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import dayjs, { Dayjs } from "dayjs"; import * as React from "react"; -import { UMDenseButton } from "./UMDenseComponents"; +import { UMDenseButton, UMDenseButtonLight } from "./UMDenseComponents"; import { UMDenseDatePicker } from "./UMDenseDatePicker"; const presets = [ { key: "today", label: "Today", days: 1 }, { key: "last2", label: "Last 2 Days", days: 2 }, + { key: "last3", label: "Last 3 Days", days: 3 }, { key: "last7", label: "Last 7 Days", days: 7 }, + { key: "last10", label: "Last 10 Days", days: 10 }, { key: "last14", label: "Last 14 Days", days: 14 }, { key: "last30", label: "Last 30 Days", days: 30 }, ]; interface PresetDateRangeSelectorProps { setTimeRange?: (startDate: Dayjs, endDate: Dayjs) => void; - start?: string; - end?: string; + start?: dayjs.Dayjs; + end?: dayjs.Dayjs; + gap?: number; } export function UMDateRangePicker({ - start = dayjs().utc().startOf("day").subtract(6, "day").format("YYYY-MM-DD"), - end = dayjs().utc().startOf("day").format("YYYY-MM-DD"), + start = dayjs().utc().startOf("day").subtract(6, "day"), + end = dayjs().utc().endOf("day"), + gap = 1, setTimeRange = () => {}, }: PresetDateRangeSelectorProps) { - const [startDate, setStartDate] = React.useState(dayjs(start).utc()); - const [endDate, setEndDate] = React.useState(dayjs(end).utc()); + const [startDate, setStartDate] = React.useState(dayjs.utc(start)); + const [endDate, setEndDate] = React.useState(dayjs.utc(end)); const [activePreset, setActivePreset] = React.useState(""); const setRange = (days: number, key: string) => { - const now = dayjs().utc(); - const start = now.startOf("day").subtract(days - 1, "day"); + const now = dayjs().utc().startOf("hour"); + const start = now.startOf("day").subtract(days - gap, "day"); setStartDate(start); setEndDate(now); setActivePreset(key); @@ -40,17 +44,19 @@ export function UMDateRangePicker({ const handleManualStart = (newValue: any) => { if (newValue) { - setStartDate(newValue); + const newStart = dayjs.utc(newValue).startOf("day"); + setStartDate(newStart); setActivePreset(null); - setTimeRange(newValue, dayjs().utc()); + setTimeRange(newValue, endDate); } }; const handleManualEnd = (newValue: any) => { if (newValue) { - setEndDate(newValue); + let newEnd = dayjs.utc(newValue).endOf("day"); + setEndDate(newEnd); setActivePreset(null); - setTimeRange(startDate, newValue); + setTimeRange(startDate, newEnd); } }; @@ -88,8 +94,8 @@ export function UMDateRangePicker({ } export function UMDateButtonPicker({ - start = dayjs().utc().startOf("day").subtract(6, "day").format("YYYY-MM-DD"), - end = dayjs().utc().startOf("day").format("YYYY-MM-DD"), + start = dayjs().utc().startOf("day").subtract(6, "day"), + end = dayjs().utc().endOf("day"), setTimeRange = () => {}, }: PresetDateRangeSelectorProps) { const [open, setOpen] = React.useState(false); @@ -105,7 +111,7 @@ export function UMDateButtonPicker({ }} > Time Range: - setOpen(true)} @@ -117,8 +123,8 @@ export function UMDateButtonPicker({ justifyContent: "space-between", }} > - {start} - {end} - + {start.format("YYYY-MM-DD")} - {end.format("YYYY-MM-DD")} +
({ padding: "2px 2px", @@ -10,3 +20,197 @@ export const UMDenseButton = styled(Button)(({ theme }) => ({ borderRadius: 0, textTransform: "none", // optional: avoids uppercase })); + +export const UMDenseButtonLight = styled(Button)(({ theme }) => ({ + padding: "2px 4px", + minHeight: "25px", + fontSize: "0.75rem", + borderRadius: 4, + textTransform: "none", // optional: avoids uppercase +})); + +// Reusable dense menu style (affects the dropdown list items) +export const DENSE_MENU_STYLE = { + // shrink the whole list + "& .MuiList-root": { + paddingTop: 0, + paddingBottom: 0, + }, + + // make each item short & tight + "& .MuiMenuItem-root": { + minHeight: 18, // default ~48 + paddingTop: 1, // 2px + paddingBottom: 1, + paddingLeft: 2, + paddingRight: 2, + m: 0.2, + }, + // smaller text + tight line height + "& .MuiTypography-root": { + fontSize: "0.95rem", + lineHeight: 1.0, + }, +}; + +// Optional: compact display for the Select trigger itself +const DENSE_SELECT_SX = { + "& .MuiSelect-select": { + minHeight: 20, + paddingTop: 0.5, + paddingBottom: 0.5, + fontSize: "0.9rem", + m: 0.5, + }, +}; + +type Props = { + dtype: string; + setDType: (v: string) => void; + dtypes: string[]; + label: string; +}; + +export const DEFAULT_MODE = "inference"; +// The value is the default dtype for that mode +export const MODES: { [k: string]: string } = { + training: "amp", + inference: "bfloat16", +}; + +export const UMDenseDropdown: React.FC = ({ + dtype, + setDType, + dtypes, + label, +}) => { + const labelId = "dtype-picker-label"; + const selectId = "dtype-picker-select"; + + const handleChange = (e: SelectChangeEvent) => { + setDType(e.target.value); + }; + + return ( + + {label} + + + ); +}; + +export function UMDenseModePicker({ + mode, + setMode, + setDType, +}: { + mode: string; + setMode: any; + setDType: any; +}) { + function handleChange(e: SelectChangeEvent) { + const selectedMode = e.target.value; + setMode(selectedMode); + setDType(selectedMode in MODES ? MODES[selectedMode] : "amp"); + } + return ( + <> + + Mode + + + + ); +} + +export type UMDenseCommitDropdownCommitData = { + commit: string; + workflow_id: string; + date: string; + branch: string; +}; + +export const UMDenseCommitDropdown: React.FC<{ + label: string; + disable: boolean; + branchName: string; + commitList: UMDenseCommitDropdownCommitData[]; + selectedCommit: UMDenseCommitDropdownCommitData | null; + setCommit: (c: UMDenseCommitDropdownCommitData | null) => void; +}> = ({ label, disable, commitList, selectedCommit, setCommit }) => { + // Clamp the value so we never feed an out-of-range value to Select + const selectedValue = + selectedCommit?.workflow_id && + commitList.some((c) => c.workflow_id === selectedCommit.workflow_id) + ? selectedCommit.workflow_id + : ""; + + function handleChange(e: SelectChangeEvent) { + const wf = e.target.value as string; + setCommit(commitList.find((c) => c.workflow_id === wf) ?? null); + } + + return ( + + {/* branchName field ... (unchanged) */} + + {label} + + + + ); +}; diff --git a/torchci/components/utilization/UtilizationReportPage/UtilizationReportPage.tsx b/torchci/components/utilization/UtilizationReportPage/UtilizationReportPage.tsx index 53f1d83a39..7b13366800 100644 --- a/torchci/components/utilization/UtilizationReportPage/UtilizationReportPage.tsx +++ b/torchci/components/utilization/UtilizationReportPage/UtilizationReportPage.tsx @@ -19,10 +19,7 @@ const UtilizationReportPage = () => { const router = useRouter(); useEffect(() => { - const { - start_time = dayjs.utc().format("YYYY-MM-DD"), - end_time = dayjs.utc().format("YYYY-MM-DD"), - } = router.query; + const { start_time, end_time } = router.query; const newprops: any = { start_time, end_time, @@ -79,8 +76,8 @@ const InnerUtilizationContent = ({ payload: newprops, }); }} - start={timeRange.start_time} - end={timeRange.end_time} + start={dayjs.utc(timeRange.start_time)} + end={dayjs.utc(timeRange.end_time)} /> , mode: "min" | "max" @@ -68,11 +94,13 @@ export async function postBenchmarkTimeSeriesFetcher( export async function listBenchmarkCommits( name: string, - queryParams: Record + queryParams: Record, + response_formats: string[] = ["branch"] ): Promise { const body = { name: name, query_params: queryParams, + response_formats: response_formats, }; const url = "/api/benchmark/list_commits"; const res = await fetch(url, { @@ -82,22 +110,27 @@ export async function listBenchmarkCommits( }); return res.json(); } + export interface CompilerBundleResult { - timeSeries: any; - table: any; + data: { + data: any; + time_range: any; + total_raw_rows: number; + }; } -export function useBenchmarkCommitsData( - name: string, +// --- Hook wrapper --- +export function useBenchmarkData( + benchamrk_name: string, queryParams: Record | null -): SWRResponse { +): SWRResponse { const shouldFetch = !!queryParams; - return useSWR( - shouldFetch ? [name, queryParams] : null, + shouldFetch ? [benchamrk_name, queryParams] : null, async ([n, qp]) => { - return listBenchmarkCommits( + return postBenchmarkTimeSeriesFetcher( n as string, + ["time_series", "table"], qp as Record ); }, @@ -109,19 +142,34 @@ export function useBenchmarkCommitsData( ); } -// --- Hook wrapper --- -export function useBenchmarkCompilerData( - name: string, - queryParams: Record | null -): SWRResponse { - const shouldFetch = !!queryParams; - return useSWR( - shouldFetch ? [name, queryParams] : null, - async ([n, qp]) => { - return postBenchmarkTimeSeriesFetcher( +export const REQUIRED_COMPLIER_LIST_COMMITS_KEYS = [ + "mode", + "dtype", + "deviceName", +] as const; + +export function useBenchmarkCommitsData( + benchmarkId: string, + baseParams: any | null, + formats: string[] = ["branch"] +): any { + const shouldFetch = !!baseParams; + + if (!baseParams.branches) { + baseParams.branches = []; + } + + const keys = shouldFetch + ? ([benchmarkId, baseParams, formats] as const) + : null; + + return useSWR( + keys, + async ([n, qp, f]) => { + return listBenchmarkCommits( n as string, - ["time_series", "table"], - qp as Record + qp as Record, + f as string[] ); }, { @@ -131,29 +179,3 @@ export function useBenchmarkCompilerData( } ); } - -export const defaultGetTimeSeriesInputs: any = { - models: [], - commits: [], - compilers: [], - branches: [], - device: "", - arch: "", - dtype: "", - mode: "", - granularity: "hour", - startTime: "", - stopTime: "", - suites: [], -}; - -export const defaultListCommitsInputs: any = { - branches: [], - device: "", - arch: [], - dtype: "", - mode: "", - startTime: "", - stopTime: "", - suites: [], -}; diff --git a/torchci/lib/benchmark/store/benchmark_dashboard_provider.tsx b/torchci/lib/benchmark/store/benchmark_dashboard_provider.tsx new file mode 100644 index 0000000000..b57e6ff417 --- /dev/null +++ b/torchci/lib/benchmark/store/benchmark_dashboard_provider.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useRef } from "react"; +import { StoreApi } from "zustand"; +import { shallow } from "zustand/shallow"; +import type { UseBoundStoreWithEqualityFn } from "zustand/traditional"; +import type { + BenchmarkCommitMeta, + BenchmarkDashboardState, + TimeRange, +} from "./benchmark_regression_store"; +import { createDashboardStore } from "./benchmark_regression_store"; + +// The context will hold a Zustand *hook* created by createDashboardStore. +// We wrap it in a React Context so different benchmark pages can each get their own store. +type DashboardStoreHook = UseBoundStoreWithEqualityFn< + StoreApi +>; +const DashboardContext = createContext(null); + +export function BenchmarkDashboardStoreProvider({ + children, + initial, +}: { + children: React.ReactNode; + initial: { + benchmarkId: string; + time: TimeRange; + filters: Record; + lbranch: string; + rbranch: string; + lcommit?: BenchmarkCommitMeta; + rcommit?: BenchmarkCommitMeta; + }; +}) { + // useRef ensures the store is created only once per mount, + // not on every re-render. + const storeRef = useRef(); + + if (!storeRef.current) { + // Create a new store using the provided initial values. + // This happens once when the provider is mounted. + storeRef.current = createDashboardStore(initial); + } + + return ( + // Provide the store to all children via React Context. + // IMPORTANT: At the call site, wrap this Provider with `key={benchmarkId}` + // so navigating to a new benchmarkId forces a remount and new store. + + {children} + + ); +} + +// Hook to access the zustand store hook from context. +// Throws if no BenchmarkDashboardStoreProvider is found. +export function useDashboardStore(): DashboardStoreHook { + const ctx = useContext(DashboardContext); + if (!ctx) throw new Error("DashboardStoreProvider is missing"); + return ctx; +} + +// Convenience hook to select part of the dashboard state. +// This reduces re-renders compared to subscribing to the full store. +export function useDashboardSelector( + selector: (s: BenchmarkDashboardState) => T +): T { + const useStore = useDashboardStore(); + return useStore(selector, shallow); +} diff --git a/torchci/lib/benchmark/store/benchmark_regression_store.ts b/torchci/lib/benchmark/store/benchmark_regression_store.ts new file mode 100644 index 0000000000..45daed91fb --- /dev/null +++ b/torchci/lib/benchmark/store/benchmark_regression_store.ts @@ -0,0 +1,126 @@ +// benchmark_regression_store.ts +import type { Dayjs } from "dayjs"; +import { createWithEqualityFn } from "zustand/traditional"; + +export type TimeRange = { start: Dayjs; end: Dayjs }; +type KV = Record; + +export type BenchmarkCommitMeta = { + commit: string; + date: string; + branch: string; + workflow_id: string; + index?: number; +}; + +export interface BenchmarkDashboardState { + stagedTime: TimeRange; + stagedFilters: Record; + stagedLbranch: string; + stagedRbranch: string; + committedTime: TimeRange; + committedFilters: Record; + committedLbranch: string; + committedRbranch: string; + + // may key to track of the benchamrk + benchmarkId: string; + + lcommit: BenchmarkCommitMeta | null; + rcommit: BenchmarkCommitMeta | null; + + setStagedTime: (t: TimeRange) => void; + setStagedLBranch: (c: string) => void; + setStagedRBranch: (c: string) => void; + setStagedFilter: (k: string, v: string) => void; + setStagedFilters: (filters: Record) => void; + + commitMainOptions: () => void; + revertMainOptions: () => void; + + setLCommit: (commit: BenchmarkCommitMeta | null) => void; + setRCommit: (commit: BenchmarkCommitMeta | null) => void; + + reset: (initial: { + time: TimeRange; + benchmarkId: string; + filters: Record; + lcommit?: BenchmarkCommitMeta | null; + rcommit?: BenchmarkCommitMeta | null; + lbranch?: string; + rbranch?: string; + }) => void; +} + +export function createDashboardStore(initial: { + benchmarkId: string; + time: TimeRange; + filters: Record; + lbranch: string; + rbranch: string; + lcommit?: BenchmarkCommitMeta | null; + rcommit?: BenchmarkCommitMeta | null; +}) { + return createWithEqualityFn()((set, get) => ({ + benchmarkId: initial.benchmarkId, // <-- fixed name + + // staged + stagedTime: initial.time, + stagedFilters: initial.filters, + stagedLbranch: initial.lbranch ?? "", + stagedRbranch: initial.rbranch ?? "", + + // committed + committedTime: initial.time, + committedFilters: initial.filters, + committedLbranch: initial.lbranch ?? "", + committedRbranch: initial.rbranch ?? "", + + // current commits + lcommit: initial.lcommit ?? null, + rcommit: initial.rcommit ?? null, + + // actions... + setStagedLBranch: (c) => set({ stagedLbranch: c }), + setStagedRBranch: (c) => set({ stagedRbranch: c }), + setStagedTime: (t) => set({ stagedTime: t }), + setStagedFilter: (k, v) => + set((s) => ({ stagedFilters: { ...s.stagedFilters, [k]: v } })), + setStagedFilters: (filters) => + set((s) => ({ stagedFilters: { ...s.stagedFilters, ...filters } })), + + commitMainOptions: () => + set({ + committedTime: get().stagedTime, + committedFilters: get().stagedFilters, + committedLbranch: get().stagedLbranch, + committedRbranch: get().stagedRbranch, + }), + + revertMainOptions: () => + set({ + stagedTime: get().committedTime, + stagedFilters: get().committedFilters, + stagedLbranch: get().committedLbranch, + stagedRbranch: get().committedRbranch, + }), + + setLCommit: (commit) => set({ lcommit: commit }), + setRCommit: (commit) => set({ rcommit: commit }), + + reset: (next) => + set({ + stagedTime: next.time, + committedTime: next.time, + stagedFilters: next.filters, + committedFilters: next.filters, + stagedLbranch: next.lbranch ?? "", + stagedRbranch: next.rbranch ?? "", + committedLbranch: next.lbranch ?? "", + committedRbranch: next.rbranch ?? "", + lcommit: next.lcommit ?? null, + rcommit: next.rcommit ?? null, + // (optional) benchmarkId: next.benchmarkId, + }), + })); +} diff --git a/torchci/package.json b/torchci/package.json index c2f4249f4d..891d35c0d7 100644 --- a/torchci/package.json +++ b/torchci/package.json @@ -69,7 +69,8 @@ "typed-rest-client": "^1.8.9", "urllib": "2.44.0", "uuid": "^8.3.2", - "zod": "^3.25.64" + "zod": "^3.25.64", + "zustand": "^5.0.8" }, "devDependencies": { "@types/argparse": "^2.0.10", diff --git a/torchci/pages/_app.tsx b/torchci/pages/_app.tsx index c2ff135862..7af5d16d8e 100644 --- a/torchci/pages/_app.tsx +++ b/torchci/pages/_app.tsx @@ -73,7 +73,7 @@ function AppContent({ -
+
diff --git a/torchci/pages/benchmark/compilers_regression.tsx b/torchci/pages/benchmark/compilers_regression.tsx new file mode 100644 index 0000000000..d05bed174d --- /dev/null +++ b/torchci/pages/benchmark/compilers_regression.tsx @@ -0,0 +1,14 @@ +import BenchmarkRegressionPage from "components/benchmark/v3/BenchmarkRegressionPage"; +import { getConfig } from "components/benchmark/v3/configs/configBook"; + +export default function Page() { + const id = "compiler_precompute"; + const config = getConfig(id); + const dataBinding = config.dataBinding; + return ( + + ); +} diff --git a/torchci/yarn.lock b/torchci/yarn.lock index bf3b9a35e7..8356314535 100644 --- a/torchci/yarn.lock +++ b/torchci/yarn.lock @@ -9451,3 +9451,8 @@ zrender@5.6.1: integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag== dependencies: tslib "2.3.0" + +zustand@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a" + integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==