browsertime 27.5.0 → 28.1.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 +34 -2
- package/bin/browsertime.js +28 -4
- package/browserscripts/pageinfo/loaf.js +15 -0
- package/browserscripts/pageinfo/loafSummary.js +24 -0
- package/lib/chrome/coverage.js +89 -19
- package/lib/chrome/mediawikiResourceLoader.js +225 -0
- package/lib/chrome/parseCpuTrace.js +87 -2
- package/lib/chrome/trace/blocking-time.js +189 -0
- package/lib/chrome/trace/domain-breakdown.js +86 -0
- package/lib/chrome/trace/frame-stability.js +135 -0
- package/lib/chrome/trace/function-costs.js +281 -0
- package/lib/chrome/trace/index.js +7 -0
- package/lib/chrome/trace/module-costs.js +137 -0
- package/lib/chrome/trace/non-composited-animations.js +50 -5
- package/lib/chrome/trace/selector-stats.js +110 -0
- package/lib/chrome/trace/style-invalidations.js +162 -0
- package/lib/chrome/trace/task-groups.js +28 -2
- package/lib/chrome/trace/timer-costs.js +183 -0
- package/lib/chrome/webdriver/chromium.js +128 -0
- package/lib/chrome/webdriver/traceUtilities.js +11 -1
- package/lib/core/engine/collector.js +12 -0
- package/lib/core/engine/index.js +6 -0
- package/lib/support/cli.js +1 -1
- package/lib/support/har/index.js +44 -1
- package/package.json +4 -4
- package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
- package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
- package/types/chrome/parseCpuTrace.d.ts +1 -26
- package/types/chrome/parseCpuTrace.d.ts.map +1 -1
- package/types/chrome/trace/blocking-time.d.ts +7 -0
- package/types/chrome/trace/blocking-time.d.ts.map +1 -0
- package/types/chrome/trace/domain-breakdown.d.ts +11 -0
- package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
- package/types/chrome/trace/frame-stability.d.ts +11 -0
- package/types/chrome/trace/frame-stability.d.ts.map +1 -0
- package/types/chrome/trace/function-costs.d.ts +6 -0
- package/types/chrome/trace/function-costs.d.ts.map +1 -0
- package/types/chrome/trace/index.d.ts +7 -0
- package/types/chrome/trace/index.d.ts.map +1 -1
- package/types/chrome/trace/module-costs.d.ts +20 -0
- package/types/chrome/trace/module-costs.d.ts.map +1 -0
- package/types/chrome/trace/non-composited-animations.d.ts +1 -25
- package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
- package/types/chrome/trace/selector-stats.d.ts +8 -0
- package/types/chrome/trace/selector-stats.d.ts.map +1 -0
- package/types/chrome/trace/style-invalidations.d.ts +9 -0
- package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
- package/types/chrome/trace/task-groups.d.ts.map +1 -1
- package/types/chrome/trace/timer-costs.d.ts +10 -0
- package/types/chrome/trace/timer-costs.d.ts.map +1 -0
- package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-function JS cost from the V8 sampling profiler
|
|
3
|
+
* (disabled-by-default-v8.cpu_profiler, enabled on --enableProfileRun).
|
|
4
|
+
* The task-based analyses (scriptCosts, moduleCosts) attribute
|
|
5
|
+
* main-thread time to a URL or module; the sampled stacks answer the
|
|
6
|
+
* next question — which function inside that script the time was
|
|
7
|
+
* spent in.
|
|
8
|
+
*
|
|
9
|
+
* The category emits one Profile event per profiled V8 thread and a
|
|
10
|
+
* stream of ProfileChunk events carrying incremental call-tree nodes,
|
|
11
|
+
* sampled node ids and µs deltas between samples. Only the profile
|
|
12
|
+
* started on the inspected tab's main thread is read: the Profile
|
|
13
|
+
* event is emitted on the profiled thread, while its chunks arrive on
|
|
14
|
+
* V8's sampling thread and are tied back via the shared profile id +
|
|
15
|
+
* pid.
|
|
16
|
+
*
|
|
17
|
+
* Each sample's delta is credited as self time to the sampled frame
|
|
18
|
+
* and as total time to every distinct frame on the sampled stack
|
|
19
|
+
* (recursion counted once) — the definitions DevTools' bottom-up view
|
|
20
|
+
* uses. Frames aggregate per function identity (name + script +
|
|
21
|
+
* source position), so a function called from many sites is one row.
|
|
22
|
+
*
|
|
23
|
+
* V8 meta frames ((root), (program), (idle), (garbage collector))
|
|
24
|
+
* carry codeType 'other' and are skipped — idle is not cost and GC is
|
|
25
|
+
* already in cpu.categories. Engine frames without a script URL
|
|
26
|
+
* (e.g. querySelectorAll) are kept: the cost is real, it just has no
|
|
27
|
+
* source position.
|
|
28
|
+
*
|
|
29
|
+
* When the optional `bundles` map (url → location resolver, built by
|
|
30
|
+
* the coverage path) knows a frame's URL, the frame's position is
|
|
31
|
+
* resolved to the owning module inside the concatenated bundle and
|
|
32
|
+
* reported as `module`. V8 call-frame positions are 0-based while the
|
|
33
|
+
* resolvers — and the reported line/column — are 1-based (the trace
|
|
34
|
+
* event / DevTools convention). Frame URLs longer than 1024 chars are
|
|
35
|
+
* truncated by V8; they are recovered via unique prefix match against
|
|
36
|
+
* the bundle map so the reported url matches cpu.urls / moduleCosts.
|
|
37
|
+
*
|
|
38
|
+
* Returns [{ functionName, url?, line?, column?, module?, selfTime,
|
|
39
|
+
* totalTime, callees? }, …] in ms rounded to one decimal, sorted by
|
|
40
|
+
* selfTime desc, noise-filtered to self time ≥ 1 ms and capped at 50
|
|
41
|
+
* rows. `callees` (top 5 ≥ 1 ms, [{ functionName, url?, module?,
|
|
42
|
+
* value }]) breaks a function's non-self time down by what it called,
|
|
43
|
+
* so orchestrators (loaders, dispatchers) whose total dwarfs their
|
|
44
|
+
* self time show what they actually ran. Empty array when the trace
|
|
45
|
+
* carries no profiler data (the category is only enabled on profile
|
|
46
|
+
* runs).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { findMainFrameIds } from './tracing-processor.js';
|
|
50
|
+
|
|
51
|
+
const REPORT_LIMIT_US = 1000;
|
|
52
|
+
const MAX_FUNCTIONS = 50;
|
|
53
|
+
const CALLEE_LIMIT_US = 1000;
|
|
54
|
+
const MAX_CALLEES = 5;
|
|
55
|
+
// V8 truncates callFrame.url in the trace (observed at 1024 chars),
|
|
56
|
+
// so long bundle URLs (MediaWiki load.php easily exceeds it) won't
|
|
57
|
+
// exact-match the coverage-path bundle map or the rest of the result.
|
|
58
|
+
const V8_URL_TRUNCATION_LIMIT = 1024;
|
|
59
|
+
|
|
60
|
+
function round(ms) {
|
|
61
|
+
return Math.round(ms * 10) / 10;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function functionKey(callFrame) {
|
|
65
|
+
return `${callFrame.functionName}|${callFrame.scriptId}|${callFrame.lineNumber}|${callFrame.columnNumber}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isMetaFrame(callFrame) {
|
|
69
|
+
return callFrame.codeType === 'other';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Recover the full bundle URL + resolver for a (possibly truncated)
|
|
73
|
+
// frame URL. Exact match first; for truncation-length URLs fall back
|
|
74
|
+
// to a unique prefix match, skipping when ambiguous.
|
|
75
|
+
function findBundle(bundles, url) {
|
|
76
|
+
if (!bundles || !url) return;
|
|
77
|
+
const exact = bundles.get(url);
|
|
78
|
+
if (exact) return { url, resolver: exact };
|
|
79
|
+
if (url.length < V8_URL_TRUNCATION_LIMIT) return;
|
|
80
|
+
let match;
|
|
81
|
+
for (const [bundleUrl, resolver] of bundles) {
|
|
82
|
+
if (bundleUrl.startsWith(url)) {
|
|
83
|
+
if (match) return;
|
|
84
|
+
match = { url: bundleUrl, resolver };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return match;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function computeFunctionCosts(trace, bundles) {
|
|
91
|
+
const events = trace.traceEvents;
|
|
92
|
+
const { pid, tid } = findMainFrameIds(events);
|
|
93
|
+
|
|
94
|
+
const profileIds = new Set();
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
if (event.name === 'Profile' && event.pid === pid && event.tid === tid) {
|
|
97
|
+
profileIds.add(event.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (profileIds.size === 0) return [];
|
|
101
|
+
|
|
102
|
+
const nodes = new Map();
|
|
103
|
+
const samples = [];
|
|
104
|
+
const timeDeltas = [];
|
|
105
|
+
for (const event of events) {
|
|
106
|
+
if (
|
|
107
|
+
event.name !== 'ProfileChunk' ||
|
|
108
|
+
event.pid !== pid ||
|
|
109
|
+
!profileIds.has(event.id)
|
|
110
|
+
) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const data = event.args && event.args.data;
|
|
114
|
+
if (!data) continue;
|
|
115
|
+
const cpuProfile = data.cpuProfile;
|
|
116
|
+
if (cpuProfile && cpuProfile.nodes) {
|
|
117
|
+
for (const node of cpuProfile.nodes) nodes.set(node.id, node);
|
|
118
|
+
}
|
|
119
|
+
if (cpuProfile && cpuProfile.samples) samples.push(...cpuProfile.samples);
|
|
120
|
+
if (data.timeDeltas) timeDeltas.push(...data.timeDeltas);
|
|
121
|
+
}
|
|
122
|
+
if (samples.length === 0) return [];
|
|
123
|
+
|
|
124
|
+
// Lazily-named frames can miss their url; recover it from another
|
|
125
|
+
// node in the same script.
|
|
126
|
+
const scriptUrls = new Map();
|
|
127
|
+
for (const node of nodes.values()) {
|
|
128
|
+
const callFrame = node.callFrame;
|
|
129
|
+
if (callFrame.url && callFrame.scriptId) {
|
|
130
|
+
scriptUrls.set(callFrame.scriptId, callFrame.url);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Distinct function keys on each node's stack, for total time.
|
|
135
|
+
const stackKeys = new Map();
|
|
136
|
+
function keysForNode(nodeId) {
|
|
137
|
+
const cached = stackKeys.get(nodeId);
|
|
138
|
+
if (cached) return cached;
|
|
139
|
+
const node = nodes.get(nodeId);
|
|
140
|
+
if (!node) return new Set();
|
|
141
|
+
const keys = new Set(node.parent ? keysForNode(node.parent) : undefined);
|
|
142
|
+
if (!isMetaFrame(node.callFrame)) keys.add(functionKey(node.callFrame));
|
|
143
|
+
stackKeys.set(nodeId, keys);
|
|
144
|
+
return keys;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const functions = new Map();
|
|
148
|
+
const nodesByKey = new Map();
|
|
149
|
+
const childNodes = new Map();
|
|
150
|
+
for (const node of nodes.values()) {
|
|
151
|
+
if (node.parent !== undefined) {
|
|
152
|
+
let siblings = childNodes.get(node.parent);
|
|
153
|
+
if (!siblings) {
|
|
154
|
+
siblings = [];
|
|
155
|
+
childNodes.set(node.parent, siblings);
|
|
156
|
+
}
|
|
157
|
+
siblings.push(node);
|
|
158
|
+
}
|
|
159
|
+
const callFrame = node.callFrame;
|
|
160
|
+
if (isMetaFrame(callFrame)) continue;
|
|
161
|
+
const key = functionKey(callFrame);
|
|
162
|
+
if (!functions.has(key)) {
|
|
163
|
+
functions.set(key, { callFrame, selfUs: 0, totalUs: 0 });
|
|
164
|
+
}
|
|
165
|
+
let keyNodes = nodesByKey.get(key);
|
|
166
|
+
if (!keyNodes) {
|
|
167
|
+
keyNodes = [];
|
|
168
|
+
nodesByKey.set(key, keyNodes);
|
|
169
|
+
}
|
|
170
|
+
keyNodes.push(node);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Per-node inclusive time (its subtree's samples) — the cost of the
|
|
174
|
+
// call-tree edge from its parent, which is what the callee
|
|
175
|
+
// breakdown aggregates.
|
|
176
|
+
const nodeTotals = new Map();
|
|
177
|
+
for (const [index, nodeId] of samples.entries()) {
|
|
178
|
+
const delta = timeDeltas[index];
|
|
179
|
+
// The first delta rebases against the profile start and can be
|
|
180
|
+
// negative or missing; skip rather than credit negative time.
|
|
181
|
+
if (!delta || delta <= 0) continue;
|
|
182
|
+
const node = nodes.get(nodeId);
|
|
183
|
+
if (!node) continue;
|
|
184
|
+
if (!isMetaFrame(node.callFrame)) {
|
|
185
|
+
functions.get(functionKey(node.callFrame)).selfUs += delta;
|
|
186
|
+
}
|
|
187
|
+
for (const key of keysForNode(nodeId)) {
|
|
188
|
+
functions.get(key).totalUs += delta;
|
|
189
|
+
}
|
|
190
|
+
for (
|
|
191
|
+
let ancestor = node;
|
|
192
|
+
ancestor;
|
|
193
|
+
ancestor =
|
|
194
|
+
ancestor.parent === undefined ? undefined : nodes.get(ancestor.parent)
|
|
195
|
+
) {
|
|
196
|
+
nodeTotals.set(ancestor.id, (nodeTotals.get(ancestor.id) || 0) + delta);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Where a function's non-self time went: for every call-tree node of
|
|
201
|
+
// the function, each child's inclusive time is credited to the
|
|
202
|
+
// child's function identity. Answers "what did this orchestrator
|
|
203
|
+
// run" for loader/dispatcher rows whose total dwarfs their self
|
|
204
|
+
// time. Direct recursion shows the inner occurrence as a callee of
|
|
205
|
+
// itself, same as DevTools' top-down tree.
|
|
206
|
+
function calleesFor(key) {
|
|
207
|
+
const byCallee = new Map();
|
|
208
|
+
for (const node of nodesByKey.get(key) || []) {
|
|
209
|
+
for (const child of childNodes.get(node.id) || []) {
|
|
210
|
+
if (isMetaFrame(child.callFrame)) continue;
|
|
211
|
+
const childUs = nodeTotals.get(child.id);
|
|
212
|
+
if (!childUs) continue;
|
|
213
|
+
const childKey = functionKey(child.callFrame);
|
|
214
|
+
let entry = byCallee.get(childKey);
|
|
215
|
+
if (!entry) {
|
|
216
|
+
entry = { callFrame: child.callFrame, us: 0 };
|
|
217
|
+
byCallee.set(childKey, entry);
|
|
218
|
+
}
|
|
219
|
+
entry.us += childUs;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const callees = [];
|
|
223
|
+
for (const { callFrame, us } of [...byCallee.values()].toSorted(
|
|
224
|
+
(a, b) => b.us - a.us
|
|
225
|
+
)) {
|
|
226
|
+
if (us < CALLEE_LIMIT_US || callees.length >= MAX_CALLEES) break;
|
|
227
|
+
const callee = {
|
|
228
|
+
functionName: callFrame.functionName || '(anonymous)',
|
|
229
|
+
value: round(us / 1000)
|
|
230
|
+
};
|
|
231
|
+
const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
|
|
232
|
+
const bundle = findBundle(bundles, url);
|
|
233
|
+
if (bundle) {
|
|
234
|
+
callee.url = bundle.url;
|
|
235
|
+
} else if (url) {
|
|
236
|
+
callee.url = url;
|
|
237
|
+
}
|
|
238
|
+
if (bundle && callFrame.lineNumber !== undefined) {
|
|
239
|
+
const module_ = bundle.resolver.resolve(
|
|
240
|
+
callFrame.lineNumber + 1,
|
|
241
|
+
callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1
|
|
242
|
+
);
|
|
243
|
+
if (module_) callee.module = module_.name;
|
|
244
|
+
}
|
|
245
|
+
callees.push(callee);
|
|
246
|
+
}
|
|
247
|
+
return callees;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const costs = [];
|
|
251
|
+
for (const [key, { callFrame, selfUs, totalUs }] of functions.entries()) {
|
|
252
|
+
if (selfUs < REPORT_LIMIT_US) continue;
|
|
253
|
+
const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
|
|
254
|
+
const cost = {
|
|
255
|
+
functionName: callFrame.functionName || '(anonymous)',
|
|
256
|
+
selfTime: round(selfUs / 1000),
|
|
257
|
+
totalTime: round(totalUs / 1000)
|
|
258
|
+
};
|
|
259
|
+
const bundle = findBundle(bundles, url);
|
|
260
|
+
if (bundle) {
|
|
261
|
+
cost.url = bundle.url;
|
|
262
|
+
} else if (url) {
|
|
263
|
+
cost.url = url;
|
|
264
|
+
}
|
|
265
|
+
if (callFrame.lineNumber !== undefined) {
|
|
266
|
+
cost.line = callFrame.lineNumber + 1;
|
|
267
|
+
cost.column =
|
|
268
|
+
callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1;
|
|
269
|
+
if (bundle) {
|
|
270
|
+
const module_ = bundle.resolver.resolve(cost.line, cost.column);
|
|
271
|
+
if (module_) cost.module = module_.name;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const callees = calleesFor(key);
|
|
275
|
+
if (callees.length > 0) cost.callees = callees;
|
|
276
|
+
costs.push(cost);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
costs.sort((a, b) => b.selfTime - a.selfTime);
|
|
280
|
+
return costs.slice(0, MAX_FUNCTIONS);
|
|
281
|
+
}
|
|
@@ -15,6 +15,13 @@ import { compute } from './main-thread-tasks.js';
|
|
|
15
15
|
export { computeScriptCosts } from './script-costs.js';
|
|
16
16
|
export { computeForcedReflows } from './forced-reflows.js';
|
|
17
17
|
export { computeNonCompositedAnimations } from './non-composited-animations.js';
|
|
18
|
+
export { computeFrameStability } from './frame-stability.js';
|
|
19
|
+
export { computeFunctionCosts } from './function-costs.js';
|
|
20
|
+
export { computeStyleInvalidations } from './style-invalidations.js';
|
|
21
|
+
export { computeSelectorStats } from './selector-stats.js';
|
|
22
|
+
export { computeTimerCosts } from './timer-costs.js';
|
|
23
|
+
export { computeBlockingTime } from './blocking-time.js';
|
|
24
|
+
export { computeDomainBreakdown } from './domain-breakdown.js';
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Compute the hierarchical main-thread task tree for a trace.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module CPU attribution for script bundles that concatenate many
|
|
3
|
+
* logical modules into a single URL (e.g. MediaWiki ResourceLoader
|
|
4
|
+
* load.php responses). cpu.urls can only say "load.php used 267 ms of
|
|
5
|
+
* main thread"; this analysis splits that time across the modules
|
|
6
|
+
* inside each bundle so the owner of a slow module can be identified.
|
|
7
|
+
*
|
|
8
|
+
* This file is bundle-format agnostic. It walks the main-thread task
|
|
9
|
+
* tree and, for every task whose most-specific code location falls
|
|
10
|
+
* inside a known bundle URL, asks that bundle's resolver which module
|
|
11
|
+
* owns the source position. All knowledge about the bundle format
|
|
12
|
+
* (module delimiters, line/column → byte-offset math) lives in the
|
|
13
|
+
* resolver — see lib/chrome/mediawikiResourceLoader.js.
|
|
14
|
+
*
|
|
15
|
+
* Attribution rules per task (selfTime is exclusive of children, so
|
|
16
|
+
* nothing is counted twice):
|
|
17
|
+
* - EvaluateScript / v8.compile / v8.compileModule on a bundle URL:
|
|
18
|
+
* the event spans the whole script, not one module (module bodies
|
|
19
|
+
* are only registered at evaluate time and executed later), so the
|
|
20
|
+
* time goes to the bundle's `otherTime` bucket.
|
|
21
|
+
* - FunctionCall: args.data carries the definition site of the
|
|
22
|
+
* invoked function — resolve it to a module.
|
|
23
|
+
* - Any other event with a stack trace: the innermost frame with a
|
|
24
|
+
* URL decides — resolve when it is a bundle, skip the task when it
|
|
25
|
+
* points at code outside any bundle.
|
|
26
|
+
* - Tasks with no location of their own (layout, GC, microtasks
|
|
27
|
+
* triggered by module code) fall back to the inherited
|
|
28
|
+
* most-specific attributable URL; when that is a bundle the time
|
|
29
|
+
* is in-bundle but not attributable to one module → `otherTime`.
|
|
30
|
+
*
|
|
31
|
+
* Because only a task's own most-specific location (or the inherited
|
|
32
|
+
* chain when it has none) is credited, the per-bundle sum of module
|
|
33
|
+
* selfTime + otherTime is always ≤ that URL's total in cpu.urls,
|
|
34
|
+
* which counts a task for every URL anywhere in its chain.
|
|
35
|
+
*
|
|
36
|
+
* Times are in ms rounded to one decimal, matching the sibling
|
|
37
|
+
* analyses. Modules that round to 0 are dropped; bundles are sorted
|
|
38
|
+
* by total time desc and modules by selfTime desc.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { compute } from './main-thread-tasks.js';
|
|
42
|
+
|
|
43
|
+
const WHOLE_SCRIPT_EVENTS = new Set([
|
|
44
|
+
'EvaluateScript',
|
|
45
|
+
'v8.compile',
|
|
46
|
+
'v8.compileModule'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function round(ms) {
|
|
50
|
+
return Math.round(ms * 10) / 10;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function attributeTask(task, bundles) {
|
|
54
|
+
const event = task.event;
|
|
55
|
+
const argsData = (event.args && event.args.data) || {};
|
|
56
|
+
|
|
57
|
+
if (WHOLE_SCRIPT_EVENTS.has(event.name)) {
|
|
58
|
+
const url =
|
|
59
|
+
event.name === 'v8.compileModule' ? event.args.fileName : argsData.url;
|
|
60
|
+
return url && bundles.has(url) ? { url } : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (event.name === 'FunctionCall' && argsData.url) {
|
|
64
|
+
const bundle = bundles.get(argsData.url);
|
|
65
|
+
if (!bundle) return;
|
|
66
|
+
return {
|
|
67
|
+
url: argsData.url,
|
|
68
|
+
module: bundle.resolve(argsData.lineNumber, argsData.columnNumber)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const frame of argsData.stackTrace || []) {
|
|
73
|
+
if (!frame.url) continue;
|
|
74
|
+
const bundle = bundles.get(frame.url);
|
|
75
|
+
if (!bundle) return;
|
|
76
|
+
return {
|
|
77
|
+
url: frame.url,
|
|
78
|
+
module: bundle.resolve(frame.lineNumber, frame.columnNumber)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inherited = task.attributableURLs.at(-1);
|
|
83
|
+
return inherited && bundles.has(inherited) ? { url: inherited } : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {Object} trace Parsed Chrome trace.json (with .traceEvents).
|
|
88
|
+
* @param {Map<string, {resolve: Function}>} bundles Bundle URL →
|
|
89
|
+
* location resolver, built while collecting coverage.
|
|
90
|
+
* @returns {Array<{url:string,
|
|
91
|
+
* modules:Array<{name:string, version:string, selfTime:number}>,
|
|
92
|
+
* otherTime:number}>}
|
|
93
|
+
*/
|
|
94
|
+
export function computeModuleCosts(trace, bundles) {
|
|
95
|
+
if (!bundles || bundles.size === 0) return [];
|
|
96
|
+
|
|
97
|
+
const byBundle = new Map();
|
|
98
|
+
for (const task of compute(trace)) {
|
|
99
|
+
if (!(task.selfTime > 0)) continue;
|
|
100
|
+
const attribution = attributeTask(task, bundles);
|
|
101
|
+
if (!attribution) continue;
|
|
102
|
+
|
|
103
|
+
let entry = byBundle.get(attribution.url);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = { byModule: new Map(), otherTime: 0 };
|
|
106
|
+
byBundle.set(attribution.url, entry);
|
|
107
|
+
}
|
|
108
|
+
const module = attribution.module;
|
|
109
|
+
if (module) {
|
|
110
|
+
const key = `${module.name}@${module.version}`;
|
|
111
|
+
const moduleEntry = entry.byModule.get(key);
|
|
112
|
+
if (moduleEntry) {
|
|
113
|
+
moduleEntry.selfTime += task.selfTime;
|
|
114
|
+
} else {
|
|
115
|
+
entry.byModule.set(key, { ...module, selfTime: task.selfTime });
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
entry.otherTime += task.selfTime;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const [url, entry] of byBundle) {
|
|
124
|
+
const modules = [];
|
|
125
|
+
let total = entry.otherTime;
|
|
126
|
+
for (const moduleEntry of entry.byModule.values()) {
|
|
127
|
+
total += moduleEntry.selfTime;
|
|
128
|
+
moduleEntry.selfTime = round(moduleEntry.selfTime);
|
|
129
|
+
if (moduleEntry.selfTime > 0) modules.push(moduleEntry);
|
|
130
|
+
}
|
|
131
|
+
modules.sort((a, b) => b.selfTime - a.selfTime);
|
|
132
|
+
result.push({ url, modules, otherTime: round(entry.otherTime), total });
|
|
133
|
+
}
|
|
134
|
+
result.sort((a, b) => b.total - a.total);
|
|
135
|
+
for (const entry of result) delete entry.total;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
@@ -10,20 +10,64 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Chrome marks these in the trace via `Animation` events whose
|
|
12
12
|
* `args.data.compositeFailed` bitmask is non-zero, plus a list of
|
|
13
|
-
* `unsupportedProperties` strings.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `unsupportedProperties` strings. Chrome often omits the property
|
|
14
|
+
* list (the bitmask is the only signal), so the bitmask is decoded
|
|
15
|
+
* into `failureReasons` strings here — otherwise consumers show "an
|
|
16
|
+
* animation failed" with no way to say why.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* The bit meanings come from Blink's CompositorAnimations
|
|
19
|
+
* FailureReason enum (compositor_animations.h, kFailureReasonCount
|
|
20
|
+
* = 21 as of Chrome ~M140): bit values are stable and never reused,
|
|
21
|
+
* so decoding old traces is safe. Bits 8/11/14 are retired in
|
|
22
|
+
* current Blink but kept here for traces from older Chrome versions.
|
|
23
|
+
* Unknown future bits decode to 'unknown reason (bit N)' instead of
|
|
24
|
+
* being dropped.
|
|
25
|
+
*
|
|
26
|
+
* Returns: [{ name, id, compositeFailed, failureReasons,
|
|
27
|
+
* unsupportedProperties, startTime }, …]
|
|
19
28
|
* name — args.data.nodeName when present (rare)
|
|
20
29
|
* id — args.data.id (compositor-internal)
|
|
21
30
|
* compositeFailed — bitmask of failure reasons
|
|
31
|
+
* failureReasons — the bitmask decoded to human-readable strings
|
|
22
32
|
* unsupportedProperties — array of property names that blocked
|
|
23
33
|
* composite (e.g. ['top','box-shadow'])
|
|
24
34
|
* startTime — event ts in microseconds (raw trace timestamp)
|
|
25
35
|
*/
|
|
26
36
|
|
|
37
|
+
// Blink FailureReason bit → human-readable string, in bit order.
|
|
38
|
+
const FAILURE_REASONS = [
|
|
39
|
+
'accelerated animations disabled', // 1 << 0
|
|
40
|
+
'effect suppressed by DevTools', // 1 << 1
|
|
41
|
+
'invalid animation or effect', // 1 << 2
|
|
42
|
+
'effect has unsupported timing parameters', // 1 << 3
|
|
43
|
+
"effect has composite mode other than 'replace'", // 1 << 4
|
|
44
|
+
'target has invalid compositing state', // 1 << 5
|
|
45
|
+
'target has another incompatible animation', // 1 << 6
|
|
46
|
+
'target has CSS offset', // 1 << 7
|
|
47
|
+
'target has multiple transform properties', // 1 << 8 (retired)
|
|
48
|
+
'animation affects non-CSS properties', // 1 << 9
|
|
49
|
+
'transform-related property cannot be accelerated on target', // 1 << 10
|
|
50
|
+
'transform-related property depends on box size', // 1 << 11 (retired)
|
|
51
|
+
'filter-related property may move pixels', // 1 << 12
|
|
52
|
+
'unsupported CSS property', // 1 << 13
|
|
53
|
+
'multiple transform animations on same target', // 1 << 14 (retired)
|
|
54
|
+
'mixed keyframe value types', // 1 << 15
|
|
55
|
+
'scroll timeline source has invalid compositing state', // 1 << 16
|
|
56
|
+
'animation has no visible change', // 1 << 17
|
|
57
|
+
'animation affects an !important property', // 1 << 18
|
|
58
|
+
'SVG target has independent transform property', // 1 << 19
|
|
59
|
+
"effect has iteration composite mode other than 'replace'" // 1 << 20
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function decodeFailureReasons(bitmask) {
|
|
63
|
+
const reasons = [];
|
|
64
|
+
for (let bit = 0; bitmask >> bit > 0; bit++) {
|
|
65
|
+
if (((bitmask >> bit) & 1) === 0) continue;
|
|
66
|
+
reasons.push(FAILURE_REASONS[bit] || `unknown reason (bit ${bit})`);
|
|
67
|
+
}
|
|
68
|
+
return reasons;
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
export function computeNonCompositedAnimations(trace) {
|
|
28
72
|
const seen = new Set();
|
|
29
73
|
const animations = [];
|
|
@@ -46,6 +90,7 @@ export function computeNonCompositedAnimations(trace) {
|
|
|
46
90
|
name: data.nodeName || '',
|
|
47
91
|
id: data.id || '',
|
|
48
92
|
compositeFailed: failed,
|
|
93
|
+
failureReasons: decodeFailureReasons(failed),
|
|
49
94
|
unsupportedProperties: unsupported,
|
|
50
95
|
startTime: event.ts
|
|
51
96
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS selector matching cost, from Chrome's SelectorStats trace
|
|
3
|
+
* events (the disabled-by-default-blink.debug category, enabled on
|
|
4
|
+
* --enableProfileRun). For every style recalculation Chrome reports
|
|
5
|
+
* per-selector elapsed time, match attempts and match count — the
|
|
6
|
+
* same data DevTools' "CSS selector stats" checkbox shows. Style &
|
|
7
|
+
* layout often dominates main-thread blocking, and this is the only
|
|
8
|
+
* signal that says WHICH selectors the recalc time goes to.
|
|
9
|
+
*
|
|
10
|
+
* Selectors are aggregated across all recalcs in the trace. Two
|
|
11
|
+
* rankings matter and both are kept: elapsed time (what costs now)
|
|
12
|
+
* and match attempts with zero matches (pure waste — selectors
|
|
13
|
+
* evaluated against the whole document for rules that never apply,
|
|
14
|
+
* e.g. a lightbox selector on pages where the lightbox never opens).
|
|
15
|
+
*
|
|
16
|
+
* `styleSheetId` is Chrome's session-internal stylesheet handle —
|
|
17
|
+
* kept so consumers that also collect CSS coverage over CDP can join
|
|
18
|
+
* back to a stylesheet URL, but it is not stable across runs.
|
|
19
|
+
* 'ua-style-sheet' marks Chrome's own user-agent rules; those are
|
|
20
|
+
* not the page's to fix and are excluded from the reported lists
|
|
21
|
+
* (their time still counts in the totals).
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined when the trace has no SelectorStats events (the
|
|
24
|
+
* category is only on for profile runs). Otherwise:
|
|
25
|
+
* { totalElapsed, totalMatchAttempts, totalMatchCount,
|
|
26
|
+
* selectors: [{ selector, styleSheetId?, elapsed,
|
|
27
|
+
* matchAttempts, matchCount }, …],
|
|
28
|
+
* wastedSelectors: [{ … same shape, matchCount always 0 }, …] }
|
|
29
|
+
* elapsed in ms with two decimals (individual selectors are sub-ms),
|
|
30
|
+
* both lists capped at 25 entries; selectors sorted by elapsed desc,
|
|
31
|
+
* wastedSelectors by matchAttempts desc.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const MAX_SELECTORS = 25;
|
|
35
|
+
const UA_STYLE_SHEET = 'ua-style-sheet';
|
|
36
|
+
|
|
37
|
+
function round2(ms) {
|
|
38
|
+
return Math.round(ms * 100) / 100;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeSelectorStats(trace) {
|
|
42
|
+
const bySelector = new Map();
|
|
43
|
+
let sawSelectorStats = false;
|
|
44
|
+
let totalElapsedUs = 0;
|
|
45
|
+
let totalMatchAttempts = 0;
|
|
46
|
+
let totalMatchCount = 0;
|
|
47
|
+
|
|
48
|
+
for (const event of trace.traceEvents) {
|
|
49
|
+
if (event.name !== 'SelectorStats') continue;
|
|
50
|
+
sawSelectorStats = true;
|
|
51
|
+
const timings =
|
|
52
|
+
(event.args &&
|
|
53
|
+
event.args.selector_stats &&
|
|
54
|
+
event.args.selector_stats.selector_timings) ||
|
|
55
|
+
[];
|
|
56
|
+
for (const timing of timings) {
|
|
57
|
+
const elapsedUs = timing['elapsed (us)'] || 0;
|
|
58
|
+
totalElapsedUs += elapsedUs;
|
|
59
|
+
totalMatchAttempts += timing.match_attempts || 0;
|
|
60
|
+
totalMatchCount += timing.match_count || 0;
|
|
61
|
+
if (timing.style_sheet_id === UA_STYLE_SHEET) continue;
|
|
62
|
+
const key = `${timing.selector}|${timing.style_sheet_id || ''}`;
|
|
63
|
+
let entry = bySelector.get(key);
|
|
64
|
+
if (!entry) {
|
|
65
|
+
entry = {
|
|
66
|
+
selector: timing.selector,
|
|
67
|
+
styleSheetId: timing.style_sheet_id,
|
|
68
|
+
elapsedUs: 0,
|
|
69
|
+
matchAttempts: 0,
|
|
70
|
+
matchCount: 0
|
|
71
|
+
};
|
|
72
|
+
bySelector.set(key, entry);
|
|
73
|
+
}
|
|
74
|
+
entry.elapsedUs += elapsedUs;
|
|
75
|
+
entry.matchAttempts += timing.match_attempts || 0;
|
|
76
|
+
entry.matchCount += timing.match_count || 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!sawSelectorStats) return;
|
|
80
|
+
|
|
81
|
+
function publish(entry) {
|
|
82
|
+
const result = {
|
|
83
|
+
selector: entry.selector,
|
|
84
|
+
elapsed: round2(entry.elapsedUs / 1000),
|
|
85
|
+
matchAttempts: entry.matchAttempts,
|
|
86
|
+
matchCount: entry.matchCount
|
|
87
|
+
};
|
|
88
|
+
if (entry.styleSheetId) result.styleSheetId = entry.styleSheetId;
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const all = [...bySelector.values()];
|
|
93
|
+
const selectors = all
|
|
94
|
+
.toSorted((a, b) => b.elapsedUs - a.elapsedUs)
|
|
95
|
+
.slice(0, MAX_SELECTORS)
|
|
96
|
+
.map(entry => publish(entry));
|
|
97
|
+
const wastedSelectors = all
|
|
98
|
+
.filter(entry => entry.matchCount === 0)
|
|
99
|
+
.toSorted((a, b) => b.matchAttempts - a.matchAttempts)
|
|
100
|
+
.slice(0, MAX_SELECTORS)
|
|
101
|
+
.map(entry => publish(entry));
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
totalElapsed: round2(totalElapsedUs / 1000),
|
|
105
|
+
totalMatchAttempts,
|
|
106
|
+
totalMatchCount,
|
|
107
|
+
selectors,
|
|
108
|
+
wastedSelectors
|
|
109
|
+
};
|
|
110
|
+
}
|