@scope-profiler/plotly 0.2.0 → 0.4.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/CHANGELOG.md +61 -0
- package/README.md +166 -8
- package/package.json +49 -6
- package/src/dashboard.d.ts +51 -0
- package/src/dashboard.js +177 -0
- package/src/index.d.ts +382 -23
- package/src/index.js +1664 -147
package/src/index.js
CHANGED
|
@@ -1,47 +1,449 @@
|
|
|
1
1
|
/** Framework-neutral Plotly specifications for scope-profiler plot-data. */
|
|
2
|
+
/** Runtime checks shared by direct builders and the envelope validator. */
|
|
3
|
+
function validateRecords(kind, payload, options = {}) {
|
|
4
|
+
const fields = {
|
|
5
|
+
gantt: ["intervals", "start_seconds", "end_seconds"],
|
|
6
|
+
flame: ["calls", "start_seconds"],
|
|
7
|
+
durations: ["bars", "value_seconds"],
|
|
8
|
+
histogram: [
|
|
9
|
+
"bins",
|
|
10
|
+
"bin_low_seconds",
|
|
11
|
+
"bin_high_seconds",
|
|
12
|
+
"bin_center_seconds",
|
|
13
|
+
"count",
|
|
14
|
+
],
|
|
15
|
+
timeseries: ["points", "time_seconds", "mean_duration_seconds"],
|
|
16
|
+
imbalance: ["points", "rank", "value_seconds", "mean_over_ranks_seconds"],
|
|
17
|
+
density: [
|
|
18
|
+
"points",
|
|
19
|
+
"bin_start_seconds",
|
|
20
|
+
"bin_end_seconds",
|
|
21
|
+
"occupied_seconds",
|
|
22
|
+
],
|
|
23
|
+
rank_heatmap: ["points"],
|
|
24
|
+
scaling: ["points"],
|
|
25
|
+
likwid: ["bars", "value"],
|
|
26
|
+
roofline: [
|
|
27
|
+
"points",
|
|
28
|
+
"arithmetic_intensity_flops_per_byte",
|
|
29
|
+
"performance_gflops",
|
|
30
|
+
],
|
|
31
|
+
callgraph: [Array.isArray(payload.regions) ? "regions" : "calls", "depth"],
|
|
32
|
+
region_statistics: ["files"],
|
|
33
|
+
};
|
|
34
|
+
const [key, ...required] = fields[kind] ?? [];
|
|
35
|
+
if (!key) return;
|
|
36
|
+
const rows = payload[key];
|
|
37
|
+
if (!Array.isArray(rows)) throw new TypeError(`Expected a ${key} array.`);
|
|
38
|
+
const fail = (index, field, reason) => {
|
|
39
|
+
throw new TypeError(`${key}[${index}].${field}: ${reason}`);
|
|
40
|
+
};
|
|
41
|
+
rows.forEach((row, index) => {
|
|
42
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
43
|
+
fail(index, "record", "must be an object");
|
|
44
|
+
if (kind !== "region_statistics") {
|
|
45
|
+
const name = kind === "callgraph" ? "name" : "region";
|
|
46
|
+
if (typeof row[name] !== "string") fail(index, name, "must be a string");
|
|
47
|
+
}
|
|
48
|
+
if ((kind === "flame" || kind === "callgraph") && key === "calls") {
|
|
49
|
+
for (const field of [
|
|
50
|
+
"call_id",
|
|
51
|
+
kind === "flame" ? "parent_call_id" : "parent_id",
|
|
52
|
+
]) {
|
|
53
|
+
if (field !== "call_id" && row[field] == null) continue;
|
|
54
|
+
if (typeof row[field] !== "string" && !Number.isFinite(row[field]))
|
|
55
|
+
fail(index, field, "must be a string or finite number");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const field of required) {
|
|
59
|
+
if (!Number.isFinite(row[field]))
|
|
60
|
+
fail(index, field, "must be a finite number");
|
|
61
|
+
}
|
|
62
|
+
for (const [field, value] of Object.entries(row)) {
|
|
63
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
64
|
+
fail(index, field, "must be finite");
|
|
65
|
+
if (
|
|
66
|
+
value != null &&
|
|
67
|
+
(field.endsWith("_seconds") || field === "rank") &&
|
|
68
|
+
!Number.isFinite(value)
|
|
69
|
+
)
|
|
70
|
+
fail(index, field, "must be a finite number");
|
|
71
|
+
}
|
|
72
|
+
for (const [start, end] of [
|
|
73
|
+
["start_seconds", "end_seconds"],
|
|
74
|
+
["bin_low_seconds", "bin_high_seconds"],
|
|
75
|
+
["bin_start_seconds", "bin_end_seconds"],
|
|
76
|
+
]) {
|
|
77
|
+
if (row[start] != null && row[end] != null && row[end] < row[start])
|
|
78
|
+
fail(index, end, `must be >= ${start}`);
|
|
79
|
+
}
|
|
80
|
+
if (kind === "flame") {
|
|
81
|
+
if (
|
|
82
|
+
!Number.isFinite(row.inclusive_duration_seconds) &&
|
|
83
|
+
!Number.isFinite(row.end_seconds)
|
|
84
|
+
)
|
|
85
|
+
fail(index, "end_seconds", "or inclusive_duration_seconds is required");
|
|
86
|
+
if (row.inclusive_duration_seconds < 0)
|
|
87
|
+
fail(index, "inclusive_duration_seconds", "must be nonnegative");
|
|
88
|
+
}
|
|
89
|
+
if (kind === "timeseries") {
|
|
90
|
+
if (
|
|
91
|
+
row.min_duration_seconds > row.mean_duration_seconds ||
|
|
92
|
+
row.max_duration_seconds < row.mean_duration_seconds
|
|
93
|
+
)
|
|
94
|
+
fail(index, "bounds", "must bracket mean_duration_seconds");
|
|
95
|
+
}
|
|
96
|
+
if (kind === "rank_heatmap") {
|
|
97
|
+
const field =
|
|
98
|
+
options.valueKey ??
|
|
99
|
+
Object.keys(row).find((name) => name.endsWith("_duration_seconds")) ??
|
|
100
|
+
"total_duration_seconds";
|
|
101
|
+
if (!Number.isFinite(row[field]))
|
|
102
|
+
fail(index, field, "must be a finite number");
|
|
103
|
+
}
|
|
104
|
+
if (kind === "scaling") {
|
|
105
|
+
const x = options.xField ?? payload.options?.x_field ?? "num_ranks";
|
|
106
|
+
const y = options.yField;
|
|
107
|
+
if (typeof row[x] !== "string" && !Number.isFinite(row[x]))
|
|
108
|
+
fail(index, x, "must be a string or finite number");
|
|
109
|
+
if (y && !Number.isFinite(row[y]))
|
|
110
|
+
fail(index, y, "must be a finite number");
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
kind === "roofline" &&
|
|
114
|
+
(row.arithmetic_intensity_flops_per_byte <= 0 ||
|
|
115
|
+
row.performance_gflops <= 0)
|
|
116
|
+
)
|
|
117
|
+
fail(index, "rates", "must be positive on logarithmic axes");
|
|
118
|
+
if (kind === "region_statistics") {
|
|
119
|
+
if (
|
|
120
|
+
!row.region_statistics ||
|
|
121
|
+
typeof row.region_statistics !== "object" ||
|
|
122
|
+
Array.isArray(row.region_statistics)
|
|
123
|
+
)
|
|
124
|
+
fail(index, "region_statistics", "must be an object");
|
|
125
|
+
for (const [region, stats] of Object.entries(row.region_statistics)) {
|
|
126
|
+
if (!stats || typeof stats !== "object")
|
|
127
|
+
fail(index, region, "statistics must be an object");
|
|
128
|
+
for (const [field, value] of Object.entries(stats)) {
|
|
129
|
+
if (
|
|
130
|
+
(field === "count" || field.endsWith("_seconds")) &&
|
|
131
|
+
value != null &&
|
|
132
|
+
!Number.isFinite(value)
|
|
133
|
+
)
|
|
134
|
+
fail(
|
|
135
|
+
index,
|
|
136
|
+
`${region}.${field}`,
|
|
137
|
+
"must be a finite number or null",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (kind === "callgraph" && payload.edges != null) {
|
|
144
|
+
if (!Array.isArray(payload.edges))
|
|
145
|
+
throw new TypeError("edges must be an array.");
|
|
146
|
+
payload.edges.forEach((edge, index) => {
|
|
147
|
+
if (
|
|
148
|
+
!edge ||
|
|
149
|
+
typeof edge.parent !== "string" ||
|
|
150
|
+
typeof edge.child !== "string"
|
|
151
|
+
)
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
`edges[${index}]: parent and child must be strings.`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
2
158
|
|
|
3
|
-
|
|
4
|
-
|
|
159
|
+
/** Refuse last-write-wins data loss. Keys are serialized tuples, not labels. */
|
|
160
|
+
function uniqueMap(entries, context) {
|
|
161
|
+
const result = new Map();
|
|
162
|
+
for (const [key, value] of entries) {
|
|
163
|
+
if (result.has(key))
|
|
164
|
+
throw new TypeError(`${context}: duplicate cell ${JSON.stringify(key)}`);
|
|
165
|
+
result.set(key, value);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Iterative traversal also handles deeply nested profiles without stack overflow. */
|
|
171
|
+
function validateParents(calls, keyOf, parentOf) {
|
|
172
|
+
const byKey = uniqueMap(
|
|
173
|
+
calls.map((call) => [keyOf(call), call]),
|
|
174
|
+
"calls (duplicate call ID)",
|
|
175
|
+
);
|
|
176
|
+
const done = new Set();
|
|
177
|
+
for (const start of byKey.keys()) {
|
|
178
|
+
const path = new Set();
|
|
179
|
+
let key = start;
|
|
180
|
+
while (key != null && byKey.has(key) && !done.has(key)) {
|
|
181
|
+
if (path.has(key)) throw new TypeError(`calls: ancestor cycle at ${key}`);
|
|
182
|
+
path.add(key);
|
|
183
|
+
key = parentOf(byKey.get(key));
|
|
184
|
+
}
|
|
185
|
+
for (const visited of path) done.add(visited);
|
|
186
|
+
}
|
|
187
|
+
return byKey;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const DEFAULT_COLORS = [
|
|
191
|
+
"#2a78d6",
|
|
192
|
+
"#eb6834",
|
|
193
|
+
"#1baf7a",
|
|
194
|
+
"#eda100",
|
|
195
|
+
"#e87ba4",
|
|
196
|
+
"#008300",
|
|
197
|
+
"#4a3aa7",
|
|
198
|
+
"#e34948",
|
|
199
|
+
];
|
|
5
200
|
|
|
6
201
|
function colorMap(names, supplied = {}) {
|
|
7
202
|
const map = new Map();
|
|
8
|
-
let index = 0;
|
|
9
203
|
for (const name of names) {
|
|
10
204
|
if (map.has(name)) continue;
|
|
11
|
-
map.set(
|
|
205
|
+
map.set(
|
|
206
|
+
name,
|
|
207
|
+
(Object.hasOwn(supplied, name) ? supplied[name] : undefined) ??
|
|
208
|
+
stableColor(name),
|
|
209
|
+
);
|
|
12
210
|
}
|
|
13
211
|
return map;
|
|
14
212
|
}
|
|
15
213
|
|
|
16
|
-
function
|
|
17
|
-
|
|
214
|
+
function stableColor(name) {
|
|
215
|
+
let hash = 2166136261;
|
|
216
|
+
for (const char of String(name))
|
|
217
|
+
hash = Math.imul(hash ^ char.codePointAt(0), 16777619);
|
|
218
|
+
return DEFAULT_COLORS[(hash >>> 0) % DEFAULT_COLORS.length];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function interactionData(rows, metrics = () => []) {
|
|
222
|
+
return rows.map((row) => ({
|
|
223
|
+
...metrics(row),
|
|
224
|
+
identity: {
|
|
225
|
+
region: row.region ?? row.name ?? null,
|
|
226
|
+
file: row.file ?? "run",
|
|
227
|
+
rank: row.rank ?? null,
|
|
228
|
+
call_id: row.call_id ?? null,
|
|
229
|
+
},
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Read an identity from a Plotly click/hover/selection point. */
|
|
234
|
+
export function getPointIdentity(point) {
|
|
235
|
+
return point?.customdata?.identity ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Share an explicit palette across independently built figures. */
|
|
239
|
+
export function createColorRegistry(names = [], supplied = {}) {
|
|
240
|
+
return Object.freeze(Object.fromEntries(colorMap(names, supplied)));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mergeLayout(base, overrides = {}) {
|
|
244
|
+
const result = { ...base };
|
|
245
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
246
|
+
if (["__proto__", "constructor", "prototype"].includes(key)) continue;
|
|
247
|
+
result[key] =
|
|
248
|
+
value && Object.getPrototypeOf(value) === Object.prototype
|
|
249
|
+
? mergeLayout(
|
|
250
|
+
result[key] &&
|
|
251
|
+
typeof result[key] === "object" &&
|
|
252
|
+
!Array.isArray(result[key])
|
|
253
|
+
? result[key]
|
|
254
|
+
: {},
|
|
255
|
+
value,
|
|
256
|
+
)
|
|
257
|
+
: cloneLayoutValue(value);
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cloneLayoutValue(value) {
|
|
263
|
+
if (Array.isArray(value)) return value.map(cloneLayoutValue);
|
|
264
|
+
if (value && Object.getPrototypeOf(value) === Object.prototype)
|
|
265
|
+
return mergeLayout({}, value);
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Region, file and series names come from the profiled application, so a
|
|
270
|
+
// hovertemplate that interpolates one is interpolating untrusted text. Plotly
|
|
271
|
+
// substitutes %{...} inside a template and renders what is left as HTML, so a
|
|
272
|
+
// region named "%{y}" rewrote every hover label that mentioned it, and one
|
|
273
|
+
// carrying a tag injected markup into them. Escape both spellings; the result
|
|
274
|
+
// is only ever used as literal text.
|
|
275
|
+
function label(name) {
|
|
276
|
+
return String(name)
|
|
277
|
+
.replace(/&/g, "&")
|
|
278
|
+
.replace(/</g, "<")
|
|
279
|
+
.replace(/>/g, ">")
|
|
280
|
+
.replace(/%\{/g, "%{");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function values(payload, key, kind, options) {
|
|
284
|
+
if (!payload || !Array.isArray(payload[key]))
|
|
285
|
+
throw new TypeError(
|
|
286
|
+
`Expected a scope-profiler plot-data payload with a ${key} array.`,
|
|
287
|
+
);
|
|
288
|
+
if (kind) validateRecords(kind, payload, options);
|
|
18
289
|
return payload[key];
|
|
19
290
|
}
|
|
20
291
|
|
|
21
|
-
|
|
292
|
+
// Chrome colours (text, gridlines, hover surface, the dashed ideal lines)
|
|
293
|
+
// for each theme a host page can be in. "auto" commits to nothing: it leaves
|
|
294
|
+
// the text colour unset and paints gridlines in a half-transparent grey that
|
|
295
|
+
// reads on either background, which is what every figure did before themes
|
|
296
|
+
// existed and so stays the default. A host that knows which theme it is in
|
|
297
|
+
// passes "light" or "dark" -- or its own token object -- and gets chrome that
|
|
298
|
+
// matches, since a grey that works on both is never the best on either.
|
|
299
|
+
const THEMES = {
|
|
300
|
+
auto: {
|
|
301
|
+
text: undefined,
|
|
302
|
+
muted: undefined,
|
|
303
|
+
grid: "rgba(128, 128, 128, 0.2)",
|
|
304
|
+
hoverBg: undefined,
|
|
305
|
+
neutral: "#777",
|
|
306
|
+
},
|
|
307
|
+
light: {
|
|
308
|
+
text: "#111827",
|
|
309
|
+
muted: "#6b7280",
|
|
310
|
+
grid: "#e1e0d9",
|
|
311
|
+
hoverBg: "#ffffff",
|
|
312
|
+
neutral: "#777",
|
|
313
|
+
},
|
|
314
|
+
dark: {
|
|
315
|
+
text: "#e5e7eb",
|
|
316
|
+
muted: "#9ca3af",
|
|
317
|
+
grid: "#2a2f3a",
|
|
318
|
+
hoverBg: "#171a21",
|
|
319
|
+
neutral: "#8b8b8b",
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
let defaultTheme = "auto";
|
|
324
|
+
|
|
325
|
+
/** Set the theme every builder uses when its options do not name one.
|
|
326
|
+
*
|
|
327
|
+
* A page with a dark-mode toggle sets this once per toggle and rebuilds its
|
|
328
|
+
* figures, instead of threading the theme through every build call.
|
|
329
|
+
* Accepts a theme name or an object of token overrides.
|
|
330
|
+
*/
|
|
331
|
+
export function setTheme(theme) {
|
|
332
|
+
defaultTheme =
|
|
333
|
+
theme && typeof theme === "object" ? { ...theme } : (theme ?? "auto");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The theme tokens currently in effect, or those a build option resolves to. */
|
|
337
|
+
export function resolveTheme(theme = defaultTheme) {
|
|
338
|
+
if (theme && typeof theme === "object") return { ...THEMES.auto, ...theme };
|
|
339
|
+
return { ...(Object.hasOwn(THEMES, theme) ? THEMES[theme] : THEMES.auto) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Builders take their layout helpers from here rather than calling the
|
|
343
|
+
// module-level ones, so a theme reaches every layout and axis in a figure
|
|
344
|
+
// without being passed down to each call.
|
|
345
|
+
function palette(options) {
|
|
346
|
+
const theme = resolveTheme(options?.theme);
|
|
347
|
+
return {
|
|
348
|
+
theme,
|
|
349
|
+
baseLayout: (overrides = {}) =>
|
|
350
|
+
mergeLayout(baseLayout(overrides, theme), options.layout),
|
|
351
|
+
axis: (overrides = {}) => axis(overrides, theme),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function baseLayout(overrides = {}, theme = resolveTheme()) {
|
|
22
356
|
return {
|
|
23
|
-
paper_bgcolor: "transparent",
|
|
24
|
-
|
|
25
|
-
|
|
357
|
+
paper_bgcolor: "transparent",
|
|
358
|
+
plot_bgcolor: "transparent",
|
|
359
|
+
font: {
|
|
360
|
+
family: "Inter, ui-sans-serif, system-ui, sans-serif",
|
|
361
|
+
size: 12,
|
|
362
|
+
...(theme.text ? { color: theme.text } : {}),
|
|
363
|
+
},
|
|
364
|
+
hovermode: "closest",
|
|
365
|
+
hoverlabel: {
|
|
366
|
+
namelength: -1,
|
|
367
|
+
...(theme.hoverBg
|
|
368
|
+
? {
|
|
369
|
+
bgcolor: theme.hoverBg,
|
|
370
|
+
bordercolor: theme.grid,
|
|
371
|
+
font: { color: theme.text },
|
|
372
|
+
}
|
|
373
|
+
: {}),
|
|
374
|
+
},
|
|
26
375
|
margin: { l: 100, r: 24, t: 32, b: 64 },
|
|
27
|
-
legend: {
|
|
376
|
+
legend: {
|
|
377
|
+
orientation: "h",
|
|
378
|
+
y: -0.2,
|
|
379
|
+
x: 0,
|
|
380
|
+
...(theme.muted ? { font: { color: theme.muted } } : {}),
|
|
381
|
+
},
|
|
382
|
+
...overrides,
|
|
28
383
|
};
|
|
29
384
|
}
|
|
30
385
|
|
|
31
|
-
function axis(overrides = {}) {
|
|
32
|
-
|
|
386
|
+
function axis(overrides = {}, theme = resolveTheme()) {
|
|
387
|
+
const styled = {
|
|
388
|
+
automargin: true,
|
|
389
|
+
gridcolor: theme.grid,
|
|
390
|
+
zeroline: false,
|
|
391
|
+
...(theme.text ? { zerolinecolor: theme.grid, linecolor: theme.grid } : {}),
|
|
392
|
+
...(theme.muted ? { tickfont: { color: theme.muted } } : {}),
|
|
393
|
+
...overrides,
|
|
394
|
+
};
|
|
395
|
+
// Builders pass an axis title as a bare string. Keep that spelling but give
|
|
396
|
+
// it the theme's muted colour, which a plain string cannot carry.
|
|
397
|
+
if (theme.muted && styled.title != null)
|
|
398
|
+
styled.title =
|
|
399
|
+
typeof styled.title === "string"
|
|
400
|
+
? { text: styled.title, font: { color: theme.muted } }
|
|
401
|
+
: {
|
|
402
|
+
...styled.title,
|
|
403
|
+
font: { color: theme.muted, ...styled.title.font },
|
|
404
|
+
};
|
|
405
|
+
return styled;
|
|
33
406
|
}
|
|
34
407
|
|
|
35
408
|
function withEmptyState(layout, hasData) {
|
|
36
409
|
if (hasData) return layout;
|
|
37
410
|
return {
|
|
38
411
|
...layout,
|
|
39
|
-
|
|
412
|
+
// Append rather than assign: an empty payload used to discard whatever
|
|
413
|
+
// annotations the caller's own `layout` override had put here.
|
|
414
|
+
annotations: [
|
|
415
|
+
...(Array.isArray(layout.annotations) ? layout.annotations : []),
|
|
416
|
+
{
|
|
417
|
+
text: "No data to display.",
|
|
418
|
+
showarrow: false,
|
|
419
|
+
xref: "paper",
|
|
420
|
+
yref: "paper",
|
|
421
|
+
x: 0.5,
|
|
422
|
+
y: 0.5,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
// A full grid with axis titles and no marks on it reads as a broken chart
|
|
426
|
+
// rather than an empty one, so the message stands on its own. Figures
|
|
427
|
+
// without cartesian axes (icicle, sankey) ignore these.
|
|
428
|
+
xaxis: { ...layout.xaxis, visible: false },
|
|
429
|
+
yaxis: { ...layout.yaxis, visible: false },
|
|
40
430
|
};
|
|
41
431
|
}
|
|
42
432
|
|
|
433
|
+
// Pooled magnitude per region, largest first -- the order both the durations
|
|
434
|
+
// bars and the heatmap columns are laid out in, and the one
|
|
435
|
+
// `buildRegionSummaryFigure` has always ranked by.
|
|
436
|
+
function totalsByRegion(rows, value) {
|
|
437
|
+
const totals = new Map();
|
|
438
|
+
for (const row of rows)
|
|
439
|
+
totals.set(row.region, (totals.get(row.region) ?? 0) + (value(row) ?? 0));
|
|
440
|
+
return new Map([...totals].sort((a, b) => b[1] - a[1]));
|
|
441
|
+
}
|
|
442
|
+
|
|
43
443
|
function filtered(rows, options) {
|
|
44
|
-
return typeof options?.filterRegion === "function"
|
|
444
|
+
return typeof options?.filterRegion === "function"
|
|
445
|
+
? rows.filter((row) => options.filterRegion(row.region, row))
|
|
446
|
+
: rows;
|
|
45
447
|
}
|
|
46
448
|
|
|
47
449
|
// One pass instead of a filter per series. A large trace has both many rows and
|
|
@@ -69,8 +471,10 @@ function runAware(rows) {
|
|
|
69
471
|
return {
|
|
70
472
|
multi,
|
|
71
473
|
files: [...files],
|
|
72
|
-
label: (row) =>
|
|
73
|
-
|
|
474
|
+
label: (row) =>
|
|
475
|
+
multi ? `${row.file ?? "run"} / ${row.region}` : row.region,
|
|
476
|
+
key: (row) =>
|
|
477
|
+
multi ? `${row.file ?? "run"}\u0000${row.region}` : row.region,
|
|
74
478
|
};
|
|
75
479
|
}
|
|
76
480
|
|
|
@@ -89,23 +493,43 @@ const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
|
|
|
89
493
|
* flat profile compared across many ranks.
|
|
90
494
|
*/
|
|
91
495
|
export function buildGanttFigure(payload, options = {}) {
|
|
92
|
-
const
|
|
496
|
+
const { baseLayout, axis } = palette(options);
|
|
497
|
+
const intervals = filtered(values(payload, "intervals", "gantt"), options);
|
|
93
498
|
const byRegion = groupBy(intervals, (row) => row.region);
|
|
94
499
|
const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
|
|
95
500
|
const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
|
|
96
501
|
const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
|
|
97
502
|
// Matching `plot gantt`'s own lane label, extended by the run only when the
|
|
98
503
|
// payload holds more than one.
|
|
99
|
-
const regionLane = (row) =>
|
|
504
|
+
const regionLane = (row) =>
|
|
505
|
+
`${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
|
|
100
506
|
const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
|
|
101
507
|
const lanes = [...new Set(intervals.map(laneOf))];
|
|
508
|
+
// A lane index rather than the lane string on every bar. The label is
|
|
509
|
+
// already in `lanes`; repeating it per interval costs one string per row and
|
|
510
|
+
// makes Plotly resolve a category for each of them, which a 200k-interval
|
|
511
|
+
// trace feels. The axis carries the names back via ticktext.
|
|
512
|
+
const laneIndex = new Map(lanes.map((lane, position) => [lane, position]));
|
|
102
513
|
const data = [...byRegion].map(([region, rows]) => {
|
|
103
|
-
return {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
514
|
+
return {
|
|
515
|
+
type: "bar",
|
|
516
|
+
orientation: "h",
|
|
517
|
+
name: region,
|
|
518
|
+
y: rows.map((row) => laneIndex.get(laneOf(row))),
|
|
519
|
+
x: rows.map((row) => row.end_seconds - row.start_seconds),
|
|
520
|
+
base: rows.map((row) => row.start_seconds),
|
|
521
|
+
// A categorical axis gave every bar its slot; a linear one sizes bars
|
|
522
|
+
// from the data, so the thickness has to be said out loud.
|
|
523
|
+
width: 0.8,
|
|
524
|
+
marker: {
|
|
525
|
+
color: colors.get(region),
|
|
526
|
+
line: { color: "rgba(0, 0, 0, 0.28)", width: 0.5 },
|
|
527
|
+
},
|
|
528
|
+
customdata: interactionData(rows, (row) => [
|
|
529
|
+
label(row.file ?? "run"),
|
|
530
|
+
row.rank ?? 0,
|
|
531
|
+
]),
|
|
532
|
+
hovertemplate: `<b>${label(region)}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
|
|
109
533
|
};
|
|
110
534
|
});
|
|
111
535
|
const byRank = options.laneBy === "rank";
|
|
@@ -113,92 +537,283 @@ export function buildGanttFigure(payload, options = {}) {
|
|
|
113
537
|
// Region lanes read bottom-up, so the first region -- the enclosing one --
|
|
114
538
|
// sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
|
|
115
539
|
// keep rank 0 on top, like the rank heatmap.
|
|
116
|
-
const layout = baseLayout({
|
|
540
|
+
const layout = baseLayout({
|
|
541
|
+
barmode: "overlay",
|
|
542
|
+
height: Math.max(280, perLane * lanes.length + 150),
|
|
543
|
+
showlegend: byRegion.size > 1,
|
|
544
|
+
xaxis: axis({ title: "Time (s)" }),
|
|
545
|
+
yaxis: axis({
|
|
546
|
+
tickmode: "array",
|
|
547
|
+
tickvals: lanes.map((_, position) => position),
|
|
548
|
+
ticktext: lanes,
|
|
549
|
+
range: byRank ? [lanes.length - 0.5, -0.5] : [-0.5, lanes.length - 0.5],
|
|
550
|
+
showgrid: false,
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
117
553
|
return { data, layout: withEmptyState(layout, intervals.length > 0) };
|
|
118
554
|
}
|
|
119
555
|
|
|
120
556
|
/** Build an icicle flame chart using scope-profiler's explicit call IDs. */
|
|
121
557
|
export function buildFlameFigure(payload, options = {}) {
|
|
122
|
-
const
|
|
558
|
+
const { theme, baseLayout } = palette(options);
|
|
559
|
+
const allCalls = values(payload, "calls", "flame");
|
|
123
560
|
const calls = filtered(allCalls, options);
|
|
124
561
|
const regions = [...new Set(calls.map((call) => call.region))];
|
|
125
562
|
const colors = colorMap(regions, options.colors ?? payload.colors);
|
|
126
563
|
const root = "scope-profiler-root";
|
|
127
|
-
const callKey = (call) =>
|
|
128
|
-
|
|
129
|
-
const
|
|
564
|
+
const callKey = (call) =>
|
|
565
|
+
`${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
|
|
566
|
+
const parentKey = (call) =>
|
|
567
|
+
call.parent_call_id == null
|
|
568
|
+
? null
|
|
569
|
+
: `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`;
|
|
570
|
+
const duration = (call) =>
|
|
571
|
+
call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds;
|
|
130
572
|
// A filter can remove a call whose children survive; re-parent each survivor
|
|
131
573
|
// onto its nearest surviving ancestor so the icicle stays a single tree
|
|
132
574
|
// instead of silently dropping the orphans.
|
|
133
|
-
const byKey =
|
|
575
|
+
const byKey = validateParents(allCalls, callKey, parentKey);
|
|
134
576
|
const kept = new Set(calls.map(callKey));
|
|
135
577
|
const anchor = (call) => {
|
|
136
578
|
let key = parentKey(call);
|
|
137
|
-
while (key != null && !kept.has(key))
|
|
579
|
+
while (key != null && !kept.has(key))
|
|
580
|
+
key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
|
|
138
581
|
return key ?? root;
|
|
139
582
|
};
|
|
140
583
|
const anchors = calls.map(anchor);
|
|
141
|
-
const rootDuration = calls.reduce(
|
|
142
|
-
|
|
584
|
+
const rootDuration = calls.reduce(
|
|
585
|
+
(sum, call, index) =>
|
|
586
|
+
anchors[index] === root ? sum + duration(call) : sum,
|
|
587
|
+
0,
|
|
588
|
+
);
|
|
589
|
+
const ids = [root],
|
|
590
|
+
labels = [options.rootLabel ?? "All calls"],
|
|
591
|
+
parents = [""],
|
|
592
|
+
markerColors = [theme.neutral],
|
|
593
|
+
hovertext = ["All calls"];
|
|
143
594
|
calls.forEach((call, index) => {
|
|
144
|
-
ids.push(callKey(call));
|
|
595
|
+
ids.push(callKey(call));
|
|
596
|
+
labels.push(call.region);
|
|
597
|
+
parents.push(anchors[index]);
|
|
145
598
|
markerColors.push(colors.get(call.region));
|
|
146
|
-
hovertext.push(
|
|
599
|
+
hovertext.push(
|
|
600
|
+
`<b>${label(call.region)}</b><br>${label(call.file ?? "run")} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${duration(call).toPrecision(6)} s`,
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
const layout = baseLayout({
|
|
604
|
+
height: 500,
|
|
605
|
+
margin: { l: 24, r: 24, t: 24, b: 24 },
|
|
147
606
|
});
|
|
148
|
-
|
|
149
|
-
|
|
607
|
+
return {
|
|
608
|
+
data: [
|
|
609
|
+
{
|
|
610
|
+
type: "icicle",
|
|
611
|
+
ids,
|
|
612
|
+
labels,
|
|
613
|
+
parents,
|
|
614
|
+
values: [rootDuration, ...calls.map(duration)],
|
|
615
|
+
branchvalues: "total",
|
|
616
|
+
tiling: { orientation: "h" },
|
|
617
|
+
marker: {
|
|
618
|
+
colors: markerColors,
|
|
619
|
+
line: { color: "rgba(255, 255, 255, 0.55)", width: 1 },
|
|
620
|
+
},
|
|
621
|
+
hovertext,
|
|
622
|
+
customdata: interactionData([{ region: null }, ...calls]),
|
|
623
|
+
hoverinfo: "text",
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
layout: withEmptyState(layout, calls.length > 0),
|
|
627
|
+
};
|
|
150
628
|
}
|
|
151
629
|
|
|
152
630
|
export function buildDurationsFigure(payload, options = {}) {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
631
|
+
const { baseLayout, axis } = palette(options);
|
|
632
|
+
const metric =
|
|
633
|
+
options.metric ??
|
|
634
|
+
payload.options?.metric ??
|
|
635
|
+
payload.metrics?.[0] ??
|
|
636
|
+
"total";
|
|
637
|
+
const bars = filtered(values(payload, "bars", "durations"), options).filter(
|
|
638
|
+
(bar) => bar.metric === metric,
|
|
639
|
+
);
|
|
155
640
|
// A stacked-children export is already decomposed into segments. Preserve
|
|
156
641
|
// that decomposition instead of letting duplicate region rows overwrite.
|
|
157
642
|
const stacked = bars.some((bar) => bar.segment != null);
|
|
158
|
-
const groups = groupBy(bars, (bar) =>
|
|
159
|
-
|
|
160
|
-
|
|
643
|
+
const groups = groupBy(bars, (bar) =>
|
|
644
|
+
stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`,
|
|
645
|
+
);
|
|
646
|
+
// Ranked by the pooled metric, so the costly regions lead. Appearance order
|
|
647
|
+
// is whatever the exporter happened to walk, which with two runs of
|
|
648
|
+
// different region sets interleaves them unpredictably.
|
|
649
|
+
const regions = [...totalsByRegion(bars, (bar) => bar.value_seconds).keys()];
|
|
650
|
+
// A group is a rank, a run, or a stacked child region depending on the
|
|
651
|
+
// export, but a caller -- and the payload's own `colors` -- keys colours by
|
|
652
|
+
// the name it knows. "rank 3" is a label this builder invents, so accept a
|
|
653
|
+
// colour supplied under the bare rank rather than silently dropping to the
|
|
654
|
+
// default cycle.
|
|
655
|
+
const supplied = options.colors ?? payload.colors ?? {};
|
|
656
|
+
const byGroup = {};
|
|
657
|
+
for (const [group, rows] of groups) {
|
|
658
|
+
const color = supplied[group] ?? supplied[rows[0].rank];
|
|
659
|
+
if (color != null) byGroup[group] = color;
|
|
660
|
+
}
|
|
661
|
+
const colors = colorMap(groups.keys(), byGroup);
|
|
161
662
|
const data = [...groups].map(([group, rows]) => {
|
|
162
|
-
const byRegion =
|
|
163
|
-
|
|
663
|
+
const byRegion = uniqueMap(
|
|
664
|
+
rows.map((bar) => [bar.region, bar.value_seconds]),
|
|
665
|
+
"durations",
|
|
666
|
+
);
|
|
667
|
+
return {
|
|
668
|
+
type: "bar",
|
|
669
|
+
name: group,
|
|
670
|
+
x: regions,
|
|
671
|
+
y: regions.map((region) => byRegion.get(region) ?? null),
|
|
672
|
+
customdata: interactionData(
|
|
673
|
+
regions.map((region) => ({
|
|
674
|
+
...rows.find((row) => row.region === region),
|
|
675
|
+
region,
|
|
676
|
+
})),
|
|
677
|
+
),
|
|
678
|
+
marker: {
|
|
679
|
+
color: colors.get(group),
|
|
680
|
+
line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
|
|
681
|
+
},
|
|
682
|
+
hovertemplate: `<b>%{x}</b><br>${label(group)}: %{y:.6g} s<extra></extra>`,
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
const layout = baseLayout({
|
|
686
|
+
barmode: stacked ? "stack" : "group",
|
|
687
|
+
height: Math.max(360, 34 * regions.length + 180),
|
|
688
|
+
showlegend: groups.size > 1,
|
|
689
|
+
xaxis: axis({ tickangle: -35 }),
|
|
690
|
+
yaxis: axis({ title: `${metric} duration (s)` }),
|
|
164
691
|
});
|
|
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 });
|
|
166
692
|
return { data, layout: withEmptyState(layout, bars.length > 0) };
|
|
167
693
|
}
|
|
168
694
|
|
|
169
695
|
// The three scaling exports differ only in the y column they carry and the
|
|
170
696
|
// shape of their ideal line, so one builder serves all of them.
|
|
171
697
|
const SCALING_KINDS = {
|
|
172
|
-
speedup: {
|
|
173
|
-
|
|
174
|
-
|
|
698
|
+
speedup: {
|
|
699
|
+
yKey: "speedup",
|
|
700
|
+
title: "Speedup",
|
|
701
|
+
suffix: "×",
|
|
702
|
+
idealName: "Ideal speedup",
|
|
703
|
+
ideal: (value, baseline) => value / baseline,
|
|
704
|
+
},
|
|
705
|
+
weak_scaling: {
|
|
706
|
+
yKey: "normalized_runtime",
|
|
707
|
+
title: "Normalized runtime",
|
|
708
|
+
suffix: "×",
|
|
709
|
+
idealName: "Ideal weak scaling",
|
|
710
|
+
ideal: () => 1,
|
|
711
|
+
},
|
|
712
|
+
scaling_efficiency: {
|
|
713
|
+
yKey: "efficiency",
|
|
714
|
+
title: "Scaling efficiency",
|
|
715
|
+
suffix: "",
|
|
716
|
+
idealName: "Ideal efficiency",
|
|
717
|
+
ideal: () => 1,
|
|
718
|
+
},
|
|
719
|
+
// Shares its y column with scaling_efficiency, so it has to be named --
|
|
720
|
+
// by the document's own `plot` field, or options.plot -- rather than
|
|
721
|
+
// recognised from the rows. Listed after it so a payload with neither
|
|
722
|
+
// still infers the strong-scaling reading it always did.
|
|
723
|
+
weak_scaling_efficiency: {
|
|
724
|
+
yKey: "efficiency",
|
|
725
|
+
title: "Weak-scaling efficiency",
|
|
726
|
+
suffix: "",
|
|
727
|
+
idealName: "Ideal efficiency",
|
|
728
|
+
ideal: () => 1,
|
|
729
|
+
},
|
|
175
730
|
};
|
|
176
731
|
|
|
177
732
|
function scalingKind(payload, options) {
|
|
178
733
|
const named = options.plot ?? payload?.plot;
|
|
179
734
|
if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
|
|
180
735
|
if (options.yField) {
|
|
181
|
-
const match = Object.values(SCALING_KINDS).find(
|
|
182
|
-
|
|
736
|
+
const match = Object.values(SCALING_KINDS).find(
|
|
737
|
+
(kind) => kind.yKey === options.yField,
|
|
738
|
+
);
|
|
739
|
+
return (
|
|
740
|
+
match ?? {
|
|
741
|
+
...SCALING_KINDS.speedup,
|
|
742
|
+
yKey: options.yField,
|
|
743
|
+
title: options.yField,
|
|
744
|
+
}
|
|
745
|
+
);
|
|
183
746
|
}
|
|
184
747
|
const row = payload?.points?.[0];
|
|
185
|
-
return (
|
|
748
|
+
return (
|
|
749
|
+
(row &&
|
|
750
|
+
Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ??
|
|
751
|
+
SCALING_KINDS.speedup
|
|
752
|
+
);
|
|
186
753
|
}
|
|
187
754
|
|
|
188
755
|
/** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
|
|
189
756
|
export function buildSpeedupFigure(payload, options = {}) {
|
|
757
|
+
const { theme, baseLayout, axis } = palette(options);
|
|
190
758
|
const kind = scalingKind(payload, options);
|
|
191
759
|
const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
|
|
192
|
-
const points = filtered(
|
|
760
|
+
const points = filtered(
|
|
761
|
+
values(payload, "points", "scaling", { ...options, yField: kind.yKey }),
|
|
762
|
+
options,
|
|
763
|
+
);
|
|
193
764
|
const byRegion = groupBy(points, (point) => point.region);
|
|
194
765
|
const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
|
|
195
|
-
const xValues = [...new Set(points.map((point) => point[xField]))].sort(
|
|
766
|
+
const xValues = [...new Set(points.map((point) => point[xField]))].sort(
|
|
767
|
+
(a, b) =>
|
|
768
|
+
typeof a === "number" && typeof b === "number"
|
|
769
|
+
? a - b
|
|
770
|
+
: String(a).localeCompare(String(b)),
|
|
771
|
+
);
|
|
196
772
|
const order = new Map(xValues.map((value, position) => [value, position]));
|
|
197
773
|
const numeric = xValues.every((value) => typeof value === "number");
|
|
198
|
-
const data = [...byRegion].map(([region, unsorted]) => {
|
|
774
|
+
const data = [...byRegion].map(([region, unsorted]) => {
|
|
775
|
+
const rows = [...unsorted].sort(
|
|
776
|
+
(a, b) => order.get(a[xField]) - order.get(b[xField]),
|
|
777
|
+
);
|
|
778
|
+
return {
|
|
779
|
+
type: "scatter",
|
|
780
|
+
mode: "lines+markers",
|
|
781
|
+
name: region,
|
|
782
|
+
x: rows.map((row) => row[xField]),
|
|
783
|
+
y: rows.map((row) => row[kind.yKey]),
|
|
784
|
+
customdata: interactionData(rows),
|
|
785
|
+
line: { color: colors.get(region), width: 2.4 },
|
|
786
|
+
marker: { color: colors.get(region), size: 7 },
|
|
787
|
+
hovertemplate: `<b>%{x}</b><br>${label(region)}: %{y:.3g}${kind.suffix}<extra></extra>`,
|
|
788
|
+
};
|
|
789
|
+
});
|
|
199
790
|
const baseline = payload.options?.baseline ?? xValues[0];
|
|
200
|
-
if (
|
|
201
|
-
|
|
791
|
+
if (
|
|
792
|
+
numeric &&
|
|
793
|
+
points.length &&
|
|
794
|
+
options.ideal !== false &&
|
|
795
|
+
(!Number.isFinite(baseline) || baseline <= 0)
|
|
796
|
+
)
|
|
797
|
+
throw new TypeError("Scaling baseline must be a positive finite number.");
|
|
798
|
+
if (numeric && options.ideal !== false)
|
|
799
|
+
data.push({
|
|
800
|
+
type: "scatter",
|
|
801
|
+
mode: "lines",
|
|
802
|
+
name: kind.idealName,
|
|
803
|
+
x: xValues,
|
|
804
|
+
y: xValues.map((value) => kind.ideal(value, baseline)),
|
|
805
|
+
line: { color: theme.neutral, dash: "dash" },
|
|
806
|
+
hoverinfo: "skip",
|
|
807
|
+
});
|
|
808
|
+
const layout = baseLayout({
|
|
809
|
+
height: 420,
|
|
810
|
+
showlegend: data.length > 1,
|
|
811
|
+
xaxis: axis({
|
|
812
|
+
title: payload.options?.x_label ?? xField,
|
|
813
|
+
tickvals: xValues,
|
|
814
|
+
}),
|
|
815
|
+
yaxis: axis({ title: kind.title, rangemode: "tozero" }),
|
|
816
|
+
});
|
|
202
817
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
203
818
|
}
|
|
204
819
|
|
|
@@ -209,136 +824,710 @@ export function buildWeakScalingFigure(payload, options = {}) {
|
|
|
209
824
|
|
|
210
825
|
/** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
|
|
211
826
|
export function buildScalingEfficiencyFigure(payload, options = {}) {
|
|
212
|
-
return buildSpeedupFigure(payload, {
|
|
827
|
+
return buildSpeedupFigure(payload, {
|
|
828
|
+
...options,
|
|
829
|
+
plot: "scaling_efficiency",
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Build a weak-scaling efficiency curve (baseline runtime over runtime).
|
|
834
|
+
*
|
|
835
|
+
* For a study that grows the problem with the machine, where the ideal is
|
|
836
|
+
* constant runtime -- not the rank-proportional speedup
|
|
837
|
+
* `buildScalingEfficiencyFigure` measures against.
|
|
838
|
+
*/
|
|
839
|
+
export function buildWeakScalingEfficiencyFigure(payload, options = {}) {
|
|
840
|
+
return buildSpeedupFigure(payload, {
|
|
841
|
+
...options,
|
|
842
|
+
plot: "weak_scaling_efficiency",
|
|
843
|
+
});
|
|
213
844
|
}
|
|
214
845
|
|
|
215
846
|
/** Build mean call duration over time, one trace per region. */
|
|
216
847
|
export function buildDurationTimeseriesFigure(payload, options = {}) {
|
|
217
|
-
const
|
|
848
|
+
const { baseLayout, axis } = palette(options);
|
|
849
|
+
const points = filtered(values(payload, "points", "timeseries"), options);
|
|
218
850
|
const runs = runAware(points);
|
|
219
|
-
const colors = colorMap(
|
|
851
|
+
const colors = colorMap(
|
|
852
|
+
points.map((point) => point.region),
|
|
853
|
+
options.colors ?? payload.colors,
|
|
854
|
+
);
|
|
220
855
|
const series = groupBy(points, runs.key);
|
|
221
856
|
const data = [...series].map(([, unsorted]) => {
|
|
222
857
|
const rows = [...unsorted].sort((a, b) => a.time_seconds - b.time_seconds);
|
|
223
|
-
const region = rows[0].region,
|
|
224
|
-
|
|
858
|
+
const region = rows[0].region,
|
|
859
|
+
name = runs.label(rows[0]);
|
|
860
|
+
return {
|
|
861
|
+
type: "scatter",
|
|
862
|
+
mode: "lines+markers",
|
|
863
|
+
name,
|
|
864
|
+
legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
|
|
865
|
+
x: rows.map((row) => row.time_seconds),
|
|
866
|
+
y: rows.map((row) => row.mean_duration_seconds),
|
|
867
|
+
...(options.variability === "error"
|
|
868
|
+
? {
|
|
869
|
+
error_y: {
|
|
870
|
+
type: "data",
|
|
871
|
+
symmetric: false,
|
|
872
|
+
array: rows.map((row) =>
|
|
873
|
+
row.max_duration_seconds == null
|
|
874
|
+
? null
|
|
875
|
+
: row.max_duration_seconds - row.mean_duration_seconds,
|
|
876
|
+
),
|
|
877
|
+
arrayminus: rows.map((row) =>
|
|
878
|
+
row.min_duration_seconds == null
|
|
879
|
+
? null
|
|
880
|
+
: row.mean_duration_seconds - row.min_duration_seconds,
|
|
881
|
+
),
|
|
882
|
+
},
|
|
883
|
+
}
|
|
884
|
+
: {}),
|
|
885
|
+
line: { color: colors.get(region), width: 2.2 },
|
|
886
|
+
marker: {
|
|
887
|
+
color: colors.get(region),
|
|
888
|
+
size: 5,
|
|
889
|
+
symbol:
|
|
890
|
+
FILE_SYMBOLS[
|
|
891
|
+
runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
|
|
892
|
+
],
|
|
893
|
+
},
|
|
894
|
+
customdata: interactionData(rows, (row) => [
|
|
895
|
+
row.min_duration_seconds,
|
|
896
|
+
row.max_duration_seconds,
|
|
897
|
+
row.call_index,
|
|
898
|
+
]),
|
|
899
|
+
hovertemplate: `<b>${label(name)}</b><br>time: %{x:.6g} s<br>mean: %{y:.6g} s<br>min–max: %{customdata[0]:.4g}–%{customdata[1]:.4g} s<extra></extra>`,
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
if (options.variability === "band") {
|
|
903
|
+
const bands = [...series].flatMap(([, unsorted]) => {
|
|
904
|
+
const rows = [...unsorted].sort(
|
|
905
|
+
(a, b) => a.time_seconds - b.time_seconds,
|
|
906
|
+
);
|
|
907
|
+
const common = {
|
|
908
|
+
type: "scatter",
|
|
909
|
+
mode: "lines",
|
|
910
|
+
x: rows.map((row) => row.time_seconds),
|
|
911
|
+
legendgroup: JSON.stringify([rows[0].file ?? "run", rows[0].region]),
|
|
912
|
+
showlegend: false,
|
|
913
|
+
hoverinfo: "skip",
|
|
914
|
+
line: { width: 0, color: colors.get(rows[0].region) },
|
|
915
|
+
};
|
|
916
|
+
return [
|
|
917
|
+
{ ...common, y: rows.map((row) => row.min_duration_seconds ?? null) },
|
|
918
|
+
{
|
|
919
|
+
...common,
|
|
920
|
+
y: rows.map((row) => row.max_duration_seconds ?? null),
|
|
921
|
+
fill: "tonexty",
|
|
922
|
+
opacity: 0.2,
|
|
923
|
+
},
|
|
924
|
+
];
|
|
925
|
+
});
|
|
926
|
+
data.unshift(...bands);
|
|
927
|
+
}
|
|
928
|
+
const layout = baseLayout({
|
|
929
|
+
height: 420,
|
|
930
|
+
showlegend: series.size > 1,
|
|
931
|
+
xaxis: axis({ title: "Time (s)" }),
|
|
932
|
+
yaxis: axis({ title: "Mean call duration (s)" }),
|
|
225
933
|
});
|
|
226
|
-
const layout = baseLayout({ height: 420, showlegend: series.size > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
|
|
227
934
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
228
935
|
}
|
|
229
936
|
|
|
230
937
|
/** Build duration distributions from histogram bin records. */
|
|
231
938
|
export function buildHistogramFigure(payload, options = {}) {
|
|
232
|
-
const
|
|
939
|
+
const { baseLayout, axis } = palette(options);
|
|
940
|
+
const bins = filtered(values(payload, "bins", "histogram"), options);
|
|
233
941
|
const runs = runAware(bins);
|
|
234
|
-
const colors = colorMap(
|
|
942
|
+
const colors = colorMap(
|
|
943
|
+
bins.map((bin) => bin.region),
|
|
944
|
+
options.colors ?? payload.colors,
|
|
945
|
+
);
|
|
235
946
|
const series = groupBy(bins, runs.key);
|
|
236
947
|
const data = [...series].map(([, unsorted]) => {
|
|
237
|
-
const rows = [...unsorted].sort(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
948
|
+
const rows = [...unsorted].sort(
|
|
949
|
+
(a, b) => a.bin_center_seconds - b.bin_center_seconds,
|
|
950
|
+
);
|
|
951
|
+
const region = rows[0].region,
|
|
952
|
+
name = runs.label(rows[0]);
|
|
953
|
+
const pattern =
|
|
954
|
+
FILE_PATTERNS[
|
|
955
|
+
runs.files.indexOf(rows[0].file ?? "run") % FILE_PATTERNS.length
|
|
956
|
+
];
|
|
957
|
+
return {
|
|
958
|
+
type: "bar",
|
|
959
|
+
name,
|
|
960
|
+
x: rows.map((bin) => bin.bin_center_seconds),
|
|
961
|
+
y: rows.map((bin) => bin.count),
|
|
962
|
+
customdata: interactionData(rows),
|
|
963
|
+
width: rows.map((bin) => bin.bin_high_seconds - bin.bin_low_seconds),
|
|
964
|
+
marker: {
|
|
965
|
+
color: colors.get(region),
|
|
966
|
+
line: { color: "rgba(0, 0, 0, 0.2)", width: 0.5 },
|
|
967
|
+
...(runs.multi ? { pattern: { shape: pattern, solidity: 0.35 } } : {}),
|
|
968
|
+
},
|
|
969
|
+
hovertemplate: `<b>${label(name)}</b><br>%{x:.6g} s: %{y} calls<extra></extra>`,
|
|
970
|
+
};
|
|
971
|
+
});
|
|
972
|
+
const layout = baseLayout({
|
|
973
|
+
barmode: "overlay",
|
|
974
|
+
height: 400,
|
|
975
|
+
showlegend: series.size > 1,
|
|
976
|
+
xaxis: axis({ title: "Call duration (s)" }),
|
|
977
|
+
yaxis: axis({ title: "Calls" }),
|
|
241
978
|
});
|
|
242
|
-
const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: series.size > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
|
|
243
979
|
return { data, layout: withEmptyState(layout, bins.length > 0) };
|
|
244
980
|
}
|
|
245
981
|
|
|
246
982
|
/** Build a rank × region heatmap from duration records. */
|
|
247
983
|
export function buildRankHeatmapFigure(payload, options = {}) {
|
|
248
|
-
const
|
|
249
|
-
const
|
|
984
|
+
const { baseLayout, axis } = palette(options);
|
|
985
|
+
const points = filtered(
|
|
986
|
+
values(payload, "points", "rank_heatmap", options),
|
|
987
|
+
options,
|
|
988
|
+
);
|
|
250
989
|
const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
|
|
251
990
|
// A lane per run and rank. Keying cells by rank alone silently let a second
|
|
252
991
|
// run overwrite the first, showing one run's numbers under both labels.
|
|
253
|
-
const laneOf = (point) =>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
992
|
+
const laneOf = (point) =>
|
|
993
|
+
multi
|
|
994
|
+
? `${point.file ?? "run"} / rank ${point.rank ?? 0}`
|
|
995
|
+
: String(point.rank ?? 0);
|
|
996
|
+
const lanes = [...new Set(points.map(laneOf))].sort((a, b) =>
|
|
997
|
+
a.localeCompare(b, undefined, { numeric: true }),
|
|
998
|
+
);
|
|
999
|
+
const inferredValueKey = points[0]
|
|
1000
|
+
? Object.keys(points[0]).find((key) => key.endsWith("_duration_seconds"))
|
|
1001
|
+
: undefined;
|
|
1002
|
+
const valueKey =
|
|
1003
|
+
options.valueKey ?? inferredValueKey ?? "total_duration_seconds";
|
|
1004
|
+
// Columns ranked by pooled duration rather than by the order the exporter
|
|
1005
|
+
// happened to walk, which with two runs of different region sets interleaves
|
|
1006
|
+
// them differently every time.
|
|
1007
|
+
const regions = [
|
|
1008
|
+
...totalsByRegion(points, (point) => point[valueKey]).keys(),
|
|
1009
|
+
];
|
|
1010
|
+
const byCell = uniqueMap(
|
|
1011
|
+
points.map((point) => [`${laneOf(point)}\u0000${point.region}`, point]),
|
|
1012
|
+
"rank_heatmap",
|
|
1013
|
+
);
|
|
1014
|
+
const data = [
|
|
1015
|
+
{
|
|
1016
|
+
type: "heatmap",
|
|
1017
|
+
x: regions,
|
|
1018
|
+
y: lanes,
|
|
1019
|
+
z: lanes.map((lane) =>
|
|
1020
|
+
regions.map(
|
|
1021
|
+
(region) => byCell.get(`${lane}\u0000${region}`)?.[valueKey] ?? null,
|
|
1022
|
+
),
|
|
1023
|
+
),
|
|
1024
|
+
customdata: lanes.map((lane) =>
|
|
1025
|
+
regions.map(
|
|
1026
|
+
(region) =>
|
|
1027
|
+
interactionData([
|
|
1028
|
+
{ ...byCell.get(`${lane}\u0000${region}`), region },
|
|
1029
|
+
])[0],
|
|
1030
|
+
),
|
|
1031
|
+
),
|
|
1032
|
+
colorscale: options.colorscale ?? "Viridis",
|
|
1033
|
+
colorbar: { title: "Seconds" },
|
|
1034
|
+
hovertemplate: `${multi ? "%{y}" : "rank %{y}"}<br>%{x}: %{z:.6g} s<extra></extra>`,
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
const layout = baseLayout({
|
|
1038
|
+
height: Math.max(320, 44 * lanes.length + 150),
|
|
1039
|
+
xaxis: axis({ title: "Region" }),
|
|
1040
|
+
yaxis: axis({
|
|
1041
|
+
title: multi ? "Run / rank" : "Rank",
|
|
1042
|
+
autorange: "reversed",
|
|
1043
|
+
showgrid: false,
|
|
1044
|
+
}),
|
|
1045
|
+
});
|
|
260
1046
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
261
1047
|
}
|
|
262
1048
|
|
|
263
1049
|
/** Build per-rank duration lines, with a dashed rank mean for each region. */
|
|
264
1050
|
export function buildImbalanceFigure(payload, options = {}) {
|
|
265
|
-
const
|
|
1051
|
+
const { baseLayout, axis } = palette(options);
|
|
1052
|
+
const points = filtered(values(payload, "points", "imbalance"), options);
|
|
266
1053
|
const runs = runAware(points);
|
|
267
|
-
const colors = colorMap(
|
|
1054
|
+
const colors = colorMap(
|
|
1055
|
+
points.map((point) => point.region),
|
|
1056
|
+
options.colors ?? payload.colors,
|
|
1057
|
+
);
|
|
268
1058
|
// The mean is computed per run, so a run gets its own line and its own mean;
|
|
269
1059
|
// pooling them drew one zig-zagging series that revisited every rank.
|
|
270
1060
|
const series = groupBy(points, runs.key);
|
|
271
1061
|
const data = [...series].flatMap(([, unsorted]) => {
|
|
272
1062
|
const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
|
|
273
|
-
const region = rows[0].region,
|
|
1063
|
+
const region = rows[0].region,
|
|
1064
|
+
name = runs.label(rows[0]);
|
|
274
1065
|
const color = colors.get(region);
|
|
275
|
-
const symbol =
|
|
1066
|
+
const symbol =
|
|
1067
|
+
FILE_SYMBOLS[
|
|
1068
|
+
runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
|
|
1069
|
+
];
|
|
276
1070
|
return [
|
|
277
|
-
{
|
|
278
|
-
|
|
1071
|
+
{
|
|
1072
|
+
type: "scatter",
|
|
1073
|
+
mode: "lines+markers",
|
|
1074
|
+
name,
|
|
1075
|
+
legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
|
|
1076
|
+
x: rows.map((row) => row.rank),
|
|
1077
|
+
y: rows.map((row) => row.value_seconds),
|
|
1078
|
+
customdata: interactionData(rows),
|
|
1079
|
+
line: { color, width: 2.2 },
|
|
1080
|
+
marker: { color, size: 7, symbol },
|
|
1081
|
+
hovertemplate: `<b>${label(name)}</b><br>rank %{x}: %{y:.6g} s<extra></extra>`,
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
type: "scatter",
|
|
1085
|
+
mode: "lines",
|
|
1086
|
+
name: `${name} mean`,
|
|
1087
|
+
legendgroup: JSON.stringify([rows[0].file ?? "run", region]),
|
|
1088
|
+
x: rows.map((row) => row.rank),
|
|
1089
|
+
y: rows.map((row) => row.mean_over_ranks_seconds),
|
|
1090
|
+
customdata: interactionData(rows),
|
|
1091
|
+
line: { color, dash: "dot", width: 1.3 },
|
|
1092
|
+
hoverinfo: "skip",
|
|
1093
|
+
showlegend: false,
|
|
1094
|
+
},
|
|
279
1095
|
];
|
|
280
1096
|
});
|
|
281
|
-
const layout = baseLayout({
|
|
1097
|
+
const layout = baseLayout({
|
|
1098
|
+
height: 420,
|
|
1099
|
+
showlegend: series.size > 1,
|
|
1100
|
+
xaxis: axis({ title: "Rank", dtick: 1 }),
|
|
1101
|
+
yaxis: axis({ title: `${payload.metric ?? "Duration"} (s)` }),
|
|
1102
|
+
});
|
|
282
1103
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
283
1104
|
}
|
|
284
1105
|
|
|
285
1106
|
/** Build a timeline-occupancy heatmap from binned density records. */
|
|
286
1107
|
export function buildDensityFigure(payload, options = {}) {
|
|
287
|
-
const
|
|
1108
|
+
const { baseLayout, axis } = palette(options);
|
|
1109
|
+
const points = filtered(values(payload, "points", "density"), options);
|
|
288
1110
|
const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
|
|
289
1111
|
const lanes = [...new Set(points.map(lane))];
|
|
290
|
-
|
|
291
|
-
|
|
1112
|
+
// Each cell sits at the centre of its own bin. Deriving one bin width from
|
|
1113
|
+
// the first point and applying it to every lane put the second run's cells
|
|
1114
|
+
// at the wrong times whenever two runs of different length were binned into
|
|
1115
|
+
// the same number of bins -- which is exactly what the exporter does.
|
|
1116
|
+
const centre = (point) =>
|
|
1117
|
+
(point.bin_start_seconds + point.bin_end_seconds) / 2;
|
|
1118
|
+
const centres = [...new Set(points.map(centre))].sort((a, b) => a - b);
|
|
292
1119
|
// Occupancy is the share of the bin the region was inside, which compares
|
|
293
1120
|
// across runs of different length; raw seconds stay available via valueKey.
|
|
294
1121
|
const asFraction = (options.valueKey ?? "occupancy") === "occupancy";
|
|
295
|
-
|
|
296
|
-
|
|
1122
|
+
// NUL-joined: a lane name is a file and a region, either of which may hold
|
|
1123
|
+
// the ":" that used to separate the two halves of this key.
|
|
1124
|
+
const byCell = uniqueMap(
|
|
1125
|
+
points.map((point) => [`${lane(point)}\u0000${centre(point)}`, point]),
|
|
1126
|
+
"density",
|
|
1127
|
+
);
|
|
1128
|
+
const cell = (laneName, at, pick) => {
|
|
1129
|
+
const point = byCell.get(`${laneName}\u0000${at}`);
|
|
1130
|
+
return point ? pick(point) : null;
|
|
1131
|
+
};
|
|
297
1132
|
const span = (point) => point.bin_end_seconds - point.bin_start_seconds;
|
|
298
|
-
const data = [
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
1133
|
+
const data = [
|
|
1134
|
+
{
|
|
1135
|
+
type: "heatmap",
|
|
1136
|
+
x: centres,
|
|
1137
|
+
y: lanes,
|
|
1138
|
+
z: lanes.map((laneName) =>
|
|
1139
|
+
centres.map((at) =>
|
|
1140
|
+
cell(laneName, at, (point) =>
|
|
1141
|
+
asFraction
|
|
1142
|
+
? span(point) > 0
|
|
1143
|
+
? point.occupied_seconds / span(point)
|
|
1144
|
+
: null
|
|
1145
|
+
: point.occupied_seconds,
|
|
1146
|
+
),
|
|
1147
|
+
),
|
|
1148
|
+
),
|
|
1149
|
+
customdata: lanes.map((laneName) =>
|
|
1150
|
+
centres.map((at) =>
|
|
1151
|
+
cell(
|
|
1152
|
+
laneName,
|
|
1153
|
+
at,
|
|
1154
|
+
(point) =>
|
|
1155
|
+
interactionData([point], (row) => [row.occupied_seconds])[0],
|
|
1156
|
+
),
|
|
1157
|
+
),
|
|
1158
|
+
),
|
|
1159
|
+
colorscale: options.colorscale ?? "Viridis",
|
|
1160
|
+
...(asFraction ? { zmin: 0, zmax: 1 } : {}),
|
|
1161
|
+
colorbar: { title: asFraction ? "Occupancy" : "Seconds" },
|
|
1162
|
+
hovertemplate: `%{y}<br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata[0]:.4g} s<extra></extra>`,
|
|
1163
|
+
},
|
|
1164
|
+
];
|
|
1165
|
+
// Plotly infers edges from centers. That is only accurate on one uniform
|
|
1166
|
+
// grid. For unequal grids use one trace per lane with explicit bin edges;
|
|
1167
|
+
// this preserves measured boundaries and never invents interpolated data.
|
|
1168
|
+
const grids = [...groupBy(points, lane)].map(([name, rows]) => {
|
|
1169
|
+
rows = [...rows].sort((a, b) => a.bin_start_seconds - b.bin_start_seconds);
|
|
1170
|
+
for (let i = 1; i < rows.length; i++) {
|
|
1171
|
+
if (rows[i].bin_start_seconds < rows[i - 1].bin_end_seconds)
|
|
1172
|
+
throw new TypeError(`density: overlapping bins in ${name}`);
|
|
1173
|
+
}
|
|
1174
|
+
return [name, rows];
|
|
1175
|
+
});
|
|
1176
|
+
const widths = new Set(points.map(span));
|
|
1177
|
+
const sameGrid =
|
|
1178
|
+
new Set(
|
|
1179
|
+
grids.map(([, rows]) =>
|
|
1180
|
+
JSON.stringify(
|
|
1181
|
+
rows.map((row) => [row.bin_start_seconds, row.bin_end_seconds]),
|
|
1182
|
+
),
|
|
1183
|
+
),
|
|
1184
|
+
).size <= 1;
|
|
1185
|
+
const explicitEdges =
|
|
1186
|
+
!sameGrid ||
|
|
1187
|
+
widths.size > 1 ||
|
|
1188
|
+
grids.some(([, rows]) =>
|
|
1189
|
+
rows.some(
|
|
1190
|
+
(row, index) =>
|
|
1191
|
+
index > 0 &&
|
|
1192
|
+
row.bin_start_seconds !== rows[index - 1].bin_end_seconds,
|
|
1193
|
+
),
|
|
1194
|
+
);
|
|
1195
|
+
if (explicitEdges) {
|
|
1196
|
+
const template = data[0];
|
|
1197
|
+
const maximumSeconds = points.reduce(
|
|
1198
|
+
(maximum, row) => Math.max(maximum, row.occupied_seconds),
|
|
1199
|
+
0,
|
|
1200
|
+
);
|
|
1201
|
+
data.splice(
|
|
1202
|
+
0,
|
|
1203
|
+
data.length,
|
|
1204
|
+
...grids.map(([name, rows], index) => {
|
|
1205
|
+
const edges = [
|
|
1206
|
+
...new Set(
|
|
1207
|
+
rows.flatMap((row) => [row.bin_start_seconds, row.bin_end_seconds]),
|
|
1208
|
+
),
|
|
1209
|
+
].sort((a, b) => a - b);
|
|
1210
|
+
const byStart = new Map(
|
|
1211
|
+
rows.map((row) => [row.bin_start_seconds, row]),
|
|
1212
|
+
);
|
|
1213
|
+
const cells = edges.slice(0, -1).map((start) => byStart.get(start));
|
|
1214
|
+
return {
|
|
1215
|
+
...template,
|
|
1216
|
+
x: edges,
|
|
1217
|
+
y: [index - 0.5, index + 0.5],
|
|
1218
|
+
showscale: index === 0,
|
|
1219
|
+
z: [
|
|
1220
|
+
cells.map((row) =>
|
|
1221
|
+
row
|
|
1222
|
+
? asFraction
|
|
1223
|
+
? span(row) > 0
|
|
1224
|
+
? row.occupied_seconds / span(row)
|
|
1225
|
+
: null
|
|
1226
|
+
: row.occupied_seconds
|
|
1227
|
+
: null,
|
|
1228
|
+
),
|
|
1229
|
+
],
|
|
1230
|
+
customdata: [
|
|
1231
|
+
cells.map((row) =>
|
|
1232
|
+
row
|
|
1233
|
+
? interactionData([row], (point) => [point.occupied_seconds])[0]
|
|
1234
|
+
: null,
|
|
1235
|
+
),
|
|
1236
|
+
],
|
|
1237
|
+
hovertemplate: `<b>${label(name)}</b><br>t = %{x:.6g} s<br>${asFraction ? "occupancy: %{z:.3f}<br>" : ""}occupied: %{customdata[0]:.4g} s<extra></extra>`,
|
|
1238
|
+
...(!asFraction
|
|
1239
|
+
? {
|
|
1240
|
+
zmin: 0,
|
|
1241
|
+
zmax: maximumSeconds,
|
|
1242
|
+
}
|
|
1243
|
+
: {}),
|
|
1244
|
+
};
|
|
1245
|
+
}),
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
const layout = baseLayout({
|
|
1249
|
+
height: Math.max(320, 34 * lanes.length + 150),
|
|
1250
|
+
xaxis: axis({ title: "Time (s)" }),
|
|
1251
|
+
yaxis: axis({
|
|
1252
|
+
categoryorder: "array",
|
|
1253
|
+
categoryarray: lanes,
|
|
1254
|
+
autorange: "reversed",
|
|
1255
|
+
showgrid: false,
|
|
1256
|
+
...(explicitEdges
|
|
1257
|
+
? {
|
|
1258
|
+
type: "linear",
|
|
1259
|
+
tickmode: "array",
|
|
1260
|
+
tickvals: lanes.map((_, index) => index),
|
|
1261
|
+
ticktext: lanes,
|
|
1262
|
+
range: [lanes.length - 0.5, -0.5],
|
|
1263
|
+
autorange: false,
|
|
1264
|
+
}
|
|
1265
|
+
: {}),
|
|
1266
|
+
}),
|
|
1267
|
+
});
|
|
307
1268
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
308
1269
|
}
|
|
309
1270
|
|
|
310
|
-
|
|
1271
|
+
/** Axis label for each field a region_statistics document stores. */
|
|
1272
|
+
const SUMMARY_LABELS = {
|
|
1273
|
+
count: "Calls",
|
|
1274
|
+
average_duration_seconds: "Average duration (s)",
|
|
1275
|
+
min_duration_seconds: "Minimum duration (s)",
|
|
1276
|
+
max_duration_seconds: "Maximum duration (s)",
|
|
1277
|
+
first_duration_seconds: "First call duration (s)",
|
|
1278
|
+
last_duration_seconds: "Last call duration (s)",
|
|
1279
|
+
std_duration_seconds: "Duration std. dev. (s)",
|
|
1280
|
+
total_duration_seconds: "Total duration (s)",
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
/** Short metric names accepted by the region_statistics builders.
|
|
1284
|
+
*
|
|
1285
|
+
* The names the CLI and the durations export use, mapped to the field they are
|
|
1286
|
+
* stored under in a region_statistics document, so a caller can say "total"
|
|
1287
|
+
* wherever it says "total" everywhere else. The stored field names are
|
|
1288
|
+
* accepted too; exported so a page can offer the list rather than guess it.
|
|
1289
|
+
*/
|
|
1290
|
+
export const SUMMARY_METRICS = Object.freeze({
|
|
1291
|
+
avg: "average_duration_seconds",
|
|
1292
|
+
min: "min_duration_seconds",
|
|
1293
|
+
max: "max_duration_seconds",
|
|
1294
|
+
total: "total_duration_seconds",
|
|
1295
|
+
first: "first_duration_seconds",
|
|
1296
|
+
last: "last_duration_seconds",
|
|
1297
|
+
std: "std_duration_seconds",
|
|
1298
|
+
count: "count",
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
// Pick the runs to draw, in the order asked for. `files` names them by label
|
|
1302
|
+
// (or by index), which is how a page lets a viewer compare two runs out of a
|
|
1303
|
+
// document that holds many.
|
|
1304
|
+
function selectFiles(files, selection) {
|
|
1305
|
+
if (!Array.isArray(selection)) return files;
|
|
1306
|
+
return selection
|
|
1307
|
+
.map((wanted) =>
|
|
1308
|
+
typeof wanted === "number"
|
|
1309
|
+
? files[wanted]
|
|
1310
|
+
: files.find((file) => (file.label ?? "run") === wanted),
|
|
1311
|
+
)
|
|
1312
|
+
.filter(Boolean);
|
|
1313
|
+
}
|
|
311
1314
|
|
|
312
1315
|
/** Build a ranked region bar chart from a region_statistics document. */
|
|
313
1316
|
export function buildRegionSummaryFigure(payload, options = {}) {
|
|
314
|
-
const
|
|
315
|
-
const
|
|
1317
|
+
const { baseLayout, axis } = palette(options);
|
|
1318
|
+
const files = selectFiles(
|
|
1319
|
+
values(payload, "files", "region_statistics"),
|
|
1320
|
+
options.files,
|
|
1321
|
+
);
|
|
1322
|
+
const metric =
|
|
1323
|
+
SUMMARY_METRICS[options.metric] ??
|
|
1324
|
+
options.metric ??
|
|
1325
|
+
"total_duration_seconds";
|
|
1326
|
+
// An unrecognised metric used to draw a full chart of nulls, which reads as
|
|
1327
|
+
// "this run recorded nothing" rather than "that is not a metric".
|
|
1328
|
+
if (!(metric in SUMMARY_LABELS))
|
|
1329
|
+
throw new TypeError(
|
|
1330
|
+
`Unknown region-statistics metric ${JSON.stringify(options.metric)}; expected one of ${[
|
|
1331
|
+
...Object.keys(SUMMARY_METRICS),
|
|
1332
|
+
...Object.keys(SUMMARY_LABELS),
|
|
1333
|
+
]
|
|
1334
|
+
.map((name) => JSON.stringify(name))
|
|
1335
|
+
.join(", ")}.`,
|
|
1336
|
+
);
|
|
316
1337
|
const limit = options.topN ?? 20;
|
|
317
|
-
const
|
|
1338
|
+
const horizontal = (options.orientation ?? "h") === "h";
|
|
1339
|
+
const keep =
|
|
1340
|
+
typeof options.filterRegion === "function"
|
|
1341
|
+
? options.filterRegion
|
|
1342
|
+
: () => true;
|
|
1343
|
+
// Comparing runs is only meaningful over the regions they share: a region
|
|
1344
|
+
// one run never entered would otherwise draw as a bar of zero against a
|
|
1345
|
+
// real one, which reads as "got faster" rather than "not measured here".
|
|
1346
|
+
const common =
|
|
1347
|
+
options.commonRegionsOnly && files.length > 1
|
|
1348
|
+
? (region) =>
|
|
1349
|
+
files.every((file) => (file.region_statistics ?? {})[region] != null)
|
|
1350
|
+
: () => true;
|
|
318
1351
|
const totals = new Map();
|
|
319
1352
|
for (const file of files) {
|
|
320
|
-
for (const [region, stats] of Object.entries(
|
|
321
|
-
|
|
1353
|
+
for (const [region, stats] of Object.entries(
|
|
1354
|
+
file.region_statistics ?? {},
|
|
1355
|
+
)) {
|
|
1356
|
+
if (!keep(region, stats) || !common(region)) continue;
|
|
322
1357
|
totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
|
|
323
1358
|
}
|
|
324
1359
|
}
|
|
325
1360
|
// Rank by the pooled metric so the slowest regions lead, then keep the head
|
|
326
1361
|
// of the list: a long run has more regions than a bar chart can carry.
|
|
327
|
-
const regions = [...totals.entries()]
|
|
1362
|
+
const regions = [...totals.entries()]
|
|
1363
|
+
.sort((a, b) => b[1] - a[1])
|
|
1364
|
+
.slice(0, limit)
|
|
1365
|
+
.map(([region]) => region);
|
|
328
1366
|
const labels = files.map((file) => file.label ?? "run");
|
|
329
1367
|
const colors = colorMap(labels, options.colors ?? payload.colors);
|
|
330
1368
|
const unit = metric === "count" ? "" : " s";
|
|
1369
|
+
const values_ = (stats) =>
|
|
1370
|
+
regions.map((region) => stats[region]?.[metric] ?? null);
|
|
331
1371
|
const data = files.map((file, index) => {
|
|
332
1372
|
const stats = file.region_statistics ?? {};
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1373
|
+
const magnitudes = values_(stats);
|
|
1374
|
+
return {
|
|
1375
|
+
type: "bar",
|
|
1376
|
+
...(horizontal
|
|
1377
|
+
? { orientation: "h", y: regions, x: magnitudes }
|
|
1378
|
+
: { x: regions, y: magnitudes }),
|
|
1379
|
+
name: labels[index],
|
|
1380
|
+
marker: {
|
|
1381
|
+
color: colors.get(labels[index]),
|
|
1382
|
+
line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
|
|
1383
|
+
},
|
|
1384
|
+
customdata: interactionData(
|
|
1385
|
+
regions.map((region) => ({ region, file: file.label ?? "run" })),
|
|
1386
|
+
(row) => [stats[row.region]?.count ?? null],
|
|
1387
|
+
),
|
|
1388
|
+
hovertemplate: horizontal
|
|
1389
|
+
? `<b>%{y}</b><br>${label(labels[index])}: %{x:.6g}${unit}<br>calls: %{customdata[0]}<extra></extra>`
|
|
1390
|
+
: `<b>%{x}</b><br>${label(labels[index])}: %{y:.6g}${unit}<br>calls: %{customdata[0]}<extra></extra>`,
|
|
1391
|
+
};
|
|
1392
|
+
});
|
|
1393
|
+
const magnitudeAxis = axis({ title: SUMMARY_LABELS[metric] ?? metric });
|
|
1394
|
+
const categoryAxis = axis({
|
|
1395
|
+
categoryorder: "array",
|
|
1396
|
+
// A horizontal bar chart fills from the bottom up, so the ranking has to
|
|
1397
|
+
// be reversed to read top-down; a vertical one already reads left to right.
|
|
1398
|
+
categoryarray: horizontal ? [...regions].reverse() : regions,
|
|
1399
|
+
showgrid: false,
|
|
1400
|
+
...(horizontal ? {} : { tickangle: -35 }),
|
|
1401
|
+
});
|
|
1402
|
+
const layout = baseLayout({
|
|
1403
|
+
barmode: "group",
|
|
1404
|
+
...(horizontal
|
|
1405
|
+
? {
|
|
1406
|
+
height: Math.max(320, 26 * regions.length + 160),
|
|
1407
|
+
xaxis: magnitudeAxis,
|
|
1408
|
+
yaxis: categoryAxis,
|
|
1409
|
+
}
|
|
1410
|
+
: {
|
|
1411
|
+
height: Math.max(360, 26 * regions.length + 200),
|
|
1412
|
+
margin: { l: 100, r: 24, t: 32, b: 140 },
|
|
1413
|
+
xaxis: categoryAxis,
|
|
1414
|
+
yaxis: magnitudeAxis,
|
|
1415
|
+
}),
|
|
1416
|
+
showlegend: files.length > 1,
|
|
337
1417
|
});
|
|
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
1418
|
return { data, layout: withEmptyState(layout, regions.length > 0) };
|
|
340
1419
|
}
|
|
341
1420
|
|
|
1421
|
+
/** Build a side-by-side comparison of runs in a region_statistics document.
|
|
1422
|
+
*
|
|
1423
|
+
* The same bars as `buildRegionSummaryFigure`, narrowed to the runs named in
|
|
1424
|
+
* `options.files` (every run in the document by default, not only the first
|
|
1425
|
+
* two) and to the regions all of them recorded, and drawn vertically with
|
|
1426
|
+
* every shared region kept rather than a ranked top slice -- the reading for
|
|
1427
|
+
* "what changed between these runs?" rather than "where did this run spend
|
|
1428
|
+
* its time?". `options.comparison: "absolute"` or `"percent"` instead draws a
|
|
1429
|
+
* single delta bar per region, which needs exactly two runs.
|
|
1430
|
+
*/
|
|
1431
|
+
export function buildComparisonFigure(payload, options = {}) {
|
|
1432
|
+
if (options.comparison && options.comparison !== "side-by-side") {
|
|
1433
|
+
const files = selectFiles(
|
|
1434
|
+
values(payload, "files", "region_statistics"),
|
|
1435
|
+
options.files ?? [0, 1],
|
|
1436
|
+
);
|
|
1437
|
+
if (files.length !== 2)
|
|
1438
|
+
throw new TypeError("Delta comparison requires exactly two runs.");
|
|
1439
|
+
const metric =
|
|
1440
|
+
SUMMARY_METRICS[options.metric] ??
|
|
1441
|
+
options.metric ??
|
|
1442
|
+
"total_duration_seconds";
|
|
1443
|
+
if (!Object.hasOwn(SUMMARY_LABELS, metric))
|
|
1444
|
+
throw new TypeError(`Unknown region-statistics metric ${metric}`);
|
|
1445
|
+
if (!["absolute", "percent"].includes(options.comparison))
|
|
1446
|
+
throw new TypeError(
|
|
1447
|
+
"comparison must be side-by-side, absolute, or percent.",
|
|
1448
|
+
);
|
|
1449
|
+
const [before, after] = files.map((file) => file.region_statistics);
|
|
1450
|
+
const regions = [
|
|
1451
|
+
...new Set([...Object.keys(before), ...Object.keys(after)]),
|
|
1452
|
+
].filter(
|
|
1453
|
+
(region) =>
|
|
1454
|
+
!options.filterRegion ||
|
|
1455
|
+
options.filterRegion(region, after[region] ?? before[region]),
|
|
1456
|
+
);
|
|
1457
|
+
const rows = regions.map((region) => {
|
|
1458
|
+
const baseline = before[region]?.[metric],
|
|
1459
|
+
candidate = after[region]?.[metric];
|
|
1460
|
+
const missing = baseline == null || candidate == null;
|
|
1461
|
+
const delta = missing ? null : candidate - baseline;
|
|
1462
|
+
const value =
|
|
1463
|
+
missing || (options.comparison === "percent" && baseline === 0)
|
|
1464
|
+
? null
|
|
1465
|
+
: options.comparison === "percent"
|
|
1466
|
+
? (delta / baseline) * 100
|
|
1467
|
+
: delta;
|
|
1468
|
+
return {
|
|
1469
|
+
region,
|
|
1470
|
+
file: files[1].label ?? "run",
|
|
1471
|
+
baseline,
|
|
1472
|
+
candidate,
|
|
1473
|
+
value,
|
|
1474
|
+
reason: missing
|
|
1475
|
+
? "Not measured in both runs"
|
|
1476
|
+
: value == null
|
|
1477
|
+
? "Zero baseline: percentage undefined"
|
|
1478
|
+
: "",
|
|
1479
|
+
};
|
|
1480
|
+
});
|
|
1481
|
+
if (options.sortBy !== "name")
|
|
1482
|
+
rows.sort((a, b) => (b.value ?? -Infinity) - (a.value ?? -Infinity));
|
|
1483
|
+
else rows.sort((a, b) => a.region.localeCompare(b.region));
|
|
1484
|
+
const selected = rows.slice(0, options.topN ?? Infinity);
|
|
1485
|
+
const { baseLayout, axis } = palette(options);
|
|
1486
|
+
return {
|
|
1487
|
+
data: [
|
|
1488
|
+
{
|
|
1489
|
+
type: "bar",
|
|
1490
|
+
name: "Change",
|
|
1491
|
+
x: selected.map((row) => row.region),
|
|
1492
|
+
y: selected.map((row) => row.value),
|
|
1493
|
+
customdata: interactionData(selected, (row) => [
|
|
1494
|
+
row.baseline ?? null,
|
|
1495
|
+
row.candidate ?? null,
|
|
1496
|
+
row.reason,
|
|
1497
|
+
]),
|
|
1498
|
+
hovertemplate:
|
|
1499
|
+
"%{x}<br>change: %{y:.6g}<br>baseline: %{customdata[0]}<br>candidate: %{customdata[1]}<br>%{customdata[2]}<extra></extra>",
|
|
1500
|
+
},
|
|
1501
|
+
],
|
|
1502
|
+
diagnostics: selected
|
|
1503
|
+
.filter((row) => row.value == null)
|
|
1504
|
+
.map((row) => ({
|
|
1505
|
+
code: "undefined-comparison",
|
|
1506
|
+
region: row.region,
|
|
1507
|
+
message: row.reason,
|
|
1508
|
+
})),
|
|
1509
|
+
layout: withEmptyState(
|
|
1510
|
+
baseLayout({
|
|
1511
|
+
xaxis: axis({ title: "Region" }),
|
|
1512
|
+
yaxis: axis({
|
|
1513
|
+
title:
|
|
1514
|
+
options.comparison === "percent"
|
|
1515
|
+
? "Change (%)"
|
|
1516
|
+
: `Change in ${SUMMARY_LABELS[metric]}`,
|
|
1517
|
+
}),
|
|
1518
|
+
}),
|
|
1519
|
+
selected.some((row) => row.value != null),
|
|
1520
|
+
),
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
return buildRegionSummaryFigure(payload, {
|
|
1524
|
+
orientation: "v",
|
|
1525
|
+
topN: Infinity,
|
|
1526
|
+
commonRegionsOnly: true,
|
|
1527
|
+
...options,
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
342
1531
|
/** Build a Sankey call graph from either callgraph export shape.
|
|
343
1532
|
*
|
|
344
1533
|
* The compact export collapses repeated invocations into one node per region,
|
|
@@ -347,60 +1536,266 @@ export function buildRegionSummaryFigure(payload, options = {}) {
|
|
|
347
1536
|
* recursion in full.
|
|
348
1537
|
*/
|
|
349
1538
|
export function buildCallgraphFigure(payload, options = {}) {
|
|
1539
|
+
const { baseLayout } = palette(options);
|
|
350
1540
|
const compact = Array.isArray(payload?.regions);
|
|
351
|
-
if (!compact && !Array.isArray(payload?.calls))
|
|
352
|
-
|
|
1541
|
+
if (!compact && !Array.isArray(payload?.calls))
|
|
1542
|
+
throw new TypeError(
|
|
1543
|
+
"Expected a scope-profiler plot-data payload with a regions or calls array.",
|
|
1544
|
+
);
|
|
1545
|
+
validateRecords("callgraph", payload, options);
|
|
1546
|
+
const diagnostics = [];
|
|
1547
|
+
const keep =
|
|
1548
|
+
typeof options.filterRegion === "function"
|
|
1549
|
+
? options.filterRegion
|
|
1550
|
+
: () => true;
|
|
353
1551
|
const weightKey = options.valueKey ?? "total_duration";
|
|
354
1552
|
let nodes, links, unit;
|
|
355
1553
|
if (compact) {
|
|
356
|
-
const regions = payload.regions.filter((region) =>
|
|
357
|
-
|
|
358
|
-
|
|
1554
|
+
const regions = payload.regions.filter((region) =>
|
|
1555
|
+
keep(region.name, region),
|
|
1556
|
+
);
|
|
1557
|
+
const depths = uniqueMap(
|
|
1558
|
+
regions.map((region) => [region.name, region.depth]),
|
|
1559
|
+
"callgraph nodes",
|
|
1560
|
+
);
|
|
1561
|
+
const weights = new Map(
|
|
1562
|
+
regions.map((region) => [region.name, region[weightKey]]),
|
|
1563
|
+
);
|
|
359
1564
|
nodes = regions.map((region) => region.name);
|
|
360
|
-
links = (payload.edges ?? [])
|
|
361
|
-
.
|
|
1565
|
+
links = (payload.edges ?? [])
|
|
1566
|
+
.filter(({ parent, child }) => depths.has(parent) && depths.has(child))
|
|
1567
|
+
.map((edge) => {
|
|
1568
|
+
const { parent, child } = edge;
|
|
1569
|
+
const incoming = (payload.edges ?? []).filter(
|
|
1570
|
+
(other) => other.child === child && other.parent !== child,
|
|
1571
|
+
).length;
|
|
1572
|
+
let value = edge[weightKey] ?? edge.value;
|
|
1573
|
+
if (value == null && incoming === 1) {
|
|
1574
|
+
value = weights.get(child);
|
|
1575
|
+
if (value != null)
|
|
1576
|
+
diagnostics.push({
|
|
1577
|
+
code: "inferred-edge-weight",
|
|
1578
|
+
source: parent,
|
|
1579
|
+
target: child,
|
|
1580
|
+
message:
|
|
1581
|
+
"Weight inferred from child total with one incoming edge.",
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
if (value == null) {
|
|
1585
|
+
diagnostics.push({
|
|
1586
|
+
code: "missing-edge-weight",
|
|
1587
|
+
source: parent,
|
|
1588
|
+
target: child,
|
|
1589
|
+
message:
|
|
1590
|
+
"Edge omitted: no measured weight and child total cannot be attributed.",
|
|
1591
|
+
});
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
if (!Number.isFinite(value) || value < 0)
|
|
1595
|
+
throw new TypeError(
|
|
1596
|
+
`callgraph edge ${parent} -> ${child}: weight must be finite and nonnegative`,
|
|
1597
|
+
);
|
|
1598
|
+
return { source: parent, target: child, value };
|
|
1599
|
+
})
|
|
1600
|
+
.filter(Boolean);
|
|
362
1601
|
unit = weightKey.endsWith("duration") ? " s" : "";
|
|
363
1602
|
} else {
|
|
364
1603
|
const calls = payload.calls.filter((call) => keep(call.name, call));
|
|
365
|
-
const
|
|
1604
|
+
const callKey = (call) =>
|
|
1605
|
+
JSON.stringify([call.file ?? "run", call.rank ?? 0, call.call_id]);
|
|
1606
|
+
const parentKey = (call) =>
|
|
1607
|
+
call.parent_id == null
|
|
1608
|
+
? null
|
|
1609
|
+
: JSON.stringify([call.file ?? "run", call.rank ?? 0, call.parent_id]);
|
|
1610
|
+
validateParents(payload.calls, callKey, parentKey);
|
|
1611
|
+
const byId = new Map(calls.map((call) => [callKey(call), call]));
|
|
366
1612
|
const counts = new Map();
|
|
367
1613
|
for (const call of calls) {
|
|
368
|
-
const parent = byId.get(call
|
|
1614
|
+
const parent = byId.get(parentKey(call));
|
|
369
1615
|
if (!parent || call.depth <= parent.depth) continue;
|
|
370
|
-
const key =
|
|
1616
|
+
const key = JSON.stringify([parent.name, call.name]);
|
|
371
1617
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
372
1618
|
}
|
|
373
1619
|
nodes = [...new Set(calls.map((call) => call.name))];
|
|
374
|
-
links = [...counts.entries()].map(([key, value]) => {
|
|
1620
|
+
links = [...counts.entries()].map(([key, value]) => {
|
|
1621
|
+
const [source, target] = JSON.parse(key);
|
|
1622
|
+
return { source, target, value };
|
|
1623
|
+
});
|
|
375
1624
|
unit = " calls";
|
|
376
1625
|
}
|
|
1626
|
+
const adjacency = new Map(nodes.map((name) => [name, []]));
|
|
1627
|
+
links = links.filter((link) => {
|
|
1628
|
+
const pending = [link.target],
|
|
1629
|
+
visited = new Set();
|
|
1630
|
+
while (pending.length) {
|
|
1631
|
+
const node = pending.pop();
|
|
1632
|
+
if (node === link.source) {
|
|
1633
|
+
diagnostics.push({
|
|
1634
|
+
code: "cycle-edge-omitted",
|
|
1635
|
+
source: link.source,
|
|
1636
|
+
target: link.target,
|
|
1637
|
+
message:
|
|
1638
|
+
"Recursive relationship omitted from the Sankey; use the flame chart for full ancestry.",
|
|
1639
|
+
});
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
if (visited.has(node)) continue;
|
|
1643
|
+
visited.add(node);
|
|
1644
|
+
pending.push(...adjacency.get(node));
|
|
1645
|
+
}
|
|
1646
|
+
adjacency.get(link.source).push(link.target);
|
|
1647
|
+
return true;
|
|
1648
|
+
});
|
|
377
1649
|
const index = new Map(nodes.map((name, position) => [name, position]));
|
|
378
1650
|
const colors = colorMap(nodes, options.colors ?? payload.colors);
|
|
379
|
-
const data = [
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1651
|
+
const data = [
|
|
1652
|
+
{
|
|
1653
|
+
type: "sankey",
|
|
1654
|
+
visible: links.some((link) => link.value > 0),
|
|
1655
|
+
orientation: "h",
|
|
1656
|
+
node: {
|
|
1657
|
+
label: nodes,
|
|
1658
|
+
customdata: interactionData(nodes.map((name) => ({ name }))),
|
|
1659
|
+
color: nodes.map((name) => colors.get(name)),
|
|
1660
|
+
pad: 14,
|
|
1661
|
+
thickness: 16,
|
|
1662
|
+
line: { color: "rgba(0, 0, 0, 0.25)", width: 0.5 },
|
|
1663
|
+
},
|
|
1664
|
+
link: {
|
|
1665
|
+
customdata: links.map((link) => ({
|
|
1666
|
+
identity: {
|
|
1667
|
+
region: link.target,
|
|
1668
|
+
source: link.source,
|
|
1669
|
+
file: null,
|
|
1670
|
+
rank: null,
|
|
1671
|
+
call_id: null,
|
|
1672
|
+
},
|
|
1673
|
+
})),
|
|
1674
|
+
source: links.map((link) => index.get(link.source)),
|
|
1675
|
+
target: links.map((link) => index.get(link.target)),
|
|
1676
|
+
value: links.map((link) => link.value),
|
|
1677
|
+
hovertemplate: `%{source.label} \u2192 %{target.label}<br>%{value:.6g}${unit}<extra></extra>`,
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
];
|
|
1681
|
+
const layout = baseLayout({
|
|
1682
|
+
height: Math.max(320, 26 * nodes.length + 160),
|
|
1683
|
+
margin: { l: 24, r: 24, t: 24, b: 24 },
|
|
1684
|
+
});
|
|
1685
|
+
return {
|
|
1686
|
+
data,
|
|
1687
|
+
layout: withEmptyState(
|
|
1688
|
+
layout,
|
|
1689
|
+
links.some((link) => link.value > 0),
|
|
1690
|
+
),
|
|
1691
|
+
diagnostics,
|
|
1692
|
+
};
|
|
387
1693
|
}
|
|
388
1694
|
|
|
389
1695
|
/** Build a grouped bar chart of one LIKWID hardware-counter metric. */
|
|
390
1696
|
export function buildLikwidFigure(payload, options = {}) {
|
|
391
|
-
const
|
|
1697
|
+
const { baseLayout, axis } = palette(options);
|
|
1698
|
+
const bars = filtered(values(payload, "bars", "likwid"), options);
|
|
392
1699
|
const series = groupBy(bars, (bar) => bar.series);
|
|
393
1700
|
const regions = [...new Set(bars.map((bar) => bar.region))];
|
|
394
1701
|
const colors = colorMap(series.keys(), options.colors ?? payload.colors);
|
|
395
1702
|
const metric = options.metric ?? payload.metric ?? "value";
|
|
396
1703
|
const data = [...series].map(([name, rows]) => {
|
|
397
|
-
const byRegion =
|
|
398
|
-
|
|
1704
|
+
const byRegion = uniqueMap(
|
|
1705
|
+
rows.map((bar) => [bar.region, bar.value]),
|
|
1706
|
+
"likwid",
|
|
1707
|
+
);
|
|
1708
|
+
return {
|
|
1709
|
+
type: "bar",
|
|
1710
|
+
name,
|
|
1711
|
+
x: regions,
|
|
1712
|
+
y: regions.map((region) => byRegion.get(region) ?? null),
|
|
1713
|
+
customdata: interactionData(
|
|
1714
|
+
regions.map((region) => ({
|
|
1715
|
+
...rows.find((row) => row.region === region),
|
|
1716
|
+
region,
|
|
1717
|
+
})),
|
|
1718
|
+
),
|
|
1719
|
+
marker: {
|
|
1720
|
+
color: colors.get(name),
|
|
1721
|
+
line: { color: "rgba(0, 0, 0, 0.22)", width: 0.5 },
|
|
1722
|
+
},
|
|
1723
|
+
hovertemplate: `<b>%{x}</b><br>${label(name)}: %{y:.6g}<extra></extra>`,
|
|
1724
|
+
};
|
|
1725
|
+
});
|
|
1726
|
+
const layout = baseLayout({
|
|
1727
|
+
barmode: "group",
|
|
1728
|
+
height: Math.max(360, 34 * regions.length + 180),
|
|
1729
|
+
showlegend: series.size > 1,
|
|
1730
|
+
xaxis: axis({ tickangle: -35 }),
|
|
1731
|
+
yaxis: axis({
|
|
1732
|
+
title: metric,
|
|
1733
|
+
...(options.logScale ? { type: "log" } : {}),
|
|
1734
|
+
}),
|
|
399
1735
|
});
|
|
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
1736
|
return { data, layout: withEmptyState(layout, bars.length > 0) };
|
|
402
1737
|
}
|
|
403
1738
|
|
|
1739
|
+
/** Build a log-log roofline plot from per-region LIKWID-derived rates. */
|
|
1740
|
+
export function buildRooflineFigure(payload, options = {}) {
|
|
1741
|
+
const { theme, baseLayout, axis } = palette(options);
|
|
1742
|
+
const points = filtered(values(payload, "points", "roofline"), options);
|
|
1743
|
+
const series = groupBy(points, (point) => point.file ?? "run");
|
|
1744
|
+
const colors = colorMap(series.keys(), options.colors ?? payload.colors);
|
|
1745
|
+
const data = [...series].map(([name, rows]) => ({
|
|
1746
|
+
type: "scatter",
|
|
1747
|
+
mode: "markers",
|
|
1748
|
+
name,
|
|
1749
|
+
x: rows.map((point) => point.arithmetic_intensity_flops_per_byte),
|
|
1750
|
+
y: rows.map((point) => point.performance_gflops),
|
|
1751
|
+
marker: { color: colors.get(name), size: 9 },
|
|
1752
|
+
customdata: interactionData(rows, (point) => [
|
|
1753
|
+
label(point.region),
|
|
1754
|
+
point.rank,
|
|
1755
|
+
point.bandwidth_gbs,
|
|
1756
|
+
]),
|
|
1757
|
+
hovertemplate:
|
|
1758
|
+
"<b>%{customdata[0]}</b> (rank %{customdata[1]})" +
|
|
1759
|
+
"<br>intensity: %{x:.6g} FLOP/byte" +
|
|
1760
|
+
"<br>performance: %{y:.6g} GFLOP/s" +
|
|
1761
|
+
"<br>bandwidth: %{customdata[2]:.6g} GB/s<extra></extra>",
|
|
1762
|
+
}));
|
|
1763
|
+
const roof = payload.roofline ?? [];
|
|
1764
|
+
if (!Array.isArray(roof)) throw new TypeError("roofline must be an array.");
|
|
1765
|
+
validateRecords("roofline", {
|
|
1766
|
+
points: roof.map((point) => ({ ...point, region: "ceiling" })),
|
|
1767
|
+
});
|
|
1768
|
+
if (roof.length) {
|
|
1769
|
+
data.push({
|
|
1770
|
+
type: "scatter",
|
|
1771
|
+
mode: "lines",
|
|
1772
|
+
name: payload.empirical_ceilings ? "empirical roof" : "roofline",
|
|
1773
|
+
x: roof.map((point) => point.arithmetic_intensity_flops_per_byte),
|
|
1774
|
+
y: roof.map((point) => point.performance_gflops),
|
|
1775
|
+
// The one line in the package that used to be a fixed near-black, which
|
|
1776
|
+
// is invisible on the dark theme this builder otherwise honours.
|
|
1777
|
+
line: { color: theme.text ?? theme.neutral, width: 2, dash: "dash" },
|
|
1778
|
+
hovertemplate:
|
|
1779
|
+
"intensity: %{x:.6g} FLOP/byte<br>ceiling: %{y:.6g} GFLOP/s<extra></extra>",
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
const layout = baseLayout({
|
|
1783
|
+
title: {
|
|
1784
|
+
text: payload.empirical_ceilings
|
|
1785
|
+
? "Roofline analysis (empirical ceilings)"
|
|
1786
|
+
: "Roofline analysis",
|
|
1787
|
+
...(theme.text ? { font: { color: theme.text } } : {}),
|
|
1788
|
+
},
|
|
1789
|
+
// This is the one figure that carries a title, and baseLayout's 32px top
|
|
1790
|
+
// margin is sized for the seventeen that do not.
|
|
1791
|
+
margin: { l: 100, r: 24, t: 56, b: 64 },
|
|
1792
|
+
xaxis: axis({ title: "Arithmetic intensity [FLOP/byte]", type: "log" }),
|
|
1793
|
+
yaxis: axis({ title: "Attained performance [GFLOP/s]", type: "log" }),
|
|
1794
|
+
showlegend: series.size > 1 || roof.length > 0,
|
|
1795
|
+
});
|
|
1796
|
+
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
1797
|
+
}
|
|
1798
|
+
|
|
404
1799
|
export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
|
|
405
1800
|
export const SUPPORTED_FORMAT_VERSION = 1;
|
|
406
1801
|
|
|
@@ -417,22 +1812,48 @@ export const PLOT_BUILDERS = {
|
|
|
417
1812
|
speedup: buildSpeedupFigure,
|
|
418
1813
|
weak_scaling: buildSpeedupFigure,
|
|
419
1814
|
scaling_efficiency: buildSpeedupFigure,
|
|
1815
|
+
weak_scaling_efficiency: buildSpeedupFigure,
|
|
420
1816
|
rank_heatmap: buildRankHeatmapFigure,
|
|
421
1817
|
histogram: buildHistogramFigure,
|
|
422
1818
|
imbalance: buildImbalanceFigure,
|
|
423
1819
|
likwid: buildLikwidFigure,
|
|
1820
|
+
roofline: buildRooflineFigure,
|
|
424
1821
|
region_statistics: buildRegionSummaryFigure,
|
|
425
1822
|
};
|
|
426
1823
|
|
|
1824
|
+
const PLOT_ARRAYS = {
|
|
1825
|
+
gantt: "intervals",
|
|
1826
|
+
density: "points",
|
|
1827
|
+
flame: "calls",
|
|
1828
|
+
flame_chart: "calls",
|
|
1829
|
+
flame_graph: "calls",
|
|
1830
|
+
durations: "bars",
|
|
1831
|
+
timeseries: "points",
|
|
1832
|
+
speedup: "points",
|
|
1833
|
+
weak_scaling: "points",
|
|
1834
|
+
scaling_efficiency: "points",
|
|
1835
|
+
weak_scaling_efficiency: "points",
|
|
1836
|
+
rank_heatmap: "points",
|
|
1837
|
+
histogram: "bins",
|
|
1838
|
+
imbalance: "points",
|
|
1839
|
+
likwid: "bars",
|
|
1840
|
+
roofline: "points",
|
|
1841
|
+
region_statistics: "files",
|
|
1842
|
+
};
|
|
1843
|
+
|
|
427
1844
|
/** Guess the plot kind of a payload written before the envelope existed. */
|
|
428
1845
|
export function inferPlotKind(payload) {
|
|
429
1846
|
if (!payload || typeof payload !== "object") return undefined;
|
|
430
1847
|
if (Array.isArray(payload.intervals)) return "gantt";
|
|
431
1848
|
if (Array.isArray(payload.bins)) return "histogram";
|
|
432
|
-
if (Array.isArray(payload.files) && payload.files[0]?.region_statistics)
|
|
433
|
-
|
|
434
|
-
if (Array.isArray(payload.
|
|
435
|
-
|
|
1849
|
+
if (Array.isArray(payload.files) && payload.files[0]?.region_statistics)
|
|
1850
|
+
return "region_statistics";
|
|
1851
|
+
if (Array.isArray(payload.regions) && Array.isArray(payload.edges))
|
|
1852
|
+
return "callgraph";
|
|
1853
|
+
if (Array.isArray(payload.bars))
|
|
1854
|
+
return payload.bars[0]?.series != null ? "likwid" : "durations";
|
|
1855
|
+
if (Array.isArray(payload.calls))
|
|
1856
|
+
return payload.calls[0]?.parent_id !== undefined ? "callgraph" : "flame";
|
|
436
1857
|
const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
|
|
437
1858
|
if (!point) return undefined;
|
|
438
1859
|
if (point.bin_start_seconds != null) return "density";
|
|
@@ -441,10 +1862,66 @@ export function inferPlotKind(payload) {
|
|
|
441
1862
|
if (point.speedup != null) return "speedup";
|
|
442
1863
|
if (point.normalized_runtime != null) return "weak_scaling";
|
|
443
1864
|
if (point.efficiency != null) return "scaling_efficiency";
|
|
1865
|
+
if (point.arithmetic_intensity_flops_per_byte != null) return "roofline";
|
|
444
1866
|
if (point.rank != null) return "rank_heatmap";
|
|
445
1867
|
return undefined;
|
|
446
1868
|
}
|
|
447
1869
|
|
|
1870
|
+
/** Validate a plot-data envelope and return its resolved plot kind.
|
|
1871
|
+
*
|
|
1872
|
+
* The validator deliberately requires only the array each builder consumes:
|
|
1873
|
+
* exporter versions may add fields without breaking existing dashboards.
|
|
1874
|
+
*/
|
|
1875
|
+
export function validatePlotData(payload, options = {}) {
|
|
1876
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
1877
|
+
throw new TypeError("plot-data must be an object.");
|
|
1878
|
+
if (payload.format != null && payload.format !== PLOT_DATA_FORMAT)
|
|
1879
|
+
throw new TypeError(
|
|
1880
|
+
`Expected a ${PLOT_DATA_FORMAT} document, got ${JSON.stringify(payload.format)}.`,
|
|
1881
|
+
);
|
|
1882
|
+
if (
|
|
1883
|
+
Object.hasOwn(payload, "format_version") &&
|
|
1884
|
+
(!Number.isInteger(payload.format_version) || payload.format_version < 1)
|
|
1885
|
+
)
|
|
1886
|
+
throw new TypeError("format_version must be a positive integer.");
|
|
1887
|
+
if (
|
|
1888
|
+
typeof payload.format_version === "number" &&
|
|
1889
|
+
payload.format_version > SUPPORTED_FORMAT_VERSION
|
|
1890
|
+
)
|
|
1891
|
+
throw new TypeError(
|
|
1892
|
+
`Plot-data format version ${payload.format_version} is newer than this package supports (${SUPPORTED_FORMAT_VERSION}); upgrade @scope-profiler/plotly.`,
|
|
1893
|
+
);
|
|
1894
|
+
const kind = options.plot ?? payload.plot ?? inferPlotKind(payload);
|
|
1895
|
+
if (!kind || !Object.hasOwn(PLOT_BUILDERS, kind))
|
|
1896
|
+
throw new TypeError(
|
|
1897
|
+
kind
|
|
1898
|
+
? `No figure builder for plot kind ${JSON.stringify(kind)}.`
|
|
1899
|
+
: "Could not determine the plot kind; pass options.plot.",
|
|
1900
|
+
);
|
|
1901
|
+
if (kind === "callgraph") {
|
|
1902
|
+
if (!Array.isArray(payload.calls) && !Array.isArray(payload.regions))
|
|
1903
|
+
throw new TypeError(
|
|
1904
|
+
'Plot kind "callgraph" requires calls or regions data.',
|
|
1905
|
+
);
|
|
1906
|
+
} else if (!Array.isArray(payload[PLOT_ARRAYS[kind]])) {
|
|
1907
|
+
throw new TypeError(
|
|
1908
|
+
`Plot kind ${JSON.stringify(kind)} requires a ${PLOT_ARRAYS[kind]} array.`,
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
const recordKind = kind.startsWith("flame")
|
|
1912
|
+
? "flame"
|
|
1913
|
+
: Object.hasOwn(SCALING_KINDS, kind)
|
|
1914
|
+
? "scaling"
|
|
1915
|
+
: kind;
|
|
1916
|
+
validateRecords(recordKind, payload, {
|
|
1917
|
+
...options,
|
|
1918
|
+
...(recordKind === "scaling"
|
|
1919
|
+
? { yField: scalingKind(payload, { ...options, plot: kind }).yKey }
|
|
1920
|
+
: {}),
|
|
1921
|
+
});
|
|
1922
|
+
return kind;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
448
1925
|
/** Build the right figure for any plot-data document, without naming a builder.
|
|
449
1926
|
*
|
|
450
1927
|
* Dispatches on the document's own `plot` field, falling back to the payload
|
|
@@ -452,17 +1929,57 @@ export function inferPlotKind(payload) {
|
|
|
452
1929
|
* kind.
|
|
453
1930
|
*/
|
|
454
1931
|
export function buildFigure(payload, options = {}) {
|
|
455
|
-
|
|
456
|
-
const
|
|
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.");
|
|
1932
|
+
const kind = validatePlotData(payload, options);
|
|
1933
|
+
const builder = PLOT_BUILDERS[kind];
|
|
461
1934
|
return builder(payload, { plot: kind, ...options });
|
|
462
1935
|
}
|
|
463
1936
|
|
|
1937
|
+
const RENDER_CONFIG = { responsive: true, displaylogo: false };
|
|
1938
|
+
|
|
464
1939
|
/** Render a figure with any Plotly-compatible bundle. */
|
|
465
1940
|
export function renderFigure(plotly, element, figure, config = {}) {
|
|
466
|
-
if (!plotly || typeof plotly.newPlot !== "function")
|
|
467
|
-
|
|
1941
|
+
if (!plotly || typeof plotly.newPlot !== "function")
|
|
1942
|
+
throw new TypeError(
|
|
1943
|
+
"renderFigure requires a Plotly-compatible object with newPlot().",
|
|
1944
|
+
);
|
|
1945
|
+
return plotly.newPlot(element, figure.data, figure.layout, {
|
|
1946
|
+
...RENDER_CONFIG,
|
|
1947
|
+
...config,
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/** Redraw a figure into an element that already holds one.
|
|
1952
|
+
*
|
|
1953
|
+
* `renderFigure` builds the plot from scratch, which throws away the viewer's
|
|
1954
|
+
* zoom and pan. A theme toggle, a changed filter or a new metric rebuilds the
|
|
1955
|
+
* figure but should not move the view, so route those through here: it uses
|
|
1956
|
+
* Plotly's `react`, and falls back to `newPlot` for a bundle without one.
|
|
1957
|
+
*/
|
|
1958
|
+
export function updateFigure(plotly, element, figure, config = {}) {
|
|
1959
|
+
const draw =
|
|
1960
|
+
plotly && typeof plotly.react === "function"
|
|
1961
|
+
? plotly.react
|
|
1962
|
+
: plotly?.newPlot;
|
|
1963
|
+
if (typeof draw !== "function")
|
|
1964
|
+
throw new TypeError(
|
|
1965
|
+
"updateFigure requires a Plotly-compatible object with react() or newPlot().",
|
|
1966
|
+
);
|
|
1967
|
+
return draw.call(plotly, element, figure.data, figure.layout, {
|
|
1968
|
+
...RENDER_CONFIG,
|
|
1969
|
+
...config,
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/** Release Plotly event handlers and rendering resources on unmount. */
|
|
1974
|
+
export function disposeFigure(plotly, element) {
|
|
1975
|
+
if (typeof plotly?.purge !== "function")
|
|
1976
|
+
throw new TypeError("disposeFigure requires purge().");
|
|
1977
|
+
return plotly.purge(element);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/** Isolated defaults for a dashboard, without changing the global theme. */
|
|
1981
|
+
export function createFigureBuilder(defaults = {}) {
|
|
1982
|
+
const snapshot = mergeLayout({}, { theme: resolveTheme(), ...defaults });
|
|
1983
|
+
return (payload, options = {}) =>
|
|
1984
|
+
buildFigure(payload, mergeLayout(snapshot, options));
|
|
468
1985
|
}
|