@scope-profiler/plotly 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,5 +19,42 @@ const figure = buildGanttFigure(payload);
19
19
  await renderFigure(Plotly, document.querySelector("#gantt"), figure);
20
20
  ```
21
21
 
22
- Builders accept both the current scope-profiler payloads and versioned payloads
23
- with `format: "scope-profiler-plot-data"` and `format_version: 1`.
22
+ ## One call for any payload
23
+
24
+ Every document written by `export plot-data` carries the `plot` kind that
25
+ produced it, so `buildFigure` can pick the builder for you:
26
+
27
+ ```js
28
+ import { buildFigure, renderFigure } from "@scope-profiler/plotly";
29
+
30
+ const payload = await fetch("/figures/rank_heatmap_data.json").then((response) => response.json());
31
+ await renderFigure(Plotly, document.querySelector("#chart"), buildFigure(payload));
32
+ ```
33
+
34
+ `buildFigure` rejects a document that is not `scope-profiler-plot-data` and one
35
+ whose `format_version` is newer than this package supports. JSON written before
36
+ scope-profiler stamped that envelope on every kind still works: the kind is then
37
+ inferred from the payload shape, and `{ plot: "gantt" }` settles it by hand.
38
+
39
+ ## Builders
40
+
41
+ `buildGanttFigure`, `buildFlameFigure`, `buildCallgraphFigure`,
42
+ `buildDensityFigure`, `buildDurationsFigure`, `buildDurationTimeseriesFigure`,
43
+ `buildHistogramFigure`, `buildRankHeatmapFigure`, `buildImbalanceFigure`,
44
+ `buildRegionSummaryFigure`, `buildLikwidFigure`, `buildSpeedupFigure`,
45
+ `buildWeakScalingFigure` and `buildScalingEfficiencyFigure` each take
46
+ `(payload, options)` and return a plain `{ data, layout }` figure.
47
+
48
+ Common options: `colors` (region or series name to color), `filterRegion(name,
49
+ row)` to drop rows, `layout` to merge into the generated layout, and `metric`
50
+ where a payload carries several.
51
+
52
+ ## More than one run in a payload
53
+
54
+ `export plot-data` accepts several profiles at once, and the payload then
55
+ carries a `file` column. Builders keep those runs apart: the gantt, density and
56
+ rank heatmap give each run its own lanes, and the histogram, imbalance and
57
+ duration time series give each run its own trace, labelled `run / region`. The
58
+ region keeps its colour across runs, so a run is told apart by marker symbol
59
+ (lines) or bar pattern (bars). A single-run payload is unchanged -- series are
60
+ named by region alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scope-profiler/plotly",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-neutral Plotly figure builders for scope-profiler plot-data JSON.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,5 +17,5 @@
17
17
  "files": ["src", "README.md", "LICENSE.txt"],
18
18
  "publishConfig": { "access": "public" },
19
19
  "scripts": { "test": "node --test" },
20
- "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph"]
20
+ "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph", "callgraph", "hpc", "mpi"]
21
21
  }
package/src/index.d.ts CHANGED
@@ -1,12 +1,24 @@
1
1
  export interface Figure { data: object[]; layout: object }
2
2
  export interface PlotlyLike { newPlot(element: Element | string, data: object[], layout: object, config?: object): unknown }
3
- export interface BuildOptions { colors?: Record<string, string>; filterRegion?: (region: string, row: object) => boolean; layout?: object; metric?: string; xField?: string; ideal?: boolean; rootLabel?: string }
3
+ export type PlotKind = "gantt" | "density" | "flame" | "flame_chart" | "flame_graph" | "callgraph" | "durations" | "timeseries" | "speedup" | "weak_scaling" | "scaling_efficiency" | "rank_heatmap" | "histogram" | "imbalance" | "likwid" | "region_statistics";
4
+ export interface BuildOptions { colors?: Record<string, string>; filterRegion?: (region: string, row: object) => boolean; layout?: object; metric?: string; xField?: string; ideal?: boolean; rootLabel?: string; plot?: PlotKind }
4
5
  export function buildGanttFigure(payload: object, options?: BuildOptions): Figure;
5
6
  export function buildFlameFigure(payload: object, options?: BuildOptions): Figure;
6
7
  export function buildDurationsFigure(payload: object, options?: BuildOptions): Figure;
7
- export function buildSpeedupFigure(payload: object, options?: BuildOptions): Figure;
8
+ export function buildSpeedupFigure(payload: object, options?: BuildOptions & { yField?: string }): Figure;
9
+ export function buildWeakScalingFigure(payload: object, options?: BuildOptions): Figure;
10
+ export function buildScalingEfficiencyFigure(payload: object, options?: BuildOptions): Figure;
8
11
  export function buildDurationTimeseriesFigure(payload: object, options?: BuildOptions): Figure;
9
12
  export function buildHistogramFigure(payload: object, options?: BuildOptions): Figure;
10
13
  export function buildRankHeatmapFigure(payload: object, options?: BuildOptions & { valueKey?: string; colorscale?: string }): Figure;
14
+ export function buildDensityFigure(payload: object, options?: BuildOptions & { valueKey?: "occupancy" | "occupied_seconds"; colorscale?: string }): Figure;
11
15
  export function buildImbalanceFigure(payload: object, options?: BuildOptions): Figure;
16
+ export function buildRegionSummaryFigure(payload: object, options?: BuildOptions & { topN?: number }): Figure;
17
+ export function buildCallgraphFigure(payload: object, options?: BuildOptions & { valueKey?: string }): Figure;
18
+ export function buildLikwidFigure(payload: object, options?: BuildOptions & { logScale?: boolean }): Figure;
19
+ export const PLOT_DATA_FORMAT: string;
20
+ export const SUPPORTED_FORMAT_VERSION: number;
21
+ export const PLOT_BUILDERS: Record<PlotKind, (payload: object, options?: BuildOptions) => Figure>;
22
+ export function inferPlotKind(payload: object): PlotKind | undefined;
23
+ export function buildFigure(payload: object, options?: BuildOptions): Figure;
12
24
  export function renderFigure(plotly: PlotlyLike, element: Element | string, figure: Figure, config?: object): unknown;
package/src/index.js CHANGED
@@ -44,44 +44,109 @@ function filtered(rows, options) {
44
44
  return typeof options?.filterRegion === "function" ? rows.filter((row) => options.filterRegion(row.region, row)) : rows;
45
45
  }
46
46
 
47
- /** Build a multi-run, multi-rank timeline. Regions are colored; file/rank are lanes. */
47
+ // One pass instead of a filter per series. A large trace has both many rows and
48
+ // many regions, so filtering the whole array once per series is quadratic: on
49
+ // 200k intervals over 400 regions that is the difference between ~690 ms and
50
+ // ~8 ms. Map preserves first-appearance order, which is the order the series
51
+ // are drawn and listed in.
52
+ function groupBy(rows, key) {
53
+ const groups = new Map();
54
+ for (const row of rows) {
55
+ const name = key(row);
56
+ const bucket = groups.get(name);
57
+ if (bucket) bucket.push(row);
58
+ else groups.set(name, [row]);
59
+ }
60
+ return groups;
61
+ }
62
+
63
+ // Several payloads carry rows from more than one run. Dropping that column
64
+ // merges runs into one series -- silently, and wrongly -- so every builder that
65
+ // can see two runs keys its series by file as well, and says so in the label.
66
+ function runAware(rows) {
67
+ const files = new Set(rows.map((row) => row.file ?? "run"));
68
+ const multi = files.size > 1;
69
+ return {
70
+ multi,
71
+ files: [...files],
72
+ label: (row) => (multi ? `${row.file ?? "run"} / ${row.region}` : row.region),
73
+ key: (row) => (multi ? `${row.file ?? "run"}\u0000${row.region}` : row.region),
74
+ };
75
+ }
76
+
77
+ // Region colour stays with the region across runs (and honours the payload's
78
+ // own colours), so a run is told apart by marker shape or bar pattern instead.
79
+ const FILE_SYMBOLS = ["circle", "square", "diamond", "triangle-up", "cross"];
80
+ const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
81
+
82
+ /** Build a multi-run, multi-rank timeline: a lane per region and rank.
83
+ *
84
+ * A lane per rank alone cannot show a nested profile: every region of a rank
85
+ * lands on one row, and the outermost region -- the session, typically -- is
86
+ * drawn over everything inside it. One lane per region and rank is also what
87
+ * `scope-profiler plot gantt` draws, so the two agree. Pass
88
+ * `{ laneBy: "rank" }` for the compact one-row-per-rank view, which suits a
89
+ * flat profile compared across many ranks.
90
+ */
48
91
  export function buildGanttFigure(payload, options = {}) {
49
92
  const intervals = filtered(values(payload, "intervals"), options);
50
- const regions = [...new Set(intervals.map((row) => row.region))];
51
- const colors = colorMap(regions, options.colors ?? payload.colors);
52
- const lanes = [...new Set(intervals.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`))];
53
- const data = regions.map((region) => {
54
- const rows = intervals.filter((row) => row.region === region);
93
+ const byRegion = groupBy(intervals, (row) => row.region);
94
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
95
+ const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
96
+ const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
97
+ // Matching `plot gantt`'s own lane label, extended by the run only when the
98
+ // payload holds more than one.
99
+ const regionLane = (row) => `${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
100
+ const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
101
+ const lanes = [...new Set(intervals.map(laneOf))];
102
+ const data = [...byRegion].map(([region, rows]) => {
55
103
  return { type: "bar", orientation: "h", name: region,
56
- y: rows.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`),
104
+ y: rows.map(laneOf),
57
105
  x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
58
106
  marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 } },
