diff --git a/site/frontend/src/graph/api.ts b/site/frontend/src/graph/api.ts new file mode 100644 index 000000000..eca11defc --- /dev/null +++ b/site/frontend/src/graph/api.ts @@ -0,0 +1,16 @@ +import {GraphData, GraphsSelector} from "./data"; +import {getJson} from "../utils/requests"; +import {GRAPH_DATA_URL} from "../urls"; + +export async function loadGraphs(selector: GraphsSelector): Promise { + const params = { + start: selector.start, + end: selector.end, + kind: selector.kind as string, + stat: selector.stat, + benchmark: selector.benchmark, + scenario: selector.scenario, + profile: selector.profile, + }; + return await getJson(GRAPH_DATA_URL, params); +} diff --git a/site/frontend/src/pages/graphs/state.ts b/site/frontend/src/graph/data.ts similarity index 100% rename from site/frontend/src/pages/graphs/state.ts rename to site/frontend/src/graph/data.ts diff --git a/site/frontend/src/pages/graphs/plots.ts b/site/frontend/src/graph/render.ts similarity index 94% rename from site/frontend/src/pages/graphs/plots.ts rename to site/frontend/src/graph/render.ts index 6ea87ce0b..cef018e0d 100644 --- a/site/frontend/src/pages/graphs/plots.ts +++ b/site/frontend/src/graph/render.ts @@ -1,5 +1,5 @@ import uPlot, {TypedArray} from "uplot"; -import {GraphData, GraphsSelector} from "./state"; +import {GraphData, GraphsSelector} from "./data"; const commonCacheStateColors = { full: "#7cb5ec", @@ -148,7 +148,6 @@ function tooltipPlugin({ } function genPlotOpts({ - title, width, height, yAxisLabel, @@ -159,9 +158,9 @@ function genPlotOpts({ alpha = 0.3, prox = 5, absoluteMode, + hooks, }) { return { - title, width, height, series, @@ -239,6 +238,7 @@ function genPlotOpts({ ctx.stroke(); }, ], + ...hooks, }, }, tooltipPlugin({ @@ -280,13 +280,22 @@ function normalizeData(data: GraphData) { } } -// Renders the plots data with the given parameters from the `selector`, into a DOM node optionally -// selected by the `elementSelector` query. +export type GraphRenderOpts = { + renderTitle?: boolean; + hooks?: {drawSeries: (uPlot, number) => void}; +}; + +// Renders the plots data with the given parameters from the `selector` into +// the passed DOM element. export function renderPlots( data: GraphData, selector: GraphsSelector, - elementSelector: string + plotElement: HTMLElement, + opts?: GraphRenderOpts ) { + const renderTitle = opts?.renderTitle ?? true; + const hooks = opts?.hooks ?? {}; + normalizeData(data); const names = Object.keys(data.benchmarks).sort(); @@ -364,7 +373,6 @@ export function renderPlots( cacheStates[Object.keys(cacheStates)[0]].interpolated_indices; let plotOpts = genPlotOpts({ - title: benchName + "-" + benchKind, width: Math.floor(window.innerWidth / 4) - 40, height: 300, yAxisLabel, @@ -375,13 +383,13 @@ export function renderPlots( return indices.has(dataIdx); }, absoluteMode: selector.kind == "raw", + hooks, }); + if (renderTitle) { + plotOpts["title"] = `${benchName}-${benchKind}`; + } - new uPlot( - plotOpts, - plotData as any as TypedArray[], - document.querySelector(elementSelector) - ); + new uPlot(plotOpts, plotData as any as TypedArray[], plotElement); i++; } diff --git a/site/frontend/src/graph/resolver.ts b/site/frontend/src/graph/resolver.ts new file mode 100644 index 000000000..3f455e4ea --- /dev/null +++ b/site/frontend/src/graph/resolver.ts @@ -0,0 +1,29 @@ +import {GraphData, GraphsSelector} from "./data"; +import {loadGraphs} from "./api"; + +/** + * Graph API resolver that contains a cache of downloaded graphs. + * This is important for Vue components that download a graph on mount. + * Without a cache, they would download a graph each time they are destroyed + * and recreated. + */ +export class GraphResolver { + private cache: Dict = {}; + + public async loadGraph(selector: GraphsSelector): Promise { + const key = `${selector.benchmark};${selector.profile};${selector.scenario};${selector.start};${selector.end};${selector.stat};${selector.kind}`; + if (!this.cache.hasOwnProperty(key)) { + this.cache[key] = await loadGraphs(selector); + } + + return this.cache[key]; + } +} + +/** + * This is essentially a global variable, but it makes the code simpler and + * since we currently don't have any unit tests, we don't really need to avoid + * global variables that much. If needed, it could be provided to Vue components + * from a parent via props or context. + */ +export const GRAPH_RESOLVER = new GraphResolver(); diff --git a/site/frontend/src/pages/compare/compile/table/benchmark-detail.vue b/site/frontend/src/pages/compare/compile/table/benchmark-detail.vue index b3539f36d..ee85c5d38 100644 --- a/site/frontend/src/pages/compare/compile/table/benchmark-detail.vue +++ b/site/frontend/src/pages/compare/compile/table/benchmark-detail.vue @@ -5,13 +5,134 @@ import { CompileBenchmarkMetadata, CompileTestCase, } from "../common"; -import {computed} from "vue"; +import {computed, onMounted, Ref, ref} from "vue"; import Tooltip from "../../tooltip.vue"; +import {ArtifactDescription} from "../../types"; +import {daysBetweenDates, getFutureDate, getPastDate} from "./utils"; +import {GraphRenderOpts, renderPlots} from "../../../../graph/render"; +import {GRAPH_RESOLVER} from "../../../../graph/resolver"; +import {GraphKind} from "../../../../graph/data"; +import uPlot from "uplot"; const props = defineProps<{ testCase: CompileTestCase; + metric: string; + artifact: ArtifactDescription; benchmarkMap: CompileBenchmarkMap; }>(); + +type GraphRange = { + start: string; + end: string; + date: Date | null; +}; + +// How many days are shown in the graph +const DAY_RANGE = 30; + +/** + * Calculates the start and end range for a history graph for this benchmark + * and artifact. + */ +function getGraphRange(artifact: ArtifactDescription): GraphRange { + const date = new Date(artifact.date); + + // If this is a try commit, we don't know its future, so always we just display + // the last `DAY_RANGE` days. + if (artifact.type === "try") { + return { + start: getPastDate(date, DAY_RANGE), + end: artifact.commit, + date: null, + }; + } else { + // If this is a master commit, then we try to display `dayRange` days + // "centered" around the commit date. + + // Calculate the end of the range, which is commit date + half of the + // amount of days we want to show. If this date is in the future, + // the server will clip the result at the current date. + const end = getFutureDate(date, DAY_RANGE / 2); + + // Calculate how many days there have been from the commit date + const daysInFuture = Math.min( + DAY_RANGE / 2, + daysBetweenDates(date, new Date()) + ); + + // Calculate how many days we should go into the past, taking into account + // the days that will be clipped by the server. + const daysInPast = DAY_RANGE - daysInFuture; + + const start = getPastDate(date, daysInPast); + return { + start, + end, + date, + }; + } +} + +/** + * Hook into the uPlot drawing machinery to draw a vertical line at the + * position of the given `date`. + */ +function drawCurrentDate(opts: GraphRenderOpts, date: Date) { + opts.hooks = { + drawSeries: (u: uPlot) => { + let ctx = u.ctx; + ctx.save(); + + const y0 = u.bbox.top; + const y1 = y0 + u.bbox.height; + const x = u.valToPos(date.getTime() / 1000, "x", true); + + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.strokeStyle = "red"; + ctx.setLineDash([5, 5]); + ctx.lineTo(x, y1); + ctx.stroke(); + + ctx.restore(); + }, + }; +} + +async function renderGraph() { + const {start, end, date} = graphRange.value; + const selector = { + benchmark: props.testCase.benchmark, + profile: props.testCase.profile, + scenario: props.testCase.scenario, + stat: props.metric, + start, + end, + // We want to be able to see noise "blips" vs. a previous artifact. + // The "percent relative from previous commit" graph should be the best to + // see these kinds of changes. + kind: "percentrelative" as GraphKind, + }; + const graphData = await GRAPH_RESOLVER.loadGraph(selector); + const opts: GraphRenderOpts = { + renderTitle: false, + }; + if (date !== null) { + drawCurrentDate(opts, date); + } + renderPlots(graphData, selector, chartElement.value, opts); +} + +function getGraphTitle() { + const {start, end, date} = graphRange.value; + const msg = `${DAY_RANGE} day history`; + if (date !== null) { + return `${msg} (${start} - ${end})`; + } else { + return `${msg} (up to benchmarked commit)`; + } +} + const metadata = computed( (): CompileBenchmarkMetadata => props.benchmarkMap[props.testCase.benchmark] ?? null @@ -29,56 +150,92 @@ const cargoProfile = computed((): CargoProfileMetadata => { return metadata?.value.dev_profile; } }); + +const chartElement: Ref = ref(null); +const graphRange = computed(() => getGraphRange(props.artifact)); + +onMounted(() => renderGraph()); + + + diff --git a/site/frontend/src/pages/compare/compile/table/comparisons-table.vue b/site/frontend/src/pages/compare/compile/table/comparisons-table.vue index 980dbb771..9eaeb4ebd 100644 --- a/site/frontend/src/pages/compare/compile/table/comparisons-table.vue +++ b/site/frontend/src/pages/compare/compile/table/comparisons-table.vue @@ -7,6 +7,7 @@ import {CompileBenchmarkMap, CompileTestCase} from "../common"; import {computed} from "vue"; import {useExpandedStore} from "./expansion"; import BenchmarkDetail from "./benchmark-detail.vue"; +import {getPastDate} from "./utils"; const props = defineProps<{ id: string; @@ -28,15 +29,9 @@ function graphLink( stat: string, comparison: TestCaseComparison ): string { - let date = new Date(commit.date); // Move to `30 days ago` to display history of the test case - date.setUTCDate(date.getUTCDate() - 30); - let year = date.getUTCFullYear(); - let month = (date.getUTCMonth() + 1).toString().padStart(2, "0"); - let day = date.getUTCDate().toString().padStart(2, "0"); - let start = `${year}-${month}-${day}`; - - let end = commit.commit; + const start = getPastDate(new Date(commit.date), 30); + const end = commit.commit; const {benchmark, profile, scenario} = comparison.testCase; return `/index.html?start=${start}&end=${end}&benchmark=${benchmark}&profile=${profile}&scenario=${scenario}&stat=${stat}`; } @@ -202,6 +197,8 @@ const {toggleExpanded, isExpanded} = useExpandedStore(); diff --git a/site/frontend/src/pages/compare/compile/table/utils.ts b/site/frontend/src/pages/compare/compile/table/utils.ts new file mode 100644 index 000000000..8f47b452f --- /dev/null +++ b/site/frontend/src/pages/compare/compile/table/utils.ts @@ -0,0 +1,31 @@ +/** + * Returns a date that is the given days in the past from the passed `date`. + */ +export function getPastDate(date: Date, days: number): string { + const past = new Date(date.getTime()); + past.setUTCDate(date.getUTCDate() - days); + return format_ymd(past); +} + +/** + * Returns a date that is the given days in the future from the passed `date`. + */ +export function getFutureDate(date: Date, days: number): string { + const future = new Date(date.getTime()); + future.setUTCDate(date.getUTCDate() + days); + return format_ymd(future); +} + +function format_ymd(date: Date): string { + const year = date.getUTCFullYear(); + const month = (date.getUTCMonth() + 1).toString().padStart(2, "0"); + const day = date.getUTCDate().toString().padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Calculates the number of (whole) days between the two days. + */ +export function daysBetweenDates(a: Date, b: Date): number { + return Math.floor((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); +} diff --git a/site/frontend/src/pages/compare/types.ts b/site/frontend/src/pages/compare/types.ts index c1b90f7ea..b0ec11a92 100644 --- a/site/frontend/src/pages/compare/types.ts +++ b/site/frontend/src/pages/compare/types.ts @@ -16,10 +16,13 @@ export interface CompareSelector { stat: string; } +export type CommitType = "try" | "master"; + export interface ArtifactDescription { commit: string; date: string | null; pr: number | null; + type: CommitType; bootstrap: Dict; bootstrap_total: number; component_sizes: Dict; diff --git a/site/frontend/src/pages/graphs/data-selector.vue b/site/frontend/src/pages/graphs/data-selector.vue index 9a11a8183..3f0032bb3 100644 --- a/site/frontend/src/pages/graphs/data-selector.vue +++ b/site/frontend/src/pages/graphs/data-selector.vue @@ -1,6 +1,6 @@