Refactor Plot benchmarks.
* Faster rendering.
* Flexible mappers for custom series generation.
* Make it easier to visually inspect and find benchmark data sources.
Change-Id: I37634549e0204fff3c6059717d12566d0abdf630
Test: `npm run-script dev`.
diff --git a/development/plot-benchmarks/src/chart-transforms.ts b/development/plot-benchmarks/src/chart-transforms.ts
deleted file mode 100644
index f76049f..0000000
--- a/development/plot-benchmarks/src/chart-transforms.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { ChartDataset } from "chart.js";
-import type { Data } from "./transforms.js";
-
-export function chartData(container: Array<Data>) {
- let xmax = 0;
- let datasets = [];
- for (let i = 0; i < container.length; i += 1) {
- // update xmax
- xmax = Math.max(xmax, container[i].data.length);
- datasets.push(chartDataset(container[i]));
- }
- return {
- labels: xrange(xmax),
- datasets: datasets
- };
-}
-
-function xrange(xmax: number): Array<number> {
- let labels = [];
- for (let i = 1; i <= xmax; i += 1) {
- labels.push(i);
- }
- return labels;
-}
-
-function chartDataset(data: Data): ChartDataset<'line'> {
- return {
- label: data.name,
- data: data.data,
- tension: 0.3
- };
-}
diff --git a/development/plot-benchmarks/src/files.ts b/development/plot-benchmarks/src/files.ts
index a5e26ef..6d45cd9 100644
--- a/development/plot-benchmarks/src/files.ts
+++ b/development/plot-benchmarks/src/files.ts
@@ -1,14 +1,4 @@
-import type { Benchmarks } from "./schema.js";
-
-export interface FileMetadata {
- file: File;
- enabled: boolean;
- benchmarks: Benchmarks;
-}
-
-export interface FileMetadataEvent {
- entries: Array<FileMetadata>;
-}
+import type { Benchmarks } from "./types/benchmark.js";
export async function readBenchmarks(file: File): Promise<Benchmarks> {
const contents = await readFile(file);
diff --git a/development/plot-benchmarks/src/lib/App.svelte b/development/plot-benchmarks/src/lib/App.svelte
index e50908c..ffa3e57 100644
--- a/development/plot-benchmarks/src/lib/App.svelte
+++ b/development/plot-benchmarks/src/lib/App.svelte
@@ -1,23 +1,14 @@
<script lang="ts">
import type { Writable } from "svelte/store";
- import { derived, writable } from "svelte/store";
- import type { FileMetadata } from "../files.js";
- import type { Benchmarks } from "../schema.js";
- import Chart from "./Chart.svelte";
- import Files from "./Files.svelte";
+ import { writable } from "svelte/store";
+ import type { FileMetadata } from "../types/files.js";
+ import Session from "./Session.svelte";
- let entries: Writable<Array<FileMetadata>> = writable([]);
- let containers = derived(entries, ($entries) => {
- const containers: Array<Benchmarks> = [];
- for (let i = 0; i < $entries.length; i += 1) {
- let entry = $entries[i];
- containers.push(entry.benchmarks);
- }
- return containers;
- });
+ // Stores
+ let entries: Writable<FileMetadata[]> = writable([]);
- function onFileMetadataEvent(event) {
- const detail: Array<FileMetadata> = event.detail;
+ function onFilesChanged(event) {
+ const detail: FileMetadata[] = event.detail;
if (detail) {
const enabled = detail.filter((metadata) => metadata.enabled === true);
$entries = [...enabled];
@@ -31,8 +22,5 @@
</details>
<div class="container">
- <Files on:entries={onFileMetadataEvent} />
- {#if $containers.length > 0}
- <Chart {containers} />
- {/if}
+ <Session fileEntries={$entries} on:entries={onFilesChanged} />
</div>
diff --git a/development/plot-benchmarks/src/lib/Chart.svelte b/development/plot-benchmarks/src/lib/Chart.svelte
index 257f228..7a063a8 100644
--- a/development/plot-benchmarks/src/lib/Chart.svelte
+++ b/development/plot-benchmarks/src/lib/Chart.svelte
@@ -1,42 +1,33 @@
<script lang="ts">
- import type { LegendItem } from "chart.js";
+ import type { ChartType, LegendItem } from "chart.js";
import { Chart } from "chart.js/auto";
import { onMount } from "svelte";
- import type { Readable, Writable } from "svelte/store";
- import { derived, writable } from "svelte/store";
- import { chartData } from "../chart-transforms.js";
- import { saveToClipboard } from "../clipboard.js";
+ import { writable, type Writable } from "svelte/store";
+ import type { Data } from "../types/chart.js";
import { LegendPlugin } from "../plugins.js";
- import { expressionFilter } from "../regexp.js";
- import type { Benchmarks } from "../schema.js";
- import { benchmarksDataset } from "../transforms.js";
- type FilterFn = (label: string) => boolean;
+ import Legend from "./Legend.svelte";
+ import { saveToClipboard as save } from "../clipboard.js";
- export let containers: Readable<Array<Benchmarks>>;
+ export let data: Data;
+ export let chartType: ChartType = "line";
- let canvas: HTMLCanvasElement;
- let filter: Writable<FilterFn> = writable((_: string) => true);
-
- let data = derived([containers, filter], ([$containers, $filter]) => {
- return chartData(benchmarksDataset($containers, $filter));
- });
-
- let chart: Writable<Chart | undefined> = writable(null);
- let legendLabels: Writable<Array<LegendItem> | undefined> = writable(null);
-
- data.subscribe(($data) => {
+ $: {
if ($chart) {
- $chart.data = $data;
+ $chart.data = data;
$chart.update();
}
- });
+ }
+ // State
+ let element: HTMLCanvasElement;
+ let chart: Writable<Chart | null> = writable(null);
+ let items: Writable<LegendItem[] | null> = writable(null);
+
+ // Effects
onMount(() => {
- const onUpdate = (updated: Chart) => {
- chart.set(updated);
- legendLabels.set(
- updated.options.plugins.legend.labels.generateLabels(updated)
- );
+ const onUpdate = (chart: Chart) => {
+ $chart = chart;
+ $items = chart.options.plugins.legend.labels.generateLabels(chart);
};
const plugins = {
legend: {
@@ -46,119 +37,56 @@
onUpdate: onUpdate,
},
};
- chart.set(
- new Chart(canvas, {
- type: "line",
- data: $data,
- plugins: [LegendPlugin],
- options: {
- plugins: plugins,
- },
- })
- );
+ $chart = new Chart(element, {
+ data: data,
+ type: chartType,
+ plugins: [LegendPlugin],
+ options: {
+ plugins: plugins,
+ },
+ });
});
- function onItemClick(item: LegendItem) {
- return (_: Event) => {
- // https://www.chartjs.org/docs/latest/samples/legend/html.html
- $chart.setDatasetVisibility(
- item.datasetIndex,
- !$chart.isDatasetVisible(item.datasetIndex)
- );
- // Update chart
- $chart.update();
- };
- }
-
- function onFilterChanged(event: Event) {
- const target = event.currentTarget as HTMLInputElement;
- const value = target.value;
- $filter = expressionFilter(value);
- }
-
- async function onCopy(_: Event) {
+ // Copy to clip board
+ async function copy(event: Event) {
if ($chart) {
- await saveToClipboard($chart);
+ await save($chart);
}
}
</script>
<article>
- <button
- class="copy outline"
- data-tooltip="Copy chart to clipboard."
- on:click={onCopy}
- >
- ⎘
- </button>
- <canvas id="chart" class="chart" bind:this={canvas} />
+ <div class="toolbar">
+ <button
+ class="btn outline"
+ data-tooltip="Copy chart to clipboard."
+ on:click={copy}
+ >
+ ⎘
+ </button>
+ </div>
+ <canvas id="chart" class="chart" bind:this={element} />
</article>
-{#if $legendLabels && $legendLabels.length >= 0}
- <article>
- <div class="filter">
- <label for="metricFilter">
- <input
- type="text"
- id="metricFilter"
- name="metricFilter"
- placeholder="Filter metrics (regular expressions)"
- autocomplete="off"
- on:input={onFilterChanged}
- />
- </label>
- </div>
- <div class="legend">
- {#each $legendLabels as label, index}
- <div
- class="item"
- on:dblclick={onItemClick(label)}
- aria-label="legend"
- role="listitem"
- >
- <span
- class="box"
- style="background: {label.fillStyle}; border-color: {label.strokeStyle}; border-width: {label.lineWidth}px;"
- />
- <span
- class="label"
- style="text-decoration: {label.hidden ? 'line-through' : ''};"
- >
- {label.text}
- </span>
- </div>
- {/each}
- </div>
- </article>
+{#if $items}
+ <Legend chart={$chart} items={$items} />
{/if}
<style>
- .copy {
- width: 4%;
- position: relative;
- padding: 0;
- border: none;
- /* Looked okay on my machine. */
- right: -52rem;
- top: -3rem;
- }
.chart {
width: 100%;
}
- .legend {
- display: flex;
- flex-direction: column;
- row-gap: 3px;
- }
- .item {
+ .toolbar {
+ padding: 0;
display: flex;
flex-direction: row;
- column-gap: 10px;
- align-items: center;
+ justify-content: flex-end;
}
- .item .box {
- display: inline-block;
- width: 20px;
- height: 20px;
+
+ .toolbar .btn {
+ width: auto;
+ height: auto;
+ border: none;
+ padding: 5px;
}
</style>
diff --git a/development/plot-benchmarks/src/lib/Dataset.svelte b/development/plot-benchmarks/src/lib/Dataset.svelte
new file mode 100644
index 0000000..ce37d45
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Dataset.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import { Session, type IndexedWrapper } from "../wrappers/session.js";
+
+ export let name: string;
+ export let datasetGroup: IndexedWrapper[];
+
+ let sources: Set<string>;
+ let sampledMetrics: Set<string>;
+ let metrics: Set<string>;
+
+ $: {
+ sources = Session.sources(datasetGroup);
+
+ let labels = datasetGroup
+ .map((indexed) => indexed.value.metricLabels())
+ .flat();
+
+ let sampled = datasetGroup
+ .map((indexed) => indexed.value.sampledLabels())
+ .flat();
+
+ metrics = new Set<string>(labels);
+ sampledMetrics = new Set<string>(sampled);
+ }
+</script>
+
+<div class="dataset">
+ <hgroup>
+ <div>{name}</div>
+ <div class="details">
+ <div class="sources">
+ {#each sources as source (source)}
+ <div>📁 <small>{source}</small></div>
+ {/each}
+ </div>
+ {#if metrics.size > 0}
+ <div class="metrics">
+ {#each metrics as metric}
+ <div>📏 <small>{metric}</small></div>
+ {/each}
+ </div>
+ {/if}
+ {#if sampledMetrics.size > 0}
+ <div class="sampled">
+ {#each sampledMetrics as metric}
+ <div>📏 <small>{metric}</small></div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </hgroup>
+</div>
+
+<style>
+ .dataset {
+ margin: 0.5rem;
+ padding-top: 1rem;
+ padding-left: 1rem;
+ border: 1px solid #b3cee5;
+ }
+ .details {
+ padding-left: 1rem;
+ }
+
+ .details > div {
+ margin-top: 0.25rem;
+ }
+</style>
diff --git a/development/plot-benchmarks/src/lib/Files.svelte b/development/plot-benchmarks/src/lib/Files.svelte
deleted file mode 100644
index ffe09d6..0000000
--- a/development/plot-benchmarks/src/lib/Files.svelte
+++ /dev/null
@@ -1,124 +0,0 @@
-<script lang="ts">
- import { createEventDispatcher } from "svelte";
- import type { Writable } from "svelte/store";
- import { writable } from "svelte/store";
- import type { FileMetadata, FileMetadataEvent } from "../files.js";
- import { readBenchmarks } from "../files.js";
-
- let active: Writable<boolean> = writable(false);
- let entries: Writable<Array<FileMetadata>> = writable([]);
- let dispatcher = createEventDispatcher<FileMetadataEvent>();
-
- entries.subscribe(($entries) => {
- // Dispatch an event anytime files change.
- dispatcher("entries", [...$entries]);
- });
-
- function onDropFile(event: DragEvent) {
- handleDropFile(event); // async
- active.set(false);
- event.preventDefault();
- }
-
- async function handleDropFile(event: DragEvent) {
- const items = [...event.dataTransfer.items];
- const metadata = [];
- if (items) {
- for (let i = 0; i < items.length; i += 1) {
- if (items[i].kind === "file") {
- const file = items[i].getAsFile();
- if (file.name.endsWith(".json")) {
- const benchmarks = await readBenchmarks(file);
- const entry: FileMetadata = {
- enabled: true,
- file: file,
- benchmarks: benchmarks,
- };
- metadata.push(entry);
- }
- }
- }
- // Deep copy
- $entries = [...$entries, ...metadata];
- }
- }
-
- function onDragOver(event: DragEvent) {
- active.set(true);
- event.preventDefault();
- }
-
- function onDragLeave(event: DragEvent) {
- active.set(false);
- event.preventDefault();
- }
-
- function onChecked(entries: Array<FileMetadata>, index: number) {
- return (event: Event) => {
- // Deep copy
- const copied = [...entries];
- copied[index].enabled = !copied[index].enabled;
- $entries = copied;
- };
- }
-</script>
-
-<article
- id="drop"
- class="drop"
- class:active={$active}
- on:drop={onDropFile}
- on:dragover={onDragOver}
- on:dragleave={onDragLeave}
->
- {#if $entries.length > 0}
- <div class="files">
- {#each $entries as entry, index}
- <div class="file">
- <input
- type="checkbox"
- checked={entry.enabled}
- on:change={onChecked($entries, index)}
- />
- <div class="index">
- <span>{index + 1}</span>
- </div>
- <span class="label">{entry.file.name}</span>
- </div>
- {/each}
- </div>
- {:else}
- <h5>Drag and drop benchmark results to get started.</h5>
- {/if}
-</article>
-
-<style>
- .active {
- background-color: #00bfff;
- outline: gray;
- outline-style: groove;
- }
-
- .files {
- display: flex;
- flex-direction: column;
- row-gap: 3px;
- }
-
- .file {
- display: flex;
- flex-direction: row;
- column-gap: 10px;
- align-items: center;
- }
-
- .index {
- width: 2rem;
- height: 2rem;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- border: 2px solid sandybrown;
- }
-</style>
diff --git a/development/plot-benchmarks/src/lib/Group.svelte b/development/plot-benchmarks/src/lib/Group.svelte
new file mode 100644
index 0000000..328e606
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Group.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { Session, type IndexedWrapper } from "../wrappers/session.js";
+ import Dataset from "./Dataset.svelte";
+
+ export let className: string;
+ export let datasetGroup: IndexedWrapper[];
+ let datasetNames: Set<string>;
+ $: {
+ datasetNames = Session.datasetNames(datasetGroup);
+ }
+</script>
+
+<details open>
+ <summary>{className}</summary>
+ <div class="details">
+ {#each datasetNames as name (name)}
+ <Dataset {datasetGroup} {name} />
+ {/each}
+ </div>
+</details>
diff --git a/development/plot-benchmarks/src/lib/Legend.svelte b/development/plot-benchmarks/src/lib/Legend.svelte
new file mode 100644
index 0000000..ef239f5
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Legend.svelte
@@ -0,0 +1,60 @@
+<script lang="ts">
+ import type { Chart, LegendItem } from "chart.js";
+ export let chart: Chart;
+ export let items: LegendItem[];
+
+ const handlerFactory = (item: LegendItem) => {
+ return (event: Event) => {
+ // https://www.chartjs.org/docs/latest/samples/legend/html.html
+ chart.setDatasetVisibility(
+ item.datasetIndex,
+ !chart.isDatasetVisible(item.datasetIndex)
+ );
+ chart.update();
+ };
+ };
+</script>
+
+<div class="legend">
+ <h6>Legend</h6>
+ {#each items as item (item)}
+ <div
+ class="item"
+ on:dblclick={handlerFactory(item)}
+ aria-label="legend"
+ role="listitem"
+ >
+ <span
+ class="box"
+ style="background: {item.fillStyle}; border-color: {item.strokeStyle}; border-width: {item.lineWidth}px;"
+ />
+ <span
+ class="label"
+ style="text-decoration: {item.hidden ? 'line-through' : ''};"
+ >
+ {item.text}
+ </span>
+ </div>
+ {/each}
+</div>
+
+<style>
+ .legend {
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+ row-gap: 3px;
+ }
+
+ .item {
+ display: flex;
+ flex-direction: row;
+ column-gap: 10px;
+ align-items: center;
+ }
+ .item .box {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ }
+</style>
diff --git a/development/plot-benchmarks/src/lib/Session.svelte b/development/plot-benchmarks/src/lib/Session.svelte
new file mode 100644
index 0000000..4b0cf57
--- /dev/null
+++ b/development/plot-benchmarks/src/lib/Session.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+ import { createEventDispatcher } from "svelte";
+ import type { FileMetadata } from "../types/files.js";
+ import { Session, type IndexedWrapper } from "../wrappers/session.js";
+ import Group from "./Group.svelte";
+ import type { FileMetadataEvent } from "../types/events.js";
+ import { writable, type Writable } from "svelte/store";
+ import { readBenchmarks } from "../files.js";
+ import type { Metrics } from "../types/data.js";
+ import { Transforms } from "../transforms/metric-transforms.js";
+ import type { Data, Series } from "../types/chart.js";
+ import { ChartDataTransforms } from "../transforms/data-transforms.js";
+ import { STANDARD_MAPPER } from "../transforms/standard-mappers.js";
+ import Chart from "./Chart.svelte";
+
+ export let fileEntries: FileMetadata[];
+
+ // State
+ let eventDispatcher = createEventDispatcher<FileMetadataEvent>();
+ let session: Session;
+ let metrics: Metrics<number>;
+ let series: Series[];
+ let chartData: Data;
+ let classGroups: Record<string, IndexedWrapper[]>;
+ let size: number;
+
+ // Stores
+ let activeDragDrop: Writable<boolean> = writable(false);
+
+ $: {
+ session = new Session(fileEntries);
+ metrics = Transforms.buildMetrics(session);
+ series = ChartDataTransforms.mapToSeries(metrics, STANDARD_MAPPER);
+ chartData = ChartDataTransforms.mapToDataset(series);
+ classGroups = session.classGroups;
+ size = session.fileNames.size;
+ }
+
+ // Helpers
+
+ function onDropFile(event: DragEvent) {
+ handleFileDragDrop(event); // async
+ $activeDragDrop = false;
+ event.preventDefault();
+ }
+
+ function onDragOver(event: DragEvent) {
+ $activeDragDrop = true;
+ event.preventDefault();
+ }
+
+ function onDragLeave(event: DragEvent) {
+ $activeDragDrop = false;
+ event.preventDefault();
+ }
+
+ async function handleFileDragDrop(event: DragEvent) {
+ const items = [...event.dataTransfer.items];
+ const newFiles: FileMetadata[] = [];
+ if (items) {
+ for (let i = 0; i < items.length; i += 1) {
+ if (items[i].kind === "file") {
+ const file = items[i].getAsFile();
+ if (file.name.endsWith(".json")) {
+ const benchmarks = await readBenchmarks(file);
+ const entry: FileMetadata = {
+ enabled: true,
+ file: file,
+ container: benchmarks,
+ };
+ newFiles.push(entry);
+ }
+ }
+ }
+ // Deep copy & notify
+ eventDispatcher("entries", [...fileEntries, ...newFiles]);
+ }
+ }
+</script>
+
+{#if size <= 0}
+ <article
+ id="drop"
+ class="drop"
+ class:active={$activeDragDrop}
+ on:drop={onDropFile}
+ on:dragover={onDragOver}
+ on:dragleave={onDragLeave}
+ >
+ <h5>Drag and drop benchmark results to get started.</h5>
+ </article>
+{:else}
+ <article
+ id="drop"
+ class="drop"
+ class:active={$activeDragDrop}
+ on:drop={onDropFile}
+ on:dragover={onDragOver}
+ on:dragleave={onDragLeave}
+ >
+ <h5>Benchmarks</h5>
+ {#each Object.entries(classGroups) as [className, wrappers]}
+ <Group {className} datasetGroup={wrappers} />
+ {/each}
+ </article>
+
+ {#if series.length > 0}
+ <Chart data={chartData} />
+ {/if}
+{/if}
+
+<style>
+ .active {
+ outline: beige;
+ outline-style: dashed;
+ }
+</style>
diff --git a/development/plot-benchmarks/src/regexp.ts b/development/plot-benchmarks/src/regexp.ts
deleted file mode 100644
index b99513d..0000000
--- a/development/plot-benchmarks/src/regexp.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Supports regular expressions, while falling back gracefully to substring matching.
- */
-export function expressionFilter(expression: string) {
- let regExp: RegExp | null = null;
- try {
- regExp = new RegExp(expression, 'g')
- } catch (error) {
- // Invalid regular expression.
- // Falling back to substring matching.
- console.warn(`Invalid regular expression ${expression}. Falling back to substring matching.`)
- }
- if (regExp) {
- return (label: string) => regExp.test(label);
- }
- return (label: string) => label.indexOf(expression) >= 0;
-}
diff --git a/development/plot-benchmarks/src/transforms.ts b/development/plot-benchmarks/src/transforms.ts
deleted file mode 100644
index be74e71..0000000
--- a/development/plot-benchmarks/src/transforms.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import type { Point } from "chart.js";
-import type { Benchmark, Benchmarks, Metrics, MetricsCollection } from "./schema.js";
-
-/**
- * A container for the chart data type.
- */
-export interface Data {
- name: string;
- data: Array<Point>;
-}
-
-export function benchmarksDataset(containers: Array<Benchmarks>, filterFn: (label: string) => boolean): Array<Data> {
- const datasets: Array<Data> = [];
- for (let i = 0; i < containers.length; i += 1) {
- let container = containers[i];
- for (let j = 0; j < container.benchmarks.length; j += 1) {
- const benchmark = container.benchmarks[j];
- // 1 based index
- const prefix = datasetName(i + 1, benchmark);
- const labels = metricLabels(benchmark);
- for (let k = 0; k < labels.length; k += 1) {
- const name = `${prefix}_${labels[k]}`;
- if (filterFn(name)) {
- const metrics = metricsData(benchmark, labels[k]);
- if (metrics) {
- const sampled = metrics.runs && Array.isArray(metrics.runs[0]);
- if (sampled) {
- // This is always the case for single metrics
- const runs = metrics.runs as number[][];
- datasets.push({
- name: name,
- // Compute histograms
- data: histogramPoints(runs)
- });
- } else {
-
- const runs = metrics.runs as number[];
- datasets.push({
- name: name,
- // Might want to make this compatible for scatter plots
- data: singlePoints(runs)
- });
- }
- }
- }
- }
- }
- }
- return datasets;
-}
-
-function datasetName(index: number, benchmark: Benchmark): string {
- const className = benchmark.className;
- const parts = className.split('.');
- const lastIndex = parts.length - 1;
- return `${index}_${parts[lastIndex]}_${benchmark.name}`;
-}
-
-function metricsData(benchmark: Benchmark, label: string): Metrics | undefined {
- let data = metricsDataFor(benchmark.metrics, label);
- if (data) {
- return data;
- }
- return metricsDataFor(benchmark.sampledMetrics, label);
-}
-
-function metricsDataFor(collection: MetricsCollection, label: string): Metrics | undefined {
- for (const key in collection) {
- if (collection.hasOwnProperty(key) && key == label) {
- return collection[key];
- }
- }
- return undefined;
-}
-
-function metricLabels(benchmark: Benchmark): Array<string> {
- const labels = labelsFor(benchmark.metrics);
- const sampled = labelsFor(benchmark.sampledMetrics);
- return [...labels, ...sampled];
-}
-
-function labelsFor(collection: MetricsCollection): Array<string> {
- const labels = [];
- if (collection) {
- for (const key in collection) {
- if (collection.hasOwnProperty(key)) {
- labels.push(key);
- }
- }
- }
- return labels;
-}
-
-function histogramPoints(runs: Array<number[]>, buckets: number = 10): Array<Point> {
- const flattened = runs.flat();
- // Default comparator coerces types to string !
- flattened.sort((a, b) => a - b); // in-place
- const min = flattened[0];
- const max = flattened[flattened.length - 1];
- const histogram = new Array(buckets).fill(0);
- const slots = buckets - 1; // The actual number of slots in the histogram
- for (let i = 0; i < flattened.length; i += 1) {
- let n = normalize(flattened[i], min, max);
- let index = Math.ceil(n * slots);
- histogram[index] = histogram[index] + 1;
- }
- return singlePoints(histogram);
-}
-
-function normalize(n: number, min: number, max: number): number {
- return (n - min) / (max - min + 1e-5);
-}
-
-function singlePoints(runs: Array<number>): Array<Point> {
- const points: Array<Point> = [];
- for (let i = 0; i < runs.length; i += 1) {
- points.push({
- x: i + 1, // 1 based index
- y: runs[i]
- });
- }
- return points;
-}
diff --git a/development/plot-benchmarks/src/transforms/data-transforms.ts b/development/plot-benchmarks/src/transforms/data-transforms.ts
new file mode 100644
index 0000000..daadebb
--- /dev/null
+++ b/development/plot-benchmarks/src/transforms/data-transforms.ts
@@ -0,0 +1,66 @@
+import type { ChartDataset, ChartType, Point } from "chart.js";
+import type { Data, Series } from "../types/chart.js";
+import type { Metric, Metrics } from "../types/data.js";
+
+export interface Mapper<T = number> {
+ standard: (value: Metric<T>) => Series[];
+ sampled: (value: Metric<T[]>) => Series[];
+}
+
+/**
+ * Converts `Metrics` to the corresponding chart data structures.
+ */
+export class ChartDataTransforms {
+
+ static mapToSeries(metrics: Metrics<number>, mapper: Mapper<number>): Series[] {
+ const series: Series[] = [];
+ const standard = metrics.standard;
+ const sampled = metrics.sampled;
+ if (standard) {
+ for (let i = 0; i < standard.length; i += 1) {
+ const metric = standard[i];
+ const mapped = mapper.standard(metric);
+ series.push(...mapped);
+ }
+ }
+ if (sampled) {
+ for (let i = 0; i < sampled.length; i += 1) {
+ const metric = sampled[i];
+ const mapped = mapper.sampled(metric);
+ series.push(...mapped);
+ }
+ }
+ return series;
+ }
+
+ static mapToDataset(series: Series[]): Data {
+ let xmax = 0;
+ let datasets: ChartDataset[] = [];
+ for (let i = 0; i < series.length; i += 1) {
+ xmax = Math.max(xmax, series[i].data.length);
+ datasets.push(ChartDataTransforms.chartDataset(series[i]));
+ }
+ const chartData: Data = {
+ labels: ChartDataTransforms.xrange(xmax),
+ datasets: datasets
+ };
+ return chartData;
+ }
+
+ private static chartDataset<T extends ChartType>(series: Series): ChartDataset {
+ return {
+ label: series.label,
+ type: series.type,
+ data: series.data,
+ ...series.options
+ };
+ }
+
+ private static xrange(xmax: number): number[] {
+ let range = [];
+ for (let i = 1; i <= xmax; i += 1) {
+ range.push(i);
+ }
+ return range;
+ }
+}
diff --git a/development/plot-benchmarks/src/transforms/metric-transforms.ts b/development/plot-benchmarks/src/transforms/metric-transforms.ts
new file mode 100644
index 0000000..56a8763
--- /dev/null
+++ b/development/plot-benchmarks/src/transforms/metric-transforms.ts
@@ -0,0 +1,115 @@
+import type { ChartData, Metric, Metrics } from "../types/data.js";
+import type { Session } from "../wrappers/session.js";
+
+/**
+ * Helps with transforming benchmark data into something that can be visualized easily.
+ */
+export class Transforms {
+ private constructor() {
+ // static helpers.
+ }
+
+ static buildMetrics(session: Session): Metrics<number> {
+ const classGroups = Object.entries(session.classGroups);
+ const standard: Metric<number>[] = [];
+ const sampled: Metric<number[]>[] = [];
+ for (let i = 0; i < classGroups.length; i += 1) {
+ const [className, wrappers] = classGroups[i];
+ for (let j = 0; j < wrappers.length; j += 1) {
+ const wrapper = wrappers[j];
+ const source = wrapper.source;
+ const testName = wrapper.value.testName();
+ // standard
+ let labels = wrapper.value.metricLabels();
+ for (let k = 0; k < labels.length; k += 1) {
+ const label = labels[k];
+ const metric = wrapper.value.metric(label);
+ const charData: ChartData<number> = {
+ values: metric.runs
+ };
+ Transforms.add<number>(
+ standard,
+ className,
+ testName,
+ label,
+ source,
+ charData
+ );
+ }
+ // sampled
+ labels = wrapper.value.sampledLabels();
+ for (let k = 0; k < labels.length; k += 1) {
+ const label = labels[k];
+ const metric = wrapper.value.sampled(label);
+ const charData: ChartData<number[]> = {
+ values: metric.runs
+ };
+ Transforms.add<number[]>(
+ sampled,
+ className,
+ testName,
+ label,
+ source,
+ charData
+ );
+ }
+ }
+ }
+ const metrics: Metrics<number> = {
+ standard: standard,
+ sampled: sampled
+ };
+ return metrics;
+ }
+
+ private static add<T>(
+ metrics: Metric<T>[],
+ className: string,
+ testName: string,
+ label: string,
+ source: string,
+ data: ChartData<T>
+ ) {
+ const metric = Transforms.getOrCreate<T>(metrics, className, testName, label);
+ metric.data[source] = data;
+ }
+
+ private static getOrCreate<T>(
+ metrics: Metric<T>[],
+ className: string,
+ testName: string,
+ label: string
+ ): Metric<T> {
+ let metric: Metric<T> | null = Transforms.find(metrics, className, testName, label);
+ if (metric == null) {
+ const data: Record<string, ChartData<T>> = {};
+ metric = {
+ class: className,
+ benchmark: testName,
+ label: label,
+ data: data
+ }
+ metrics.push(metric);
+ }
+ return metric;
+ }
+
+ private static find<T>(
+ metrics: Metric<T>[],
+ className: string,
+ testName: string,
+ label: string
+ ): Metric<T> | null {
+ for (let i = 0; i < metrics.length; i += 1) {
+ const metric = metrics[i];
+ if (
+ metric.class === className &&
+ metric.benchmark === testName &&
+ metric.label === label
+ ) {
+ return metric;
+ }
+ }
+ return null;
+ }
+}
diff --git a/development/plot-benchmarks/src/transforms/standard-mappers.ts b/development/plot-benchmarks/src/transforms/standard-mappers.ts
new file mode 100644
index 0000000..8b928cd
--- /dev/null
+++ b/development/plot-benchmarks/src/transforms/standard-mappers.ts
@@ -0,0 +1,90 @@
+import type { Point } from "chart.js";
+import type { Series } from "../types/chart.js";
+import type { ChartData, Metric } from "../types/data.js";
+import type { Mapper } from "./data-transforms.js";
+
+function sampledMapper(metric: Metric<number[]>): Series[] {
+ const series: Series[] = [];
+ const data: Record<string, ChartData<number[]>> = metric.data;
+ const entries = Object.entries(data);
+ for (let i = 0; i < entries.length; i += 1) {
+ const [source, chartData] = entries[i];
+ const label = labelFor(metric, source);
+ const points = histogramPoints(chartData.values);
+ series.push({
+ label: label,
+ type: "line",
+ data: points,
+ options: {
+ tension: 0.3
+ }
+ });
+ }
+ return series;
+}
+
+function standardMapper(metric: Metric<number>): Series[] {
+ const series: Series[] = [];
+ const data: Record<string, ChartData<number>> = metric.data;
+ const entries = Object.entries(data);
+ for (let i = 0; i < entries.length; i += 1) {
+ const [source, chartData] = entries[i];
+ const label = labelFor(metric, source);
+ const points = singlePoints(chartData.values);
+ series.push({
+ label: label,
+ type: "line",
+ data: points,
+ options: {
+ tension: 0.3
+ }
+ });
+ }
+ return series;
+}
+
+function histogramPoints(runs: number[][], buckets: number = 10): Point[] {
+ const flattened = runs.flat();
+ // Default comparator coerces types to string !
+ flattened.sort((a, b) => a - b); // in-place
+ const min = flattened[0];
+ const max = flattened[flattened.length - 1];
+ const histogram = new Array(buckets).fill(0);
+ const slots = buckets - 1; // The actual number of slots in the histogram
+ for (let i = 0; i < flattened.length; i += 1) {
+ let n = normalize(flattened[i], min, max);
+ let index = Math.ceil(n * slots);
+ histogram[index] = histogram[index] + 1;
+ }
+ return singlePoints(histogram);
+}
+
+function singlePoints(runs: number[]): Point[] {
+ const points: Point[] = [];
+ for (let i = 0; i < runs.length; i += 1) {
+ points.push({
+ x: i + 1, // 1 based index
+ y: runs[i]
+ });
+ }
+ return points;
+}
+
+function normalize(n: number, min: number, max: number): number {
+ return (n - min) / (max - min + 1e-5);
+}
+
+/**
+ * Generates a series label.
+ */
+function labelFor<T>(metric: Metric<T>, source: string): string {
+ return `${source}[${metric.class} ${metric.benchmark} ${metric.label}]`;
+}
+
+/**
+ * The standard mapper.
+ */
+export const STANDARD_MAPPER: Mapper = {
+ standard: standardMapper,
+ sampled: sampledMapper
+};
diff --git a/development/plot-benchmarks/src/schema.ts b/development/plot-benchmarks/src/types/benchmark.ts
similarity index 100%
rename from development/plot-benchmarks/src/schema.ts
rename to development/plot-benchmarks/src/types/benchmark.ts
diff --git a/development/plot-benchmarks/src/types/chart.ts b/development/plot-benchmarks/src/types/chart.ts
new file mode 100644
index 0000000..0d77b63
--- /dev/null
+++ b/development/plot-benchmarks/src/types/chart.ts
@@ -0,0 +1,27 @@
+import type { ChartDataset, ChartType, Point } from "chart.js";
+
+/**
+ * The chart data container.
+ *
+ * Has relevant default X-Axis labels & corresponding datasets.
+ */
+export interface Data {
+ // X-axis labels.
+ labels: number[];
+ // The corresponding datasets.
+ datasets: ChartDataset[];
+}
+
+/**
+ * A single data-series being rendered in the chart.
+ *
+ * Used by a Mapper for data transformations.
+ */
+export interface Series {
+ label: string;
+ type: ChartType;
+ data: Array<Point>;
+ // Additional series options
+ // For e.g. https://www.chartjs.org/docs/latest/charts/line.html
+ options: object;
+}
diff --git a/development/plot-benchmarks/src/types/data.ts b/development/plot-benchmarks/src/types/data.ts
new file mode 100644
index 0000000..6e6955c
--- /dev/null
+++ b/development/plot-benchmarks/src/types/data.ts
@@ -0,0 +1,28 @@
+/**
+ * A container for raw benchmark data.
+ *
+ * Can be extended to store descriptive statistics about the raw data.
+ */
+export interface ChartData<T> {
+ values: T[];
+}
+
+/**
+ * A container for a Metric.
+ *
+ * This metric has all relevant comparables, in the data keyed by the source.
+ */
+export interface Metric<T> {
+ class: string;
+ benchmark: string;
+ label: string;
+ data: Record<string, ChartData<T>>;
+}
+
+/**
+ * A container for standard and sampled `Metric` instances.
+ */
+export interface Metrics<T = number> {
+ standard?: Metric<T>[];
+ sampled?: Metric<T[]>[];
+}
diff --git a/development/plot-benchmarks/src/types/events.ts b/development/plot-benchmarks/src/types/events.ts
new file mode 100644
index 0000000..48c4777
--- /dev/null
+++ b/development/plot-benchmarks/src/types/events.ts
@@ -0,0 +1,5 @@
+import type { FileMetadata } from "./files.js";
+
+export interface FileMetadataEvent {
+ entries: Array<FileMetadata>;
+}
diff --git a/development/plot-benchmarks/src/types/files.ts b/development/plot-benchmarks/src/types/files.ts
new file mode 100644
index 0000000..930369e
--- /dev/null
+++ b/development/plot-benchmarks/src/types/files.ts
@@ -0,0 +1,10 @@
+import type { Benchmarks } from "./benchmark.js";
+
+/**
+ * File information + associated benchmarks.
+ */
+export interface FileMetadata {
+ file: File;
+ enabled: boolean;
+ container: Benchmarks;
+}
diff --git a/development/plot-benchmarks/src/wrappers/benchmarks.ts b/development/plot-benchmarks/src/wrappers/benchmarks.ts
new file mode 100644
index 0000000..9bbc015
--- /dev/null
+++ b/development/plot-benchmarks/src/wrappers/benchmarks.ts
@@ -0,0 +1,54 @@
+import type { Benchmark, MetricsCollection, Sampled, Standard } from "../types/benchmark.js";
+
+export class BenchmarkWrapper {
+ constructor(
+ private benchmark: Benchmark,
+ private separator: string = '_'
+ ) {
+
+ }
+
+ datasetName(): string {
+ return `${this.className()}${this.separator}${this.benchmark.name}`;
+ }
+
+ metric(label: string): Standard | undefined {
+ return this.benchmark?.metrics[label];
+ }
+
+ sampled(label: string): Sampled | undefined {
+ return this.benchmark?.sampledMetrics[label];
+ }
+
+ metricLabels(): string[] {
+ return BenchmarkWrapper.labels(this.benchmark.metrics);
+ }
+
+ sampledLabels(): string[] {
+ return BenchmarkWrapper.labels(this.benchmark.sampledMetrics);
+ }
+
+ className(): string {
+ const className = this.benchmark.className;
+ const parts = className.split('.');
+ const lastIndex = parts.length - 1;
+ return parts[lastIndex];
+ }
+
+ testName(): string {
+ return this.benchmark.name;
+ }
+
+ private static labels(collection: MetricsCollection | null): string[] {
+ const labels: string[] = [];
+ if (collection) {
+ for (const key in collection) {
+ if (collection.hasOwnProperty(key)) {
+ labels.push(key);
+ }
+ }
+ }
+ return labels;
+ }
+
+}
diff --git a/development/plot-benchmarks/src/wrappers/session.ts b/development/plot-benchmarks/src/wrappers/session.ts
new file mode 100644
index 0000000..2581f92
--- /dev/null
+++ b/development/plot-benchmarks/src/wrappers/session.ts
@@ -0,0 +1,72 @@
+import { type FileMetadata } from "../types/files.js";
+import { BenchmarkWrapper } from "./benchmarks.js";
+
+export interface Indexed<T> {
+ // The source of the benchmark.
+ source: string;
+ // `source` index.
+ index: number;
+ // The underlying type.
+ value: T;
+}
+
+export type IndexedWrapper = Indexed<BenchmarkWrapper>;
+
+/**
+ * A Benchmarking plotting session.
+ */
+export class Session {
+ // className -> BenchmarkWrapper[]
+ public classGroups: Record<string, IndexedWrapper[]>;
+ // BenchmarkWrapper[]
+ public benchmarks: IndexedWrapper[];
+ public fileNames: Set<string>;
+
+ constructor(public files: FileMetadata[]) {
+ this.initialize();
+ }
+
+ private initialize() {
+ this.classGroups = {};
+ this.fileNames = new Set();
+ this.benchmarks = [];
+
+ for (let i = 0; i < this.files.length; i += 1) {
+ const fileIndex = i;
+ const fileMeta = this.files[fileIndex];
+ for (let j = 0; j < fileMeta.container.benchmarks.length; j += 1) {
+ const wrapper = new BenchmarkWrapper(fileMeta.container.benchmarks[j]);
+ const fileGroupKey = wrapper.className();
+ const classGroup = this.classGroups[fileGroupKey] || [];
+ const item = {
+ source: fileMeta.file.name,
+ index: fileIndex,
+ value: wrapper
+ };
+ classGroup.push(item);
+ // Update
+ this.classGroups[fileGroupKey] = classGroup;
+ this.fileNames.add(fileMeta.file.name);
+ this.benchmarks.push(item);
+ }
+ }
+ }
+
+ static datasetNames(wrappers: IndexedWrapper[]): Set<string> {
+ const names = new Set<string>();
+ for (let i = 0; i < wrappers.length; i += 1) {
+ const wrapper = wrappers[i];
+ names.add(wrapper.value.datasetName());
+ }
+ return names;
+ }
+
+ static sources(wrappers: IndexedWrapper[]): Set<string> {
+ const sources = new Set<string>();
+ for (let i = 0; i < wrappers.length; i += 1) {
+ sources.add(wrappers[i].source);
+ }
+ return sources;
+ }
+
+}