59
- customdata: rows.map((row) => [row.file, row.rank]),
107
+ customdata: rows.map((row) => [row.file ?? "run", row.rank ?? 0]),
60
108
  hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
61
109
  };
62
110
  });
63
- const layout = baseLayout({ barmode: "overlay", height: Math.max(280, 48 * lanes.length + 150), showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, autorange: "reversed", showgrid: false }), ...options.layout });
111
+ const byRank = options.laneBy === "rank";
112
+ const perLane = byRank ? 48 : 26;
113
+ // Region lanes read bottom-up, so the first region -- the enclosing one --
114
+ // sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
115
+ // keep rank 0 on top, like the rank heatmap.
116
+ const layout = baseLayout({ barmode: "overlay", height: Math.max(280, perLane * lanes.length + 150), showlegend: byRegion.size > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, ...(byRank ? { autorange: "reversed" } : {}), showgrid: false }), ...options.layout });
64
117
  return { data, layout: withEmptyState(layout, intervals.length > 0) };
65
118
  }
66
119
 
67
120
  /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
68
121
  export function buildFlameFigure(payload, options = {}) {
69
- const calls = values(payload, "calls");
122
+ const allCalls = values(payload, "calls");
123
+ const calls = filtered(allCalls, options);
70
124
  const regions = [...new Set(calls.map((call) => call.region))];
71
125
  const colors = colorMap(regions, options.colors ?? payload.colors);
72
126
  const root = "scope-profiler-root";
73
- const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
74
- const roots = calls.filter((call) => call.parent_call_id == null);
75
- const rootDuration = roots.reduce((sum, call) => sum + (call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds), 0);
76
127
  const callKey = (call) => `${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
77
- for (const call of calls) {
78
- ids.push(callKey(call)); labels.push(call.region);
79
- parents.push(call.parent_call_id == null ? root : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
128
+ const parentKey = (call) => (call.parent_call_id == null ? null : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
129
+ const duration = (call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds;
130
+ // A filter can remove a call whose children survive; re-parent each survivor
131
+ // onto its nearest surviving ancestor so the icicle stays a single tree
132
+ // instead of silently dropping the orphans.
133
+ const byKey = new Map(allCalls.map((call) => [callKey(call), call]));
134
+ const kept = new Set(calls.map(callKey));
135
+ const anchor = (call) => {
136
+ let key = parentKey(call);
137
+ while (key != null && !kept.has(key)) key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
138
+ return key ?? root;
139
+ };
140
+ const anchors = calls.map(anchor);
141
+ const rootDuration = calls.reduce((sum, call, index) => (anchors[index] === root ? sum + duration(call) : sum), 0);
142
+ const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
143
+ calls.forEach((call, index) => {
144
+ ids.push(callKey(call)); labels.push(call.region); parents.push(anchors[index]);
80
145
  markerColors.push(colors.get(call.region));
81
- hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${(call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds).toPrecision(6)} s`);
82
- }
146
+ hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${duration(call).toPrecision(6)} s`);
147
+ });
83
148
  const layout = baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
84
- return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map((call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors, line: { color: "rgba(255, 255, 255, 0.55)", width: 1 } }, hovertext, hoverinfo: "text" }], layout: withEmptyState(layout, calls.length > 0) };
149
+ return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map(duration)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors, line: { color: "rgba(255, 255, 255, 0.55)", width: 1 } }, hovertext, hoverinfo: "text" }], layout: withEmptyState(layout, calls.length > 0) };
85
150
  }
86
151
 
87
152
  export function buildDurationsFigure(payload, options = {}) {
@@ -90,55 +155,91 @@ export function buildDurationsFigure(payload, options = {}) {
90
155
  // A stacked-children export is already decomposed into segments. Preserve
91
156
  // that decomposition instead of letting duplicate region rows overwrite.
92
157
  const stacked = bars.some((bar) => bar.segment != null);
93
- const groups = [...new Set(bars.map((bar) => stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`))];
158
+ const groups = groupBy(bars, (bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`));
94
159
  const regions = [...new Set(bars.map((bar) => bar.region))];
95
- const colors = colorMap(groups, options.colors ?? payload.colors);
96
- const data = groups.map((group) => {
97
- const rows = bars.filter((bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`) === group);
160
+ const colors = colorMap(groups.keys(), options.colors ?? payload.colors);
161
+ const data = [...groups].map(([group, rows]) => {
98
162
  const byRegion = new Map(rows.map((bar) => [bar.region, bar.value_seconds]));
99
163
  return { type: "bar", name: group, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(group), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } }, hovertemplate: `<b>%{x}</b><br>${group}: %{y:.6g} s<extra></extra>` };
