@houwert/conductor 0.29.0 → 0.29.2
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.
|
@@ -73,18 +73,25 @@ function num(out, re) {
|
|
|
73
73
|
function parseGfxinfoSummary(out) {
|
|
74
74
|
const header = out.match(/\*\* Graphics info for pid (\d+) \[([^\]]+)\]/);
|
|
75
75
|
const janky = out.match(/Janky frames:\s*(\d+)\s*\(([\d.]+)%\)/);
|
|
76
|
+
const totalFrames = num(out, /Total frames rendered:\s*(\d+)/);
|
|
77
|
+
// With nothing drawn the platform still prints percentiles, filled from the
|
|
78
|
+
// top histogram bucket — "p99 4950ms" for a window in which no frame existed.
|
|
79
|
+
// A percentile over zero frames is not a slow frame, it is no frame, and it
|
|
80
|
+
// must not be representable as a value.
|
|
81
|
+
const drew = totalFrames !== undefined && totalFrames > 0;
|
|
82
|
+
const pctile = (re) => (drew ? num(out, re) : undefined);
|
|
76
83
|
// The GPU block repeats these labels as "50th gpu percentile", so matching the
|
|
77
84
|
// exact phrase keeps us on the UI-thread figures.
|
|
78
85
|
return {
|
|
79
86
|
pid: header ? Number(header[1]) : undefined,
|
|
80
87
|
packageName: header ? header[2] : undefined,
|
|
81
|
-
totalFrames
|
|
88
|
+
totalFrames,
|
|
82
89
|
jankyFrames: janky ? Number(janky[1]) : undefined,
|
|
83
|
-
jankyPercent: janky ? Number(janky[2]) : undefined,
|
|
84
|
-
platformP50Ms:
|
|
85
|
-
platformP90Ms:
|
|
86
|
-
platformP95Ms:
|
|
87
|
-
platformP99Ms:
|
|
90
|
+
jankyPercent: drew && janky ? Number(janky[2]) : undefined,
|
|
91
|
+
platformP50Ms: pctile(/50th percentile:\s*(\d+)ms/),
|
|
92
|
+
platformP90Ms: pctile(/90th percentile:\s*(\d+)ms/),
|
|
93
|
+
platformP95Ms: pctile(/95th percentile:\s*(\d+)ms/),
|
|
94
|
+
platformP99Ms: pctile(/99th percentile:\s*(\d+)ms/),
|
|
88
95
|
missedVsync: num(out, /Number Missed Vsync:\s*(\d+)/),
|
|
89
96
|
highInputLatency: num(out, /Number High input latency:\s*(\d+)/),
|
|
90
97
|
slowUiThread: num(out, /Number Slow UI thread:\s*(\d+)/),
|
|
@@ -589,6 +596,14 @@ async function profileFramesReport(opts, sessionName, frameOpts) {
|
|
|
589
596
|
`(hardware acceleration must be on; WebView-only or SurfaceView-only apps report nothing)`, opts);
|
|
590
597
|
return 1;
|
|
591
598
|
}
|
|
599
|
+
if (summary.totalFrames === 0) {
|
|
600
|
+
notes.push({
|
|
601
|
+
code: 'no-frames',
|
|
602
|
+
message: `${appId} drew no frames in this window, so there is nothing to measure — not ` +
|
|
603
|
+
`smooth, not janky. The app may be idle, backgrounded, or rendering through a ` +
|
|
604
|
+
`SurfaceView/WebView that HWUI does not count. Drive it, or check it is foreground.`,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
592
607
|
if (!clockAnchor) {
|
|
593
608
|
notes.push({
|
|
594
609
|
code: 'no-clock-anchor',
|
|
@@ -27,6 +27,15 @@ const output_js_1 = require("../output.js");
|
|
|
27
27
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
28
28
|
const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
|
|
29
29
|
const stats_js_1 = require("../stats.js");
|
|
30
|
+
/** Hermes marks its own frames with brackets; anything else is app code. */
|
|
31
|
+
function classifyFrame(name) {
|
|
32
|
+
if (/^\[GC\b/i.test(name) || /garbage collector/i.test(name))
|
|
33
|
+
return 'gc';
|
|
34
|
+
if (name === '[root]' || name === '(root)' || name === '(program)' || name === '(idle)') {
|
|
35
|
+
return 'idle';
|
|
36
|
+
}
|
|
37
|
+
return name.startsWith('[') || name.startsWith('(') ? 'idle' : 'named';
|
|
38
|
+
}
|
|
30
39
|
function shortFile(url) {
|
|
31
40
|
const u = url ?? '';
|
|
32
41
|
return u.includes('/') ? u.slice(u.lastIndexOf('/') + 1) : u;
|
|
@@ -82,6 +91,33 @@ function analyzeCpuProfile(profile, top) {
|
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
const totalUs = [...self.values()].reduce((a, b) => a + b, 0);
|
|
94
|
+
// Split before ranking: a top-30 list assembled from 5% of the samples is
|
|
95
|
+
// noise dressed as a finding, and the reader cannot tell without this.
|
|
96
|
+
const buckets = { gc: 0, idle: 0, named: 0 };
|
|
97
|
+
for (const [key, us] of self.entries()) {
|
|
98
|
+
buckets[classifyFrame(label.get(key).functionName || '(anonymous)')] += us;
|
|
99
|
+
}
|
|
100
|
+
const share = (v) => (totalUs > 0 ? (0, stats_js_1.round)((v / totalUs) * 100, 1) : 0);
|
|
101
|
+
const attribution = {
|
|
102
|
+
namedJsPercent: share(buckets.named),
|
|
103
|
+
gcPercent: share(buckets.gc),
|
|
104
|
+
idlePercent: share(buckets.idle),
|
|
105
|
+
};
|
|
106
|
+
const notes = [];
|
|
107
|
+
if (totalUs > 0 && attribution.namedJsPercent < 25) {
|
|
108
|
+
notes.push({
|
|
109
|
+
code: 'low-attribution',
|
|
110
|
+
message: `Only ${attribution.namedJsPercent}% of sampled time landed in a named JS function ` +
|
|
111
|
+
`(${attribution.idlePercent}% with an empty JS stack, ${attribution.gcPercent}% in GC). ` +
|
|
112
|
+
`The ranking below is built on what little is left and should not be trusted. ` +
|
|
113
|
+
`A large empty-stack share means the JS thread was idle when sampled — which is itself ` +
|
|
114
|
+
`a result: the bottleneck is not JS. A large GC share is a memory finding; follow it ` +
|
|
115
|
+
`with \`profile memory --track\` rather than a function ranking.`,
|
|
116
|
+
namedJsPercent: attribution.namedJsPercent,
|
|
117
|
+
gcPercent: attribution.gcPercent,
|
|
118
|
+
idlePercent: attribution.idlePercent,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
85
121
|
const ranked = [...self.entries()]
|
|
86
122
|
.map(([key, selfUs]) => {
|
|
87
123
|
const frame = label.get(key);
|
|
@@ -101,8 +137,10 @@ function analyzeCpuProfile(profile, top) {
|
|
|
101
137
|
durationMs: (0, stats_js_1.round)((profile.endTime - profile.startTime) / 1000),
|
|
102
138
|
sampleCount: samples.length,
|
|
103
139
|
medianSampleIntervalMs: sortedDeltas.length > 0 ? (0, stats_js_1.round)(sortedDeltas[Math.floor(sortedDeltas.length / 2)] / 1000) : 0,
|
|
140
|
+
attribution,
|
|
104
141
|
top: ranked.slice(0, top),
|
|
105
142
|
omitted: Math.max(0, ranked.length - top),
|
|
143
|
+
notes,
|
|
106
144
|
};
|
|
107
145
|
}
|
|
108
146
|
async function connect(sessionName, cdpOpts) {
|
|
@@ -114,7 +152,10 @@ async function connect(sessionName, cdpOpts) {
|
|
|
114
152
|
return client;
|
|
115
153
|
}
|
|
116
154
|
async function startSampling(client) {
|
|
117
|
-
|
|
155
|
+
// React Native's Fusebox CDP backend answers `Profiler.enable` with
|
|
156
|
+
// -32601 Unsupported method, but implements `Profiler.start` perfectly well.
|
|
157
|
+
// Enabling is a courtesy to backends that require it, never a precondition.
|
|
158
|
+
await client.send('Profiler.enable').catch(() => undefined);
|
|
118
159
|
await client.send('Profiler.start');
|
|
119
160
|
}
|
|
120
161
|
async function stopSampling(client) {
|
|
@@ -134,13 +175,18 @@ async function writeProfile(out, profile) {
|
|
|
134
175
|
function printSummary(summary, out) {
|
|
135
176
|
console.log(`profile js — ${summary.durationMs}ms, ${summary.sampleCount} samples ` +
|
|
136
177
|
`(~${summary.medianSampleIntervalMs}ms apart)`);
|
|
137
|
-
|
|
178
|
+
const a = summary.attribution;
|
|
179
|
+
console.log(` sampled time: ${a.namedJsPercent}% named JS ${a.gcPercent}% GC ` +
|
|
180
|
+
`${a.idlePercent}% empty JS stack`);
|
|
181
|
+
console.log(`\n ${'self'.padStart(9)} ${'total'.padStart(9)} ${'%'.padStart(5)} function`);
|
|
138
182
|
for (const f of summary.top) {
|
|
139
183
|
console.log(` ${`${f.selfMs}ms`.padStart(9)} ${`${f.totalMs}ms`.padStart(9)} ` +
|
|
140
184
|
`${f.selfPercent.toFixed(1).padStart(5)} ${f.name} ${f.location}`);
|
|
141
185
|
}
|
|
142
186
|
if (summary.omitted > 0)
|
|
143
187
|
console.log(` ... ${summary.omitted} more (raise --top)`);
|
|
188
|
+
for (const note of summary.notes)
|
|
189
|
+
console.log(`\n note [${note.code}]: ${note.message}`);
|
|
144
190
|
console.log(`\n raw profile → ${out}`);
|
|
145
191
|
console.log(' open it in Chrome DevTools (Performance → Load profile), or convert with ' +
|
|
146
192
|
'`npx hermes-profile-transformer`.');
|
package/dist/commands/profile.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.REACT_PROFILER_STOP = exports.REACT_PROFILER_READ = exports.REACT_PROFIL
|
|
|
7
7
|
exports.parseSimpleperfReport = parseSimpleperfReport;
|
|
8
8
|
exports.profileCpu = profileCpu;
|
|
9
9
|
exports.heapGrowth = heapGrowth;
|
|
10
|
+
exports.inferCollections = inferCollections;
|
|
10
11
|
exports.profileMemory = profileMemory;
|
|
11
12
|
exports.profileReactStart = profileReactStart;
|
|
12
13
|
exports.profileReactStop = profileReactStop;
|
|
@@ -228,6 +229,37 @@ function heapGrowth(samples) {
|
|
|
228
229
|
function mb(bytes) {
|
|
229
230
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
230
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Infer collections from the heap series rather than from logcat.
|
|
234
|
+
*
|
|
235
|
+
* A heap that shrinks between two samples was collected between them — there is
|
|
236
|
+
* no other mechanism. This matters because ART's GC logging is not reliably on:
|
|
237
|
+
* measured on a Fire TV Stick (Fire OS, API 30), a window in which the Java heap
|
|
238
|
+
* fell 11.8MB produced no `art:I` lines at all. Absence of logged collections is
|
|
239
|
+
* therefore not absence of collection, and the deltas are the sounder signal.
|
|
240
|
+
*/
|
|
241
|
+
function inferCollections(samples, series = 'javaHeapBytes') {
|
|
242
|
+
const values = [];
|
|
243
|
+
for (const s of samples) {
|
|
244
|
+
const app = s.data?.app;
|
|
245
|
+
const v = app?.[series];
|
|
246
|
+
if (typeof v === 'number')
|
|
247
|
+
values.push(v);
|
|
248
|
+
}
|
|
249
|
+
if (values.length < 2)
|
|
250
|
+
return undefined;
|
|
251
|
+
let collections = 0;
|
|
252
|
+
let reclaimedBytes = 0;
|
|
253
|
+
for (let i = 1; i < values.length; i++) {
|
|
254
|
+
const drop = values[i - 1] - values[i];
|
|
255
|
+
// Ignore sampling jitter; only count drops worth a collection.
|
|
256
|
+
if (drop > 256 * 1024) {
|
|
257
|
+
collections++;
|
|
258
|
+
reclaimedBytes += drop;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { collections, reclaimedBytes, series };
|
|
262
|
+
}
|
|
231
263
|
async function profileMemory(opts, sessionName, profileOpts) {
|
|
232
264
|
const samples = [];
|
|
233
265
|
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
@@ -257,6 +289,7 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
257
289
|
}
|
|
258
290
|
});
|
|
259
291
|
const growth = heapGrowth(parsed);
|
|
292
|
+
const inferredGc = isAndroid ? inferCollections(parsed) : undefined;
|
|
260
293
|
let gc;
|
|
261
294
|
if (isAndroid) {
|
|
262
295
|
const events = await (0, profile_gc_js_1.collectGcSince)(sessionName, gcSince);
|
|
@@ -264,7 +297,7 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
264
297
|
gc = (0, profile_gc_js_1.summariseGc)(events);
|
|
265
298
|
}
|
|
266
299
|
if (opts.json) {
|
|
267
|
-
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc }, opts);
|
|
300
|
+
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc, inferredGc }, opts);
|
|
268
301
|
}
|
|
269
302
|
else {
|
|
270
303
|
console.log(`profile memory — ${samples.length} samples over ${profileOpts.trackSec}s`);
|
|
@@ -293,8 +326,20 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
293
326
|
}
|
|
294
327
|
}
|
|
295
328
|
else if (isAndroid) {
|
|
296
|
-
console.log('\n GC: no ART collections logged in the window.
|
|
297
|
-
|
|
329
|
+
console.log('\n GC: no ART collections logged in the window.');
|
|
330
|
+
if (inferredGc && inferredGc.collections > 0) {
|
|
331
|
+
console.log(` But ${inferredGc.series} fell ${inferredGc.collections} time(s), reclaiming ` +
|
|
332
|
+
`${mb(inferredGc.reclaimedBytes)} — so collections did run and were not logged. ` +
|
|
333
|
+
`ART's logging is off or filtered on this build; trust the heap deltas, not logcat.`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.log(' Absence of logged collections is not absence of collection — ART logging is ' +
|
|
337
|
+
'off or filtered on some builds. The heap deltas above are the sounder signal.');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (inferredGc && inferredGc.collections > 0 && gc) {
|
|
341
|
+
console.log(` (heap deltas independently imply ${inferredGc.collections} collection(s), ` +
|
|
342
|
+
`${mb(inferredGc.reclaimedBytes)} reclaimed)`);
|
|
298
343
|
}
|
|
299
344
|
}
|
|
300
345
|
return 0;
|
|
@@ -131,7 +131,9 @@ async function discoverTarget(port, host, targetIndex) {
|
|
|
131
131
|
const data = await fetchTargets(port, host);
|
|
132
132
|
const withWs = data.filter((t) => t.webSocketDebuggerUrl);
|
|
133
133
|
if (withWs.length === 0) {
|
|
134
|
-
throw new Error('Metro
|
|
134
|
+
throw new Error('Metro is reachable but has no debugger targets. Most often the app is a release ' +
|
|
135
|
+
'build — release Hermes ships without the inspector and never connects to Metro. ' +
|
|
136
|
+
'Otherwise, check the app is running and pointed at this Metro instance.');
|
|
135
137
|
}
|
|
136
138
|
if (targetIndex !== undefined) {
|
|
137
139
|
if (targetIndex < 0 || targetIndex >= withWs.length) {
|
|
@@ -33,7 +33,17 @@ function selectDebuggerUrl(targets, opts, displayName) {
|
|
|
33
33
|
const host = opts.host ?? 'localhost';
|
|
34
34
|
const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
|
|
35
35
|
if (withWs.length === 0) {
|
|
36
|
-
|
|
36
|
+
// Metro is reachable but nothing registered. The usual cause is not a
|
|
37
|
+
// missing app — it is a release build: release Hermes ships without the
|
|
38
|
+
// inspector and never connects to Metro at all, so asking "is the app
|
|
39
|
+
// running?" sends people to check something that is already true.
|
|
40
|
+
throw new Error(`Metro on ${host}:${port} is reachable but has no debugger targets.\n` +
|
|
41
|
+
`Most often the app is a release build — release Hermes ships without the ` +
|
|
42
|
+
`inspector and never connects to Metro, so no amount of relaunching will help. ` +
|
|
43
|
+
`Use a debug or profiling build for anything that attaches over Metro ` +
|
|
44
|
+
`(\`profile js\`, \`profile react\`, \`debug\`, \`network\`).\n` +
|
|
45
|
+
`Otherwise: check the app is running and foreground, and that it is pointed at ` +
|
|
46
|
+
`this Metro instance rather than another one.`);
|
|
37
47
|
}
|
|
38
48
|
if (opts.targetIndex !== undefined) {
|
|
39
49
|
if (opts.targetIndex < 0 || opts.targetIndex >= withWs.length) {
|