@scope-profiler/plotly 0.1.1 → 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 +198 -3
- package/package.json +49 -6
- package/src/dashboard.d.ts +51 -0
- package/src/dashboard.js +177 -0
- package/src/index.d.ts +383 -12
- package/src/index.js +1896 -90
package/src/index.js
CHANGED
|
@@ -1,179 +1,1985 @@
|
|
|
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);
|
|
22
347
|
return {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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()) {
|
|
356
|
+
return {
|
|
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;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// One pass instead of a filter per series. A large trace has both many rows and
|
|
450
|
+
// many regions, so filtering the whole array once per series is quadratic: on
|
|
451
|
+
// 200k intervals over 400 regions that is the difference between ~690 ms and
|
|
452
|
+
// ~8 ms. Map preserves first-appearance order, which is the order the series
|
|
453
|
+
// are drawn and listed in.
|
|
454
|
+
function groupBy(rows, key) {
|
|
455
|
+
const groups = new Map();
|
|
456
|
+
for (const row of rows) {
|
|
457
|
+
const name = key(row);
|
|
458
|
+
const bucket = groups.get(name);
|
|
459
|
+
if (bucket) bucket.push(row);
|
|
460
|
+
else groups.set(name, [row]);
|
|
461
|
+
}
|
|
462
|
+
return groups;
|
|
45
463
|
}
|
|
46
464
|
|
|
47
|
-
|
|
465
|
+
// Several payloads carry rows from more than one run. Dropping that column
|
|
466
|
+
// merges runs into one series -- silently, and wrongly -- so every builder that
|
|
467
|
+
// can see two runs keys its series by file as well, and says so in the label.
|
|
468
|
+
function runAware(rows) {
|
|
469
|
+
const files = new Set(rows.map((row) => row.file ?? "run"));
|
|
470
|
+
const multi = files.size > 1;
|
|
471
|
+
return {
|
|
472
|
+
multi,
|
|
473
|
+
files: [...files],
|
|
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,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Region colour stays with the region across runs (and honours the payload's
|
|
482
|
+
// own colours), so a run is told apart by marker shape or bar pattern instead.
|
|
483
|
+
const FILE_SYMBOLS = ["circle", "square", "diamond", "triangle-up", "cross"];
|
|
484
|
+
const FILE_PATTERNS = ["", "/", "\\", "x", "-"];
|
|
485
|
+
|
|
486
|
+
/** Build a multi-run, multi-rank timeline: a lane per region and rank.
|
|
487
|
+
*
|
|
488
|
+
* A lane per rank alone cannot show a nested profile: every region of a rank
|
|
489
|
+
* lands on one row, and the outermost region -- the session, typically -- is
|
|
490
|
+
* drawn over everything inside it. One lane per region and rank is also what
|
|
491
|
+
* `scope-profiler plot gantt` draws, so the two agree. Pass
|
|
492
|
+
* `{ laneBy: "rank" }` for the compact one-row-per-rank view, which suits a
|
|
493
|
+
* flat profile compared across many ranks.
|
|
494
|
+
*/
|
|
48
495
|
export function buildGanttFigure(payload, options = {}) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
496
|
+
const { baseLayout, axis } = palette(options);
|
|
497
|
+
const intervals = filtered(values(payload, "intervals", "gantt"), options);
|
|
498
|
+
const byRegion = groupBy(intervals, (row) => row.region);
|
|
499
|
+
const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
|
|
500
|
+
const multi = new Set(intervals.map((row) => row.file ?? "run")).size > 1;
|
|
501
|
+
const rankLane = (row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`;
|
|
502
|
+
// Matching `plot gantt`'s own lane label, extended by the run only when the
|
|
503
|
+
// payload holds more than one.
|
|
504
|
+
const regionLane = (row) =>
|
|
505
|
+
`${multi ? `${row.file ?? "run"} / ` : ""}${row.region} (rank ${row.rank ?? 0})`;
|
|
506
|
+
const laneOf = options.laneBy === "rank" ? rankLane : regionLane;
|
|
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]));
|
|
513
|
+
const data = [...byRegion].map(([region, rows]) => {
|
|
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>`,
|
|
61
533
|
};
|
|
62
534
|
});
|
|
63
|
-
const
|
|
535
|
+
const byRank = options.laneBy === "rank";
|
|
536
|
+
const perLane = byRank ? 48 : 26;
|
|
537
|
+
// Region lanes read bottom-up, so the first region -- the enclosing one --
|
|
538
|
+
// sits at the bottom, as `scope-profiler plot gantt` draws it. Rank lanes
|
|
539
|
+
// keep rank 0 on top, like the rank heatmap.
|
|
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
|
+
});
|
|
64
553
|
return { data, layout: withEmptyState(layout, intervals.length > 0) };
|
|
65
554
|
}
|
|
66
555
|
|
|
67
556
|
/** Build an icicle flame chart using scope-profiler's explicit call IDs. */
|
|
68
557
|
export function buildFlameFigure(payload, options = {}) {
|
|
69
|
-
const
|
|
558
|
+
const { theme, baseLayout } = palette(options);
|
|
559
|
+
const allCalls = values(payload, "calls", "flame");
|
|
560
|
+
const calls = filtered(allCalls, options);
|
|
70
561
|
const regions = [...new Set(calls.map((call) => call.region))];
|
|
71
562
|
const colors = colorMap(regions, options.colors ?? payload.colors);
|
|
72
563
|
const root = "scope-profiler-root";
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
572
|
+
// A filter can remove a call whose children survive; re-parent each survivor
|
|
573
|
+
// onto its nearest surviving ancestor so the icicle stays a single tree
|
|
574
|
+
// instead of silently dropping the orphans.
|
|
575
|
+
const byKey = validateParents(allCalls, callKey, parentKey);
|
|
576
|
+
const kept = new Set(calls.map(callKey));
|
|
577
|
+
const anchor = (call) => {
|
|
578
|
+
let key = parentKey(call);
|
|
579
|
+
while (key != null && !kept.has(key))
|
|
580
|
+
key = byKey.has(key) ? parentKey(byKey.get(key)) : null;
|
|
581
|
+
return key ?? root;
|
|
582
|
+
};
|
|
583
|
+
const anchors = calls.map(anchor);
|
|
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"];
|
|
594
|
+
calls.forEach((call, index) => {
|
|
595
|
+
ids.push(callKey(call));
|
|
596
|
+
labels.push(call.region);
|
|
597
|
+
parents.push(anchors[index]);
|
|
80
598
|
markerColors.push(colors.get(call.region));
|
|
81
|
-
hovertext.push(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 },
|
|
606
|
+
});
|
|
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
|
+
};
|
|
85
628
|
}
|
|
86
629
|
|
|
87
630
|
export function buildDurationsFigure(payload, options = {}) {
|
|
88
|
-
const
|
|
89
|
-
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
|
+
);
|
|
90
640
|
// A stacked-children export is already decomposed into segments. Preserve
|
|
91
641
|
// that decomposition instead of letting duplicate region rows overwrite.
|
|
92
642
|
const stacked = bars.some((bar) => bar.segment != null);
|
|
93
|
-
const groups =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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);
|
|
662
|
+
const data = [...groups].map(([group, rows]) => {
|
|
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)` }),
|
|
100
691
|
});
|
|
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 });
|
|
102
692
|
return { data, layout: withEmptyState(layout, bars.length > 0) };
|
|
103
693
|
}
|
|
104
694
|
|
|
695
|
+
// The three scaling exports differ only in the y column they carry and the
|
|
696
|
+
// shape of their ideal line, so one builder serves all of them.
|
|
697
|
+
const SCALING_KINDS = {
|
|
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
|
+
},
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
function scalingKind(payload, options) {
|
|
733
|
+
const named = options.plot ?? payload?.plot;
|
|
734
|
+
if (named && SCALING_KINDS[named]) return SCALING_KINDS[named];
|
|
735
|
+
if (options.yField) {
|
|
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
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const row = payload?.points?.[0];
|
|
748
|
+
return (
|
|
749
|
+
(row &&
|
|
750
|
+
Object.values(SCALING_KINDS).find((kind) => row[kind.yKey] != null)) ??
|
|
751
|
+
SCALING_KINDS.speedup
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Build a scaling curve: speedup, weak scaling, or parallel efficiency. */
|
|
105
756
|
export function buildSpeedupFigure(payload, options = {}) {
|
|
757
|
+
const { theme, baseLayout, axis } = palette(options);
|
|
758
|
+
const kind = scalingKind(payload, options);
|
|
106
759
|
const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
|
|
107
|
-
const points = filtered(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
760
|
+
const points = filtered(
|
|
761
|
+
values(payload, "points", "scaling", { ...options, yField: kind.yKey }),
|
|
762
|
+
options,
|
|
763
|
+
);
|
|
764
|
+
const byRegion = groupBy(points, (point) => point.region);
|
|
765
|
+
const colors = colorMap(byRegion.keys(), options.colors ?? payload.colors);
|
|
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
|
+
);
|
|
772
|
+
const order = new Map(xValues.map((value, position) => [value, position]));
|
|
111
773
|
const numeric = xValues.every((value) => typeof value === "number");
|
|
112
|
-
const data =
|
|
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
|
+
});
|
|
113
790
|
const baseline = payload.options?.baseline ?? xValues[0];
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
+
});
|
|
116
817
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
117
818
|
}
|
|
118
819
|
|
|
820
|
+
/** Build a weak-scaling curve (runtime normalized to the baseline scale). */
|
|
821
|
+
export function buildWeakScalingFigure(payload, options = {}) {
|
|
822
|
+
return buildSpeedupFigure(payload, { ...options, plot: "weak_scaling" });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Build a parallel-efficiency curve (measured speedup over ideal speedup). */
|
|
826
|
+
export function buildScalingEfficiencyFigure(payload, options = {}) {
|
|
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
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
119
846
|
/** Build mean call duration over time, one trace per region. */
|
|
120
847
|
export function buildDurationTimeseriesFigure(payload, options = {}) {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
848
|
+
const { baseLayout, axis } = palette(options);
|
|
849
|
+
const points = filtered(values(payload, "points", "timeseries"), options);
|
|
850
|
+
const runs = runAware(points);
|
|
851
|
+
const colors = colorMap(
|
|
852
|
+
points.map((point) => point.region),
|
|
853
|
+
options.colors ?? payload.colors,
|
|
854
|
+
);
|
|
855
|
+
const series = groupBy(points, runs.key);
|
|
856
|
+
const data = [...series].map(([, unsorted]) => {
|
|
857
|
+
const rows = [...unsorted].sort((a, b) => a.time_seconds - b.time_seconds);
|
|
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)" }),
|
|
127
933
|
});
|
|
128
|
-
const layout = baseLayout({ height: 420, showlegend: regions.length > 1, xaxis: axis({ title: "Time (s)" }), yaxis: axis({ title: "Mean call duration (s)" }), ...options.layout });
|
|
129
934
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
130
935
|
}
|
|
131
936
|
|
|
132
937
|
/** Build duration distributions from histogram bin records. */
|
|
133
938
|
export function buildHistogramFigure(payload, options = {}) {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
939
|
+
const { baseLayout, axis } = palette(options);
|
|
940
|
+
const bins = filtered(values(payload, "bins", "histogram"), options);
|
|
941
|
+
const runs = runAware(bins);
|
|
942
|
+
const colors = colorMap(
|
|
943
|
+
bins.map((bin) => bin.region),
|
|
944
|
+
options.colors ?? payload.colors,
|
|
945
|
+
);
|
|
946
|
+
const series = groupBy(bins, runs.key);
|
|
947
|
+
const data = [...series].map(([, unsorted]) => {
|
|
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" }),
|
|
140
978
|
});
|
|
141
|
-
const layout = baseLayout({ barmode: "overlay", height: 400, showlegend: regions.length > 1, xaxis: axis({ title: "Call duration (s)" }), yaxis: axis({ title: "Calls" }), ...options.layout });
|
|
142
979
|
return { data, layout: withEmptyState(layout, bins.length > 0) };
|
|
143
980
|
}
|
|
144
981
|
|
|
145
982
|
/** Build a rank × region heatmap from duration records. */
|
|
146
983
|
export function buildRankHeatmapFigure(payload, options = {}) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
984
|
+
const { baseLayout, axis } = palette(options);
|
|
985
|
+
const points = filtered(
|
|
986
|
+
values(payload, "points", "rank_heatmap", options),
|
|
987
|
+
options,
|
|
988
|
+
);
|
|
989
|
+
const multi = new Set(points.map((point) => point.file ?? "run")).size > 1;
|
|
990
|
+
// A lane per run and rank. Keying cells by rank alone silently let a second
|
|
991
|
+
// run overwrite the first, showing one run's numbers under both labels.
|
|
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
|
+
});
|
|
155
1046
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
156
1047
|
}
|
|
157
1048
|
|
|
158
1049
|
/** Build per-rank duration lines, with a dashed rank mean for each region. */
|
|
159
1050
|
export function buildImbalanceFigure(payload, options = {}) {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
1051
|
+
const { baseLayout, axis } = palette(options);
|
|
1052
|
+
const points = filtered(values(payload, "points", "imbalance"), options);
|
|
1053
|
+
const runs = runAware(points);
|
|
1054
|
+
const colors = colorMap(
|
|
1055
|
+
points.map((point) => point.region),
|
|
1056
|
+
options.colors ?? payload.colors,
|
|
1057
|
+
);
|
|
1058
|
+
// The mean is computed per run, so a run gets its own line and its own mean;
|
|
1059
|
+
// pooling them drew one zig-zagging series that revisited every rank.
|
|
1060
|
+
const series = groupBy(points, runs.key);
|
|
1061
|
+
const data = [...series].flatMap(([, unsorted]) => {
|
|
1062
|
+
const rows = [...unsorted].sort((a, b) => a.rank - b.rank);
|
|
1063
|
+
const region = rows[0].region,
|
|
1064
|
+
name = runs.label(rows[0]);
|
|
165
1065
|
const color = colors.get(region);
|
|
1066
|
+
const symbol =
|
|
1067
|
+
FILE_SYMBOLS[
|
|
1068
|
+
runs.files.indexOf(rows[0].file ?? "run") % FILE_SYMBOLS.length
|
|
1069
|
+
];
|
|
166
1070
|
return [
|
|
167
|
-
{
|
|
168
|
-
|
|
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
|
+
},
|
|
169
1095
|
];
|
|
170
1096
|
});
|
|
171
|
-
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
|
+
});
|
|
1103
|
+
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Build a timeline-occupancy heatmap from binned density records. */
|
|
1107
|
+
export function buildDensityFigure(payload, options = {}) {
|
|
1108
|
+
const { baseLayout, axis } = palette(options);
|
|
1109
|
+
const points = filtered(values(payload, "points", "density"), options);
|
|
1110
|
+
const lane = (point) => `${point.file ?? "run"} / ${point.region}`;
|
|
1111
|
+
const lanes = [...new Set(points.map(lane))];
|
|
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);
|
|
1119
|
+
// Occupancy is the share of the bin the region was inside, which compares
|
|
1120
|
+
// across runs of different length; raw seconds stay available via valueKey.
|
|
1121
|
+
const asFraction = (options.valueKey ?? "occupancy") === "occupancy";
|
|
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
|
+
};
|
|
1132
|
+
const span = (point) => point.bin_end_seconds - point.bin_start_seconds;
|
|
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
|
+
});
|
|
1268
|
+
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
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
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Build a ranked region bar chart from a region_statistics document. */
|
|
1316
|
+
export function buildRegionSummaryFigure(payload, options = {}) {
|
|
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
|
+
);
|
|
1337
|
+
const limit = options.topN ?? 20;
|
|
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;
|
|
1351
|
+
const totals = new Map();
|
|
1352
|
+
for (const file of files) {
|
|
1353
|
+
for (const [region, stats] of Object.entries(
|
|
1354
|
+
file.region_statistics ?? {},
|
|
1355
|
+
)) {
|
|
1356
|
+
if (!keep(region, stats) || !common(region)) continue;
|
|
1357
|
+
totals.set(region, (totals.get(region) ?? 0) + (stats[metric] ?? 0));
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
// Rank by the pooled metric so the slowest regions lead, then keep the head
|
|
1361
|
+
// of the list: a long run has more regions than a bar chart can carry.
|
|
1362
|
+
const regions = [...totals.entries()]
|
|
1363
|
+
.sort((a, b) => b[1] - a[1])
|
|
1364
|
+
.slice(0, limit)
|
|
1365
|
+
.map(([region]) => region);
|
|
1366
|
+
const labels = files.map((file) => file.label ?? "run");
|
|
1367
|
+
const colors = colorMap(labels, options.colors ?? payload.colors);
|
|
1368
|
+
const unit = metric === "count" ? "" : " s";
|
|
1369
|
+
const values_ = (stats) =>
|
|
1370
|
+
regions.map((region) => stats[region]?.[metric] ?? null);
|
|
1371
|
+
const data = files.map((file, index) => {
|
|
1372
|
+
const stats = file.region_statistics ?? {};
|
|
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,
|
|
1417
|
+
});
|
|
1418
|
+
return { data, layout: withEmptyState(layout, regions.length > 0) };
|
|
1419
|
+
}
|
|
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
|
+
|
|
1531
|
+
/** Build a Sankey call graph from either callgraph export shape.
|
|
1532
|
+
*
|
|
1533
|
+
* The compact export collapses repeated invocations into one node per region,
|
|
1534
|
+
* which can turn recursion into a cycle; a Sankey cannot draw one, so links
|
|
1535
|
+
* that do not increase call depth are dropped. Use the flame chart to see
|
|
1536
|
+
* recursion in full.
|
|
1537
|
+
*/
|
|
1538
|
+
export function buildCallgraphFigure(payload, options = {}) {
|
|
1539
|
+
const { baseLayout } = palette(options);
|
|
1540
|
+
const compact = Array.isArray(payload?.regions);
|
|
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;
|
|
1551
|
+
const weightKey = options.valueKey ?? "total_duration";
|
|
1552
|
+
let nodes, links, unit;
|
|
1553
|
+
if (compact) {
|
|
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
|
+
);
|
|
1564
|
+
nodes = regions.map((region) => region.name);
|
|
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);
|
|
1601
|
+
unit = weightKey.endsWith("duration") ? " s" : "";
|
|
1602
|
+
} else {
|
|
1603
|
+
const calls = payload.calls.filter((call) => keep(call.name, call));
|
|
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]));
|
|
1612
|
+
const counts = new Map();
|
|
1613
|
+
for (const call of calls) {
|
|
1614
|
+
const parent = byId.get(parentKey(call));
|
|
1615
|
+
if (!parent || call.depth <= parent.depth) continue;
|
|
1616
|
+
const key = JSON.stringify([parent.name, call.name]);
|
|
1617
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1618
|
+
}
|
|
1619
|
+
nodes = [...new Set(calls.map((call) => call.name))];
|
|
1620
|
+
links = [...counts.entries()].map(([key, value]) => {
|
|
1621
|
+
const [source, target] = JSON.parse(key);
|
|
1622
|
+
return { source, target, value };
|
|
1623
|
+
});
|
|
1624
|
+
unit = " calls";
|
|
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
|
+
});
|
|
1649
|
+
const index = new Map(nodes.map((name, position) => [name, position]));
|
|
1650
|
+
const colors = colorMap(nodes, options.colors ?? payload.colors);
|
|
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
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Build a grouped bar chart of one LIKWID hardware-counter metric. */
|
|
1696
|
+
export function buildLikwidFigure(payload, options = {}) {
|
|
1697
|
+
const { baseLayout, axis } = palette(options);
|
|
1698
|
+
const bars = filtered(values(payload, "bars", "likwid"), options);
|
|
1699
|
+
const series = groupBy(bars, (bar) => bar.series);
|
|
1700
|
+
const regions = [...new Set(bars.map((bar) => bar.region))];
|
|
1701
|
+
const colors = colorMap(series.keys(), options.colors ?? payload.colors);
|
|
1702
|
+
const metric = options.metric ?? payload.metric ?? "value";
|
|
1703
|
+
const data = [...series].map(([name, rows]) => {
|
|
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
|
+
}),
|
|
1735
|
+
});
|
|
1736
|
+
return { data, layout: withEmptyState(layout, bars.length > 0) };
|
|
1737
|
+
}
|
|
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
|
+
});
|
|
172
1796
|
return { data, layout: withEmptyState(layout, points.length > 0) };
|
|
173
1797
|
}
|
|
174
1798
|
|
|
1799
|
+
export const PLOT_DATA_FORMAT = "scope-profiler-plot-data";
|
|
1800
|
+
export const SUPPORTED_FORMAT_VERSION = 1;
|
|
1801
|
+
|
|
1802
|
+
/** Builder for each `plot` kind written by `export plot-data --format json`. */
|
|
1803
|
+
export const PLOT_BUILDERS = {
|
|
1804
|
+
gantt: buildGanttFigure,
|
|
1805
|
+
density: buildDensityFigure,
|
|
1806
|
+
flame: buildFlameFigure,
|
|
1807
|
+
flame_chart: buildFlameFigure,
|
|
1808
|
+
flame_graph: buildFlameFigure,
|
|
1809
|
+
callgraph: buildCallgraphFigure,
|
|
1810
|
+
durations: buildDurationsFigure,
|
|
1811
|
+
timeseries: buildDurationTimeseriesFigure,
|
|
1812
|
+
speedup: buildSpeedupFigure,
|
|
1813
|
+
weak_scaling: buildSpeedupFigure,
|
|
1814
|
+
scaling_efficiency: buildSpeedupFigure,
|
|
1815
|
+
weak_scaling_efficiency: buildSpeedupFigure,
|
|
1816
|
+
rank_heatmap: buildRankHeatmapFigure,
|
|
1817
|
+
histogram: buildHistogramFigure,
|
|
1818
|
+
imbalance: buildImbalanceFigure,
|
|
1819
|
+
likwid: buildLikwidFigure,
|
|
1820
|
+
roofline: buildRooflineFigure,
|
|
1821
|
+
region_statistics: buildRegionSummaryFigure,
|
|
1822
|
+
};
|
|
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
|
+
|
|
1844
|
+
/** Guess the plot kind of a payload written before the envelope existed. */
|
|
1845
|
+
export function inferPlotKind(payload) {
|
|
1846
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
1847
|
+
if (Array.isArray(payload.intervals)) return "gantt";
|
|
1848
|
+
if (Array.isArray(payload.bins)) return "histogram";
|
|
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";
|
|
1857
|
+
const point = Array.isArray(payload.points) ? payload.points[0] : undefined;
|
|
1858
|
+
if (!point) return undefined;
|
|
1859
|
+
if (point.bin_start_seconds != null) return "density";
|
|
1860
|
+
if (point.mean_duration_seconds != null) return "timeseries";
|
|
1861
|
+
if (point.mean_over_ranks_seconds != null) return "imbalance";
|
|
1862
|
+
if (point.speedup != null) return "speedup";
|
|
1863
|
+
if (point.normalized_runtime != null) return "weak_scaling";
|
|
1864
|
+
if (point.efficiency != null) return "scaling_efficiency";
|
|
1865
|
+
if (point.arithmetic_intensity_flops_per_byte != null) return "roofline";
|
|
1866
|
+
if (point.rank != null) return "rank_heatmap";
|
|
1867
|
+
return undefined;
|
|
1868
|
+
}
|
|
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
|
+
|
|
1925
|
+
/** Build the right figure for any plot-data document, without naming a builder.
|
|
1926
|
+
*
|
|
1927
|
+
* Dispatches on the document's own `plot` field, falling back to the payload
|
|
1928
|
+
* shape for files written before scope-profiler stamped the envelope on every
|
|
1929
|
+
* kind.
|
|
1930
|
+
*/
|
|
1931
|
+
export function buildFigure(payload, options = {}) {
|
|
1932
|
+
const kind = validatePlotData(payload, options);
|
|
1933
|
+
const builder = PLOT_BUILDERS[kind];
|
|
1934
|
+
return builder(payload, { plot: kind, ...options });
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const RENDER_CONFIG = { responsive: true, displaylogo: false };
|
|
1938
|
+
|
|
175
1939
|
/** Render a figure with any Plotly-compatible bundle. */
|
|
176
1940
|
export function renderFigure(plotly, element, figure, config = {}) {
|
|
177
|
-
if (!plotly || typeof plotly.newPlot !== "function")
|
|
178
|
-
|
|
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));
|
|
179
1985
|
}
|