100
164
  });
101
- const layout = baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), showlegend: groups.length > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: `${metric} duration (s)` }), ...options.layout });
165
+ const layout = baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), showlegend: groups.size > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: `${metric} duration (s)` }), ...options.layout });
102
166
  return { data, layout: withEmptyState(layout, bars.length > 0) };
103
167
  }
104
168
 
169
+ // The three scaling exports differ only in the y column they carry and the
170
+ // shape of their ideal line, so one builder serves all of them.
171
+ const SCALING_KINDS = {
172
+ speedup: { yKey: "speedup", title: "Speedup", suffix: "×", idealName: "Ideal speedup", ideal: (value, baseline) => value / baseline },
173
+ weak_scaling: { yKey: "normalized_runtime", title: "Normalized runtime", suffix: "×", idealName: "Ideal weak scaling", ideal: () => 1 },
174
+ scaling_efficiency: { yKey: "efficiency", title: "Scaling efficiency", suffix: "", idealName: "Ideal efficiency", ideal: () => 1 },
175
+ };
176
+
177
+ function scalingKind(payload, options) {
178
+ const named = options.plot ?? payload?.plot;
179
+ if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
180
+ if (options.yField) {
181
+ const match = Object.values(SCALING_KINDS).find((kind) => kind.yKey === options.yField);
182
+ return match ?? { ...SCALING_KINDS.speedup, yKey: options.yField, title: options.yField };
183
+ }
184
+ const row = payload?.points?.[0];
185
+ return (row && Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ?? SCALING_KINDS.speedup;
186
+ }
187
+
188
+ /** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
105
189
  export function buildSpeedupFigure(payload, options = {}) {
190
+ const kind = scalingKind(payload, options);
106
191
  const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
107
192
  const points = filtered(values(payload, "points"), options);
108
- const regions = [...new Set(points.map((point) => point.region))];
109
- const colors = colorMap(regions, options.colors ?? payload.colors);
193
+ const byRegion = groupBy(points, (point) => point.region);
194
+ const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
110
195
  const xValues = [...new Set(points.map((point) => point[xField]))].sort((a, b) => typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b)));
196
+ const order = new Map(xValues.map((value, position) => [value, position]));
111
197
  const numeric = xValues.every((value) => typeof value === "number");
112
- const data = regions.map((region) => { const rows = points.filter((point) => point.region === region).sort((a, b) => xValues.indexOf(a[xField]) - xValues.indexOf(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row.speedup), line: { color: colors.get(region), width: 2.4 }, marker: { color: colors.get(region), size: 7 }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}×<extra></extra>` }; });
198
+ const data = [...byRegion].map(([region, unsorted]) => { const rows = [...unsorted].sort((a, b) => order.get(a[xField]) - order.get(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row[kind.yKey]), line: { color: colors.get(region), width: 2.4 }, marker: { color: colors.get(region), size: 7 }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}${kind.suffix}<extra></extra>` }; });
113
199
  const baseline = payload.options?.baseline ?? xValues[0];
114
- if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: "Ideal speedup", x: xValues, y: xValues.map((value) => value / baseline), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
115
- const layout = baseLayout({ height: 420, showlegend: data.length > 1, xaxis: axis({ title: payload.options?.x_label ?? xField, tickvals: xValues }), yaxis: axis({ title: "Speedup", rangemode: "tozero" }), ...options.layout });
200
+ if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: kind.idealName, x: xValues, y: xValues.map((value) => kind.ideal(value, baseline)), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
201
+ const layout = baseLayout({ height: 420, showlegend: data.length > 1, xaxis: axis({ title: payload.options?.x_label ?? xField, tickvals: xValues }), yaxis: axis({ title: kind.title, rangemode: "tozero" }), ...options.layout });
116
202
  return { data, layout: withEmptyState(layout, points.length > 0) };
117
203
  }
118
204
 
205
+ /** Build a weak-scaling curve (runtime normalized to the baseline scale). */
206
+ export function buildWeakScalingFigure(payload, options = {}) {
207
+ return buildSpeedupFigure(payload, { ...options, plot: "weak_scaling" });
208
+ }
209
+
210
+ /** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
211
+ export function buildScalingEfficiencyFigure(payload, options = {}) {
212
+ return buildSpeedupFigure(payload, { ...options, plot: "scaling_efficiency" });
213
+ }
214
+
119
215
  /** Build mean call duration over time, one trace per region. */
120
216
  export function buildDurationTimeseriesFigure(payload, options = {}) {
121
217
  const points = filtered(values(payload, "points"), options);
122
- const regions = [...new Set(points.map((point) => point.region))];
123
- const colors = colorMap(regions, options.colors ?? payload.colors);
124
- const data = regions.map((region) => {
125
- const rows = points.filter((point) => point.region === region).sort((a, b) => a.time_seconds - b.time_seconds);
126
- return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.time_seconds), y: rows.map((row) => row.mean_duration_seconds), line: { color: colors.get(region), width: 2.2 }, marker: { color: colors.get(region), size: 5 }, customdata: rows.map((row) => [row.min_duration_seconds, row.max_duration_seconds, row.call_index]), hovertemplate: `<b>${region}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>` };
218
+ const runs = runAware(points);
219
+ const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
220
+ const series = groupBy(points, runs.key);
221
+ const data = [...series].map(([, unsorted]) => {
222
+ const rows = [...unsorted].sort((a, b) => a.time_seconds - b.time_seconds);
223
+ const region = rows[0].region, name = runs.label(rows[0]);
224
+ return { type: "scatter", mode: "lines+markers", name, x: rows.map((row) => row.time_seconds), y: rows.map((row) => row.mean_duration_seconds), line: { color: colors.get(region), width: 2.2 }, marker: { color: colors.get(region), size: 5, symbol: FILE_SYMBOLS[runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length] }, customdata: rows.map((row) => [row.min_duration_seconds, row.max_duration_seconds, row.call_index]), hovertemplate: `<b>${name}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>` };
127
225
  });
128
- const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
226
+ const layout = baseLayout({ height: 420, showlegend: series.size > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
129
227
  return { data, layout: withEmptyState(layout, points.length > 0) };
130
228
  }
131
229
 
132
230
  /** Build duration distributions from histogram bin records. */
133
231
  export function buildHistogramFigure(payload, options = {}) {
134
232
  const bins = filtered(values(payload, "bins"), options);
135
- const regions = [...new Set(bins.map((bin) => bin.region))];
136
- const colors = colorMap(regions, options.colors ?? payload.colors);
137
- const data = regions.map((region) => {
138
- const rows = bins.filter((bin) => bin.region === region).sort((a, b) => a.bin_center_seconds - b.bin_center_seconds);
139
- return { type: "bar", name: region, x: rows.map((bin) => bin.bin_center_seconds), y: rows.map((bin) => bin.count), width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds), marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 } }, hovertemplate: `<b>${region}</b><br>%{x:.6g} s: %{y} calls<extra></extra>` };
233
+ const runs = runAware(bins);
234
+ const colors = colorMap(bins.map((bin) => bin.region), options.colors ?? payload.colors);
235
+ const series = groupBy(bins, runs.key);
236
+ const data = [...series].map(([, unsorted]) => {
237
+ const rows = [...unsorted].sort((a, b) => a.bin_center_seconds - b.bin_center_seconds);
238
+ const region = rows[0].region, name = runs.label(rows[0]);
239
+ const pattern = FILE_PATTERNS[runs.files.indexOf(rows[0].file ?? "run") % FILE_PATTERNS.length];
240
+ return { type: "bar", name, x: rows.map((bin) => bin.bin_center_seconds), y: rows.map((bin) => bin.count), width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds), marker: { color: colors.get(region), line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 }, ...(runs.multi ? { pattern: { shape: pattern, solidity: 0.35 } } : {}) }, hovertemplate: `<b>${name}</b><br>%{x:.6g} s: %{y} calls<extra></extra>` };
140
241
  });
141
- const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: regions.length > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
242
+ const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: series.size > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
142
243
  return { data, layout: withEmptyState(layout, bins.length > 0) };
143
244
  }
144
245
 
@@ -146,32 +247,220 @@ export function buildHistogramFigure(payload, options = {}) {
146
247
  export function buildRankHeatmapFigure(payload, options = {}) {
147
248
  const points = filtered(values(payload, "points"), options);
148
249
  const regions = [...new Set(points.map((point) => point.region))];
149
- const ranks = [...new Set(points.map((point) => point.rank))].sort((a, b) => a - b);
250
+ const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
251
+ // A lane per run and rank. Keying cells by rank alone silently let a second
252
+ // run overwrite the first, showing one run's numbers under both labels.
253
+ const laneOf = (point) => (multi ? `${point.file ?? "run"} / rank ${point.rank ?? 0}` : String(point.rank ?? 0));
254
+ const lanes = [...new Set(points.map(laneOf))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
150
255
  const inferredValueKey = points[0] ? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds")) : undefined;
151
256
  const valueKey = options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
152
- const byCell = new Map(points.map((point) => [`${point.rank}:${point.region}`, point[valueKey]]));
153
- const data = [{ type: "heatmap", x: regions, y: ranks.map(String), z: ranks.map((rank) => regions.map((region) => byCell.get(`${rank}:${region}`) ?? null)), colorscale: options.colorscale ?? "Viridis", colorbar: { title: "Seconds" }, hovertemplate: "rank %{y}<br>%{x}: %{z:.6g} s<extra></extra>" }];
154
- const layout = baseLayout({ height: Math.max(320, 44 * ranks.length + 150), xaxis: axis({ title: "Region" }), yaxis: axis({ title: "Rank", autorange: "reversed", showgrid: false }), ...options.layout });
257
+ const byCell = new Map(points.map((point) => [`${laneOf(point)}\u0000${point.region}`, point[valueKey]]));
258
+ const data = [{ type: "heatmap", x: regions, y: lanes, z: lanes.map((lane) => regions.map((region) => byCell.get(`${lane}\u0000${region}`) ?? null)), colorscale: options.colorscale ?? "Viridis", colorbar: { title: "Seconds" }, hovertemplate: `${multi ? "%{y}" : "rank %{y}"}<br>%{x}: %{z:.6g} s<extra></extra>` }];
259
+ const layout = baseLayout({ height: Math.max(320, 44 * lanes.length + 150), xaxis: axis({ title: "Region" }), yaxis: axis({ title: multi ? "Run / rank" : "Rank", autorange: "reversed", showgrid: false }), ...options.layout });
155
260
  return { data, layout: withEmptyState(layout, points.length > 0) };
156
261
  }
157
262
 
158
263
  /** Build per-rank duration lines, with a dashed rank mean for each region. */
159
264
  export function buildImbalanceFigure(payload, options = {}) {
160
265
  const points = filtered(values(payload, "points"), options);
161
- const regions = [...new Set(points.map((point) => point.region))];
162
- const colors = colorMap(regions, options.colors ?? payload.colors);
163
- const data = regions.flatMap((region) => {
164
- const rows = points.filter((point) => point.region === region).sort((a, b) => a.rank - b.rank);
266
+ const runs = runAware(points);
267
+ const colors = colorMap(points.map((point) => point.region), options.colors ?? payload.colors);
268
+ // The mean is computed per run, so a run gets its own line and its own mean;
269
+ // pooling them drew one zig-zagging series that revisited every rank.
270
+ const series = groupBy(points, runs.key);
271
+ const data = [...series].flatMap(([, unsorted]) => {
272
+ const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
273
+ const region = rows[0].region, name = runs.label(rows[0]);
165
274
  const color = colors.get(region);
275
+ const symbol = FILE_SYMBOLS[runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length];
166
276
  return [
167
- { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row.rank), y: rows.map((row) => row.value_seconds), line: { color, width: 2.2 }, marker: { color, size: 7 }, hovertemplate: `<b>${region}</b><br>rank %{x}: %{y:.6g} s<extra></extra>` },
168
- { type: "scatter", mode: "lines", name: `${region} mean`, x: rows.map((row) => row.rank), y: rows.map((row) => row.mean_over_ranks_seconds), line: { color, dash: "dot", width: 1.3 }, hoverinfo: "skip", showlegend: false },
277
+ { type: "scatter", mode: "lines+markers", name, x: rows.map((row) => row.rank), y: rows.map((row) => row.value_seconds), line: { color, width: 2.2 }, marker: { color, size: 7, symbol }, hovertemplate: `<b>${name}</b><br>rank %{x}: %{y:.6g} s<extra></extra>` },
278
+ { type: "scatter", mode: "lines", name: `${name} mean`, x: rows.map((row) => row.rank), y: rows.map((row) => row.mean_over_ranks_seconds), line: { color, dash: "dot", width: 1.3 }, hoverinfo: "skip", showlegend: false },
169
279
  ];
170
280
  });
171
- const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Rank", dtick: 1 }), yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }), ...options.layout });
281
+ const layout = baseLayout({ height: 420, showlegend: series.size > 1, xaxis: axis({ title: "Rank", dtick: 1 }), yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }), ...options.layout });
282
+ return { data, layout: withEmptyState(layout, points.length > 0) };
283
+ }
284
+
285
+ /** Build a timeline-occupancy heatmap from binned density records. */
286
+ export function buildDensityFigure(payload, options = {}) {
287
+ const points = filtered(values(payload, "points"), options);
288
+ const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
289
+ const lanes = [...new Set(points.map(lane))];
290
+ const starts = [...new Set(points.map((point) => point.bin_start_seconds))].sort((a, b) => a - b);
291
+ const width = points.length ? points[0].bin_end_seconds - points[0].bin_start_seconds : 0;
292
+ // Occupancy is the share of the bin the region was inside, which compares
293
+ // across runs of different length; raw seconds stay available via valueKey.
294
+ const asFraction = (options.valueKey ?? "occupancy") === "occupancy";
295
+ const byCell = new Map(points.map((point) => [`${lane(point)}:${point.bin_start_seconds}`, point]));
296
+ const cell = (laneName, start, pick) => { const point = byCell.get(`${laneName}:${start}`); return point ? pick(point) : null; };
297
+ const span = (point) => point.bin_end_seconds - point.bin_start_seconds;
298
+ const data = [{
299
+ type: "heatmap", x: starts.map((start) => start + width / 2), y: lanes,
300
+ z: lanes.map((laneName) => starts.map((start) => cell(laneName, start, (point) => (asFraction ? (span(point) > 0 ? point.occupied_seconds / span(point) : null) : point.occupied_seconds)))),
301
+ customdata: lanes.map((laneName) => starts.map((start) => cell(laneName, start, (point) => point.occupied_seconds))),
302
+ colorscale: options.colorscale ?? "Viridis", ...(asFraction ? { zmin: 0, zmax: 1 } : {}),
303
+ colorbar: { title: asFraction ? "Occupancy" : "Seconds" },
304
+ hovertemplate: `%{y}<br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata:.4g} s<extra></extra>`,
305
+ }];
306
+ const layout = baseLayout({ height: Math.max(320, 34 * lanes.length + 150), xaxis: axis({ title: "Time (s)" }), yaxis: axis({ categoryorder: "array", categoryarray: lanes, autorange: "reversed", showgrid: false }), ...options.layout });
172
307
  return { data, layout: withEmptyState(layout, points.length > 0) };
173
308
  }
174
309
 
310
+ const SUMMARY_LABELS = { count: "Calls", average_duration_seconds: "Average duration (s)", min_duration_seconds: "Minimum duration (s)", max_duration_seconds: "Maximum duration (s)", first_duration_seconds: "First call duration (s)", last_duration_seconds: "Last call duration (s)", std_duration_seconds: "Duration std. dev. (s)", total_duration_seconds: "Total duration (s)" };
311
+
312
+ /** Build a ranked region bar chart from a region_statistics document. */
313
+ export function buildRegionSummaryFigure(payload, options = {}) {
314
+ const files = values(payload, "files");
315
+ const metric = options.metric ?? "total_duration_seconds";
316
+ const limit = options.topN ?? 20;
317
+ const keep = typeof options.filterRegion === "function" ? options.filterRegion : () => true;
318
+ const totals = new Map();
319
+ for (const file of files) {
320
+ for (const [region, stats] of Object.entries(file.region_statistics ?? {})) {
321
+ if (!keep(region, stats)) continue;
322
+ totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
323
+ }
324
+ }
325
+ // Rank by the pooled metric so the slowest regions lead, then keep the head
326
+ // of the list: a long run has more regions than a bar chart can carry.
327
+ const regions = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([region]) => region);
328
+ const labels = files.map((file) => file.label ?? "run");
329
+ const colors = colorMap(labels, options.colors ?? payload.colors);
330
+ const unit = metric === "count" ? "" : " s";
331
+ const data = files.map((file, index) => {
332
+ const stats = file.region_statistics ?? {};
333
+ return { type: "bar", orientation: "h", name: labels[index], y: regions, x: regions.map((region) => stats[region]?.[metric] ?? null),
334
+ marker: { color: colors.get(labels[index]), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } },
335
+ customdata: regions.map((region) => stats[region]?.count ?? null),
336
+ hovertemplate: `<b>%{y}</b><br>${labels[index]}: %{x:.6g}${unit}<br>calls: %{customdata}<extra></extra>` };
337
+ });
338
+ const layout = baseLayout({ barmode: "group", height: Math.max(320, 26 * regions.length + 160), showlegend: files.length > 1, xaxis: axis({ title: SUMMARY_LABELS[metric] ?? metric }), yaxis: axis({ categoryorder: "array", categoryarray: [...regions].reverse(), showgrid: false }), ...options.layout });
339
+ return { data, layout: withEmptyState(layout, regions.length > 0) };
340
+ }
341
+
342
+ /** Build a Sankey call graph from either callgraph export shape.
343
+ *
344
+ * The compact export collapses repeated invocations into one node per region,
345
+ * which can turn recursion into a cycle; a Sankey cannot draw one, so links
346
+ * that do not increase call depth are dropped. Use the flame chart to see
347
+ * recursion in full.
348
+ */
349
+ export function buildCallgraphFigure(payload, options = {}) {
350
+ const compact = Array.isArray(payload?.regions);
351
+ if (!compact && !Array.isArray(payload?.calls)) throw new TypeError("Expected a scope-profiler plot-data payload with a regions or calls array.");
352
+ const keep = typeof options.filterRegion === "function" ? options.filterRegion : () => true;
353
+ const weightKey = options.valueKey ?? "total_duration";
354
+ let nodes, links, unit;
355
+ if (compact) {
356
+ const regions = payload.regions.filter((region) => keep(region.name, region));
357
+ const depths = new Map(regions.map((region) => [region.name, region.depth]));
358
+ const weights = new Map(regions.map((region) => [region.name, region[weightKey] ?? 0]));
359
+ nodes = regions.map((region) => region.name);
360
+ links = (payload.edges ?? []).filter(({ parent, child }) => depths.has(parent) && depths.has(child) && depths.get(child) > depths.get(parent))
361
+ .map(({ parent, child }) => ({ source: parent, target: child, value: weights.get(child) || 1 }));
362
+ unit = weightKey.endsWith("duration") ? " s" : "";
363
+ } else {
364
+ const calls = payload.calls.filter((call) => keep(call.name, call));
365
+ const byId = new Map(calls.map((call) => [call.call_id, call]));
366
+ const counts = new Map();
367
+ for (const call of calls) {
368
+ const parent = byId.get(call.parent_id);
369
+ if (!parent || call.depth <= parent.depth) continue;
370
+ const key = `${parent.name}\u0000${call.name}`;
371
+ counts.set(key, (counts.get(key) ?? 0) + 1);
372
+ }
373
+ nodes = [...new Set(calls.map((call) => call.name))];
374
+ links = [...counts.entries()].map(([key, value]) => { const [source, target] = key.split("\u0000"); return { source, target, value }; });
375
+ unit = " calls";
376
+ }
377
+ const index = new Map(nodes.map((name, position) => [name, position]));
378
+ const colors = colorMap(nodes, options.colors ?? payload.colors);
379
+ const data = [{
380
+ type: "sankey", orientation: "h",
381
+ node: { label: nodes, color: nodes.map((name) => colors.get(name)), pad: 14, thickness: 16, line: { color: "rgba(0, 0, 0, 0.25)", width: 0.5 } },
382
+ link: { source: links.map((link) => index.get(link.source)), target: links.map((link) => index.get(link.target)), value: links.map((link) => link.value),
383
+ hovertemplate: `%{source.label} \u2192 %{target.label}<br>%{value:.6g}${unit}<extra></extra>` },
384
+ }];
385
+ const layout = baseLayout({ height: Math.max(320, 26 * nodes.length + 160), margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout });
386
+ return { data, layout: withEmptyState(layout, links.length > 0) };
387
+ }
388
+
389
+ /** Build a grouped bar chart of one LIKWID hardware-counter metric. */
390
+ export function buildLikwidFigure(payload, options = {}) {
391
+ const bars = filtered(values(payload, "bars"), options);
392
+ const series = groupBy(bars, (bar) => bar.series);
393
+ const regions = [...new Set(bars.map((bar) => bar.region))];
394
+ const colors = colorMap(series.keys(), options.colors ?? payload.colors);
395
+ const metric = options.metric ?? payload.metric ?? "value";
396
+ const data = [...series].map(([name, rows]) => {
397
+ const byRegion = new Map(rows.map((bar) => [bar.region, bar.value]));
398
+ return { type: "bar", name, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(name), line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 } }, hovertemplate: `<b>%{x}</b><br>${name}: %{y:.6g}<extra></extra>` };
399
+ });
400
+ const layout = baseLayout({ barmode: "group", height: Math.max(360, 34 * regions.length + 180), showlegend: series.size > 1, xaxis: axis({ tickangle: -35 }), yaxis: axis({ title: metric, ...(options.logScale ? { type: "log" } : {}) }), ...options.layout });
401
+ return { data, layout: withEmptyState(layout, bars.length > 0) };
402
+ }
403
+
404
+ export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
405
+ export const SUPPORTED_FORMAT_VERSION = 1;
406
+
407
+ /** Builder for each `plot` kind written by `export plot-data --format json`. */
408
+ export const PLOT_BUILDERS = {
409
+ gantt: buildGanttFigure,
410
+ density: buildDensityFigure,
411
+ flame: buildFlameFigure,
412
+ flame_chart: buildFlameFigure,
413
+ flame_graph: buildFlameFigure,
414
+ callgraph: buildCallgraphFigure,
415
+ durations: buildDurationsFigure,
416
+ timeseries: buildDurationTimeseriesFigure,
417
+ speedup: buildSpeedupFigure,
418
+ weak_scaling: buildSpeedupFigure,
419
+ scaling_efficiency: buildSpeedupFigure,
420
+ rank_heatmap: buildRankHeatmapFigure,
421
+ histogram: buildHistogramFigure,
422
+ imbalance: buildImbalanceFigure,
423
+ likwid: buildLikwidFigure,
424
+ region_statistics: buildRegionSummaryFigure,
425
+ };
426
+
427
+ /** Guess the plot kind of a payload written before the envelope existed. */
428
+ export function inferPlotKind(payload) {
429
+ if (!payload || typeof payload !== "object") return undefined;
430
+ if (Array.isArray(payload.intervals)) return "gantt";
431
+ if (Array.isArray(payload.bins)) return "histogram";
432
+ if (Array.isArray(payload.files) && payload.files[0]?.region_statistics) return "region_statistics";
433
+ if (Array.isArray(payload.regions) && Array.isArray(payload.edges)) return "callgraph";
434
+ if (Array.isArray(payload.bars)) return payload.bars[0]?.series != null ? "likwid" : "durations";
435
+ if (Array.isArray(payload.calls)) return payload.calls[0]?.parent_id !== undefined ? "callgraph" : "flame";
436
+ const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
437
+ if (!point) return undefined;
438
+ if (point.bin_start_seconds != null) return "density";
439
+ if (point.mean_duration_seconds != null) return "timeseries";
440
+ if (point.mean_over_ranks_seconds != null) return "imbalance";
441
+ if (point.speedup != null) return "speedup";
442
+ if (point.normalized_runtime != null) return "weak_scaling";
443
+ if (point.efficiency != null) return "scaling_efficiency";
444
+ if (point.rank != null) return "rank_heatmap";
445
+ return undefined;
446
+ }
447
+
448
+ /** Build the right figure for any plot-data document, without naming a builder.
449
+ *
450
+ * Dispatches on the document's own `plot` field, falling back to the payload
451
+ * shape for files written before scope-profiler stamped the envelope on every
452
+ * kind.
453
+ */
454
+ export function buildFigure(payload, options = {}) {
455
+ if (payload?.format != null && payload.format !== PLOT_DATA_FORMAT) throw new TypeError(`Expected a ${PLOT_DATA_FORMAT} document, got ${JSON.stringify(payload.format)}.`);
456
+ const version = payload?.format_version;
457
+ if (typeof version === "number" && version > SUPPORTED_FORMAT_VERSION) throw new TypeError(`Plot-data format version ${version} is newer than this package supports (${SUPPORTED_FORMAT_VERSION}); upgrade @scope-profiler/plotly.`);
458
+ const kind = options.plot ?? payload?.plot ?? inferPlotKind(payload);
459
+ const builder = kind && PLOT_BUILDERS[kind];
460
+ if (!builder) throw new TypeError(kind ? `No figure builder for plot kind ${JSON.stringify(kind)}.` : "Could not determine the plot kind; pass options.plot.");
461
+ return builder(payload, { plot: kind, ...options });
462
+ }
463
+
175
464
  /** Render a figure with any Plotly-compatible bundle. */
176
465
  export function renderFigure(plotly, element, figure, config = {}) {
177
466
  if (!plotly || typeof plotly.newPlot !== "function") throw new TypeError("renderFigure requires a Plotly-compatible object with newPlot().");