perfetto-analyzer 1.0.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.
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env python3
2
+ """Parse de um arquivo .pb do Perfetto usando a Trace Processor Python API."""
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import os
8
+
9
+
10
+ def try_import():
11
+ try:
12
+ from perfetto.trace_processor import TraceProcessor, TraceProcessorConfig
13
+ return TraceProcessor, TraceProcessorConfig
14
+ except ImportError:
15
+ print("ERRO: Biblioteca 'perfetto' não instalada.", file=sys.stderr)
16
+ print("Execute: pip install perfetto", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+
20
+ def query_to_dicts(tp, sql):
21
+ """Executa SQL e retorna lista de dicts. Retorna [] em caso de erro."""
22
+ try:
23
+ result = tp.query(sql)
24
+ rows = []
25
+ for row in result:
26
+ rows.append({col: getattr(row, col) for col in result.column_names})
27
+ return rows
28
+ except Exception as e:
29
+ # Tabela não existe neste trace (ex: heap_profile sem heapprofd)
30
+ return []
31
+
32
+
33
+ def extract_memory(tp):
34
+ native_allocs = query_to_dicts(tp, """
35
+ SELECT
36
+ spf.name AS function_name,
37
+ spf.mapping_name,
38
+ SUM(CASE WHEN hpa.size > 0 THEN hpa.size ELSE 0 END) AS alloc_bytes,
39
+ SUM(CASE WHEN hpa.size < 0 THEN -hpa.size ELSE 0 END) AS freed_bytes,
40
+ SUM(CASE WHEN hpa.count > 0 THEN hpa.count ELSE 0 END) AS alloc_count
41
+ FROM heap_profile_allocation hpa
42
+ JOIN stack_profile_callsite spc ON spc.id = hpa.callsite_id
43
+ JOIN stack_profile_frame spf ON spf.id = spc.frame_id
44
+ GROUP BY spf.name, spf.mapping_name
45
+ ORDER BY alloc_bytes DESC
46
+ LIMIT 20
47
+ """)
48
+
49
+ for alloc in native_allocs:
50
+ alloc["unreleased_bytes"] = alloc.get("alloc_bytes", 0) - alloc.get("freed_bytes", 0)
51
+
52
+ art_objects = query_to_dicts(tp, """
53
+ SELECT
54
+ hgc.name AS type_name,
55
+ COUNT(*) AS instance_count,
56
+ SUM(hgo.self_size) AS total_size
57
+ FROM heap_graph_object hgo
58
+ JOIN heap_graph_class hgc ON hgc.id = hgo.type_id
59
+ WHERE hgo.reachable = 1
60
+ GROUP BY hgc.name
61
+ ORDER BY total_size DESC
62
+ LIMIT 20
63
+ """)
64
+
65
+ return {
66
+ "native_top_allocators": native_allocs,
67
+ "art_retained_objects": art_objects,
68
+ }
69
+
70
+
71
+ def extract_cpu(tp):
72
+ sched_summary = query_to_dicts(tp, """
73
+ SELECT
74
+ p.name AS process_name,
75
+ SUM(sched.dur) / 1e9 AS total_cpu_seconds,
76
+ COUNT(*) AS context_switches
77
+ FROM sched
78
+ JOIN thread t ON t.utid = sched.utid
79
+ JOIN process p ON p.upid = t.upid
80
+ WHERE p.name IS NOT NULL
81
+ GROUP BY p.name
82
+ ORDER BY total_cpu_seconds DESC
83
+ LIMIT 15
84
+ """)
85
+
86
+ cpu_freq = query_to_dicts(tp, """
87
+ SELECT
88
+ cct.cpu,
89
+ AVG(c.value) AS avg_freq_mhz,
90
+ MAX(c.value) AS max_freq_mhz
91
+ FROM counter c
92
+ JOIN cpu_counter_track cct ON cct.id = c.track_id
93
+ WHERE cct.name = 'cpufreq'
94
+ GROUP BY cct.cpu
95
+ ORDER BY cct.cpu
96
+ """)
97
+
98
+ # Top frame nativo de cada sample (contexto de baixo nível)
99
+ top_callstacks = query_to_dicts(tp, """
100
+ SELECT
101
+ spf.name AS function_name,
102
+ t.name AS thread_name,
103
+ p.name AS process_name,
104
+ COUNT(*) AS sample_count
105
+ FROM perf_sample ps
106
+ JOIN stack_profile_callsite spc ON spc.id = ps.callsite_id
107
+ JOIN stack_profile_frame spf ON spf.id = spc.frame_id
108
+ LEFT JOIN thread t ON t.utid = ps.utid
109
+ LEFT JOIN process p ON p.upid = t.upid
110
+ GROUP BY spf.name, t.name, p.name
111
+ ORDER BY sample_count DESC
112
+ LIMIT 20
113
+ """)
114
+
115
+ # Frames Kotlin/Java em toda a cadeia do callsite (identifica ofensores reais)
116
+ jvm_callstacks = query_to_dicts(tp, """
117
+ WITH RECURSIVE callsite_chain(sample_id, frame_id, parent_id, depth) AS (
118
+ SELECT ps.id, spc.frame_id, spc.parent_id, 1
119
+ FROM perf_sample ps
120
+ JOIN stack_profile_callsite spc ON spc.id = ps.callsite_id
121
+ UNION ALL
122
+ SELECT cc.sample_id, spc.frame_id, spc.parent_id, cc.depth + 1
123
+ FROM callsite_chain cc
124
+ JOIN stack_profile_callsite spc ON spc.id = cc.parent_id
125
+ WHERE cc.depth < 60
126
+ )
127
+ SELECT
128
+ spf.name AS function_name,
129
+ spm.name AS mapping_name,
130
+ COUNT(DISTINCT cc.sample_id) AS sample_count
131
+ FROM callsite_chain cc
132
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
133
+ LEFT JOIN stack_profile_mapping spm ON spm.id = spf.mapping
134
+ WHERE spf.name LIKE 'com.%'
135
+ OR spf.name LIKE 'kotlin.%'
136
+ OR spf.name LIKE 'androidx.%'
137
+ OR spf.name LIKE 'org.jetbrains.%'
138
+ GROUP BY spf.name, spm.name
139
+ ORDER BY sample_count DESC
140
+ LIMIT 40
141
+ """)
142
+
143
+ return {
144
+ "scheduling_summary": sched_summary,
145
+ "cpu_frequency": cpu_freq,
146
+ "top_cpu_callstacks": top_callstacks,
147
+ "jvm_callstacks": jvm_callstacks,
148
+ # preenchido em main() após saber o --package
149
+ "app_callstacks": [],
150
+ }
151
+
152
+
153
+ def extract_frames(tp):
154
+ frames = query_to_dicts(tp, """
155
+ SELECT
156
+ aft.layer_name,
157
+ aft.present_type,
158
+ aft.on_time_finish,
159
+ aft.jank_type,
160
+ (aft.ts_end - aft.ts) / 1e6 AS duration_ms
161
+ FROM actual_frame_timeline_slice aft
162
+ ORDER BY aft.ts
163
+ LIMIT 5000
164
+ """)
165
+
166
+ if not frames:
167
+ frames = query_to_dicts(tp, """
168
+ SELECT
169
+ s.name,
170
+ s.dur / 1e6 AS duration_ms
171
+ FROM slice s
172
+ WHERE s.name IN ('Choreographer#doFrame', 'DrawFrame', 'doFrame')
173
+ ORDER BY s.ts
174
+ LIMIT 5000
175
+ """)
176
+
177
+ total = len(frames)
178
+ durations = [f.get("duration_ms", 0) for f in frames if f.get("duration_ms")]
179
+ dropped = sum(
180
+ 1 for f in frames
181
+ if f.get("on_time_finish") == 0 or f.get("duration_ms", 0) > 16.67
182
+ )
183
+
184
+ percentiles = {}
185
+ if durations:
186
+ s = sorted(durations)
187
+ n = len(s)
188
+ percentiles = {
189
+ "p50": round(s[int(n * 0.50)], 2),
190
+ "p95": round(s[int(n * 0.95)], 2),
191
+ "p99": round(s[min(int(n * 0.99), n - 1)], 2),
192
+ "max": round(max(s), 2),
193
+ }
194
+
195
+ jank_causes = {}
196
+ for f in frames:
197
+ j = str(f.get("jank_type", ""))
198
+ if j and j not in ("None", "0", ""):
199
+ jank_causes[j] = jank_causes.get(j, 0) + 1
200
+
201
+ # Callstacks JVM que ocorreram durante frames janked — identifica o ofensor do jank
202
+ jank_jvm_callstacks = query_to_dicts(tp, """
203
+ WITH jank_windows AS (
204
+ SELECT aft.ts AS frame_start, aft.ts_end AS frame_end
205
+ FROM actual_frame_timeline_slice aft
206
+ WHERE aft.on_time_finish = 0
207
+ ),
208
+ jank_samples AS (
209
+ SELECT DISTINCT ps.id AS sample_id
210
+ FROM perf_sample ps
211
+ JOIN jank_windows jw ON ps.ts >= jw.frame_start AND ps.ts <= jw.frame_end
212
+ ),
213
+ callsite_chain(sample_id, frame_id, parent_id, depth) AS (
214
+ SELECT js.sample_id, spc.frame_id, spc.parent_id, 1
215
+ FROM jank_samples js
216
+ JOIN perf_sample ps ON ps.id = js.sample_id
217
+ JOIN stack_profile_callsite spc ON spc.id = ps.callsite_id
218
+ UNION ALL
219
+ SELECT cc.sample_id, spc.frame_id, spc.parent_id, cc.depth + 1
220
+ FROM callsite_chain cc
221
+ JOIN stack_profile_callsite spc ON spc.id = cc.parent_id
222
+ WHERE cc.depth < 60
223
+ )
224
+ SELECT
225
+ spf.name AS function_name,
226
+ COUNT(DISTINCT cc.sample_id) AS sample_count
227
+ FROM callsite_chain cc
228
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
229
+ WHERE spf.name LIKE 'com.%'
230
+ OR spf.name LIKE 'kotlin.%'
231
+ OR spf.name LIKE 'androidx.%'
232
+ OR spf.name LIKE 'org.jetbrains.%'
233
+ GROUP BY spf.name
234
+ ORDER BY sample_count DESC
235
+ LIMIT 20
236
+ """)
237
+
238
+ return {
239
+ "total_frames": total,
240
+ "dropped_frames": dropped,
241
+ "dropped_pct": round(dropped / total * 100, 2) if total else 0,
242
+ "frame_duration_percentiles_ms": percentiles,
243
+ "jank_causes": jank_causes,
244
+ "jank_jvm_callstacks": jank_jvm_callstacks,
245
+ # preenchido em main() após saber o --package
246
+ "jank_app_callstacks": [],
247
+ }
248
+
249
+
250
+ def extract_battery(tp):
251
+ battery_counters = query_to_dicts(tp, """
252
+ SELECT
253
+ ct.name AS counter_name,
254
+ MIN(c.value) AS min_value,
255
+ MAX(c.value) AS max_value,
256
+ MAX(c.value) - MIN(c.value) AS delta
257
+ FROM counter c
258
+ JOIN counter_track ct ON ct.id = c.track_id
259
+ WHERE ct.name IN (
260
+ 'batt_charge_uah', 'batt_capacity_pct', 'batt_current_ua',
261
+ 'batt_voltage_uv', 'BatteryStats.Batt.Charge.uah'
262
+ )
263
+ GROUP BY ct.name
264
+ """)
265
+
266
+ power_rails = query_to_dicts(tp, """
267
+ SELECT
268
+ ct.name AS rail_name,
269
+ SUM(c.value) / 1e3 AS total_mw
270
+ FROM counter c
271
+ JOIN counter_track ct ON ct.id = c.track_id
272
+ WHERE ct.name LIKE 'power.rails.%'
273
+ GROUP BY ct.name
274
+ ORDER BY total_mw DESC
275
+ LIMIT 10
276
+ """)
277
+
278
+ return {
279
+ "battery_counters": battery_counters,
280
+ "power_rails": power_rails,
281
+ }
282
+
283
+
284
+ def get_trace_duration(tp):
285
+ for table in ("sched", "slice", "counter"):
286
+ rows = query_to_dicts(tp, f"SELECT (MAX(ts) - MIN(ts)) / 1e9 AS duration_seconds FROM {table}")
287
+ if rows and rows[0].get("duration_seconds"):
288
+ return rows[0]["duration_seconds"]
289
+ return 0
290
+
291
+
292
+ def _app_prefix(package):
293
+ """Deriva o prefixo de pacote para filtrar classes do app.
294
+
295
+ 'com.example.app.debug' → 'com.example.app'
296
+ Remove sufixos de build variant conhecidos para que o LIKE funcione
297
+ contra nomes de classe como 'com.example.app.feature.HomeScreen'.
298
+ """
299
+ BUILD_SUFFIXES = {"debug", "release", "staging", "demo", "qa", "prod", "internal", "beta"}
300
+ parts = package.split(".")
301
+ while parts and parts[-1].lower() in BUILD_SUFFIXES:
302
+ parts.pop()
303
+ return ".".join(parts)
304
+
305
+
306
+ def extract_app_callstacks(tp, package):
307
+ """Para cada sample de CPU, encontra o frame mais superficial que pertence ao app.
308
+
309
+ Isso responde: 'qual função do MEU código estava ativa quando a amostra foi colhida?'
310
+ O frame mais superficial (menor depth) é o mais próximo de onde a CPU estava,
311
+ dentro do código do app — ou seja, o ponto de entrada que chamou as APIs do framework.
312
+ """
313
+ if not package:
314
+ return []
315
+
316
+ prefix = _app_prefix(package)
317
+
318
+ return query_to_dicts(tp, f"""
319
+ WITH RECURSIVE callsite_chain(sample_id, frame_id, parent_id, depth) AS (
320
+ SELECT ps.id, spc.frame_id, spc.parent_id, 1
321
+ FROM perf_sample ps
322
+ JOIN thread t ON t.utid = ps.utid
323
+ JOIN process p ON p.upid = t.upid
324
+ JOIN stack_profile_callsite spc ON spc.id = ps.callsite_id
325
+ WHERE p.name = '{package}'
326
+ UNION ALL
327
+ SELECT cc.sample_id, spc.frame_id, spc.parent_id, cc.depth + 1
328
+ FROM callsite_chain cc
329
+ JOIN stack_profile_callsite spc ON spc.id = cc.parent_id
330
+ WHERE cc.depth < 60
331
+ ),
332
+ top_app AS (
333
+ SELECT cc.sample_id, MIN(cc.depth) AS min_depth
334
+ FROM callsite_chain cc
335
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
336
+ WHERE spf.name LIKE '{prefix}.%'
337
+ GROUP BY cc.sample_id
338
+ )
339
+ SELECT
340
+ spf.name AS function_name,
341
+ t.name AS thread_name,
342
+ COUNT(DISTINCT cc.sample_id) AS sample_count
343
+ FROM callsite_chain cc
344
+ JOIN top_app ta ON ta.sample_id = cc.sample_id AND cc.depth = ta.min_depth
345
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
346
+ JOIN perf_sample ps ON ps.id = cc.sample_id
347
+ JOIN thread t ON t.utid = ps.utid
348
+ GROUP BY spf.name, t.name
349
+ ORDER BY sample_count DESC
350
+ LIMIT 25
351
+ """)
352
+
353
+
354
+ def main():
355
+ parser = argparse.ArgumentParser(description="Parse de trace Perfetto")
356
+ parser.add_argument("--input", required=True, help="Arquivo .pb")
357
+ parser.add_argument("--output", default="/tmp/perfetto_parsed.json", help="JSON de saída")
358
+ parser.add_argument("--package", default="", help="Package do app (ex: com.example.app)")
359
+ args = parser.parse_args()
360
+
361
+ if not os.path.exists(args.input):
362
+ print(f"ERRO: Arquivo não encontrado: {args.input}", file=sys.stderr)
363
+ sys.exit(1)
364
+
365
+ TraceProcessor, TraceProcessorConfig = try_import()
366
+
367
+ size_mb = os.path.getsize(args.input) / 1024 / 1024
368
+ print(f"Trace: {args.input} ({size_mb:.1f} MB)")
369
+
370
+ # load_timeout precisa ser proporcional ao tamanho do trace
371
+ # padrão de 2s é insuficiente para traces > ~5 MB
372
+ timeout_seconds = max(60, int(size_mb * 2))
373
+ print(f"Abrindo trace (timeout: {timeout_seconds}s)...")
374
+
375
+ config = TraceProcessorConfig(load_timeout=timeout_seconds)
376
+ try:
377
+ tp = TraceProcessor(trace=args.input, config=config)
378
+ except Exception as e:
379
+ print(f"ERRO ao abrir trace: {e}", file=sys.stderr)
380
+ print("Verifique se o arquivo .pb não está corrompido.", file=sys.stderr)
381
+ sys.exit(1)
382
+
383
+ print("Extraindo memória...")
384
+ memory = extract_memory(tp)
385
+
386
+ print("Extraindo CPU...")
387
+ cpu = extract_cpu(tp)
388
+
389
+ print("Extraindo frames...")
390
+ frames = extract_frames(tp)
391
+
392
+ print("Extraindo battery...")
393
+ battery = extract_battery(tp)
394
+
395
+ duration = get_trace_duration(tp)
396
+
397
+ if args.package:
398
+ print("Extraindo callstacks do app...")
399
+ app_stacks = extract_app_callstacks(tp, args.package)
400
+ cpu["app_callstacks"] = app_stacks
401
+
402
+ # Para frames janked: mesma lógica mas restrita à janela de tempo do jank
403
+ prefix = _app_prefix(args.package)
404
+ pkg = args.package
405
+ jank_app = query_to_dicts(tp, f"""
406
+ WITH jank_windows AS (
407
+ SELECT aft.ts AS frame_start, aft.ts_end AS frame_end
408
+ FROM actual_frame_timeline_slice aft
409
+ WHERE aft.on_time_finish = 0
410
+ ),
411
+ jank_samples AS (
412
+ SELECT DISTINCT ps.id AS sample_id
413
+ FROM perf_sample ps
414
+ JOIN thread t ON t.utid = ps.utid
415
+ JOIN process p ON p.upid = t.upid
416
+ JOIN jank_windows jw ON ps.ts >= jw.frame_start AND ps.ts <= jw.frame_end
417
+ WHERE p.name = '{pkg}'
418
+ ),
419
+ callsite_chain(sample_id, frame_id, parent_id, depth) AS (
420
+ SELECT js.sample_id, spc.frame_id, spc.parent_id, 1
421
+ FROM jank_samples js
422
+ JOIN perf_sample ps ON ps.id = js.sample_id
423
+ JOIN stack_profile_callsite spc ON spc.id = ps.callsite_id
424
+ UNION ALL
425
+ SELECT cc.sample_id, spc.frame_id, spc.parent_id, cc.depth + 1
426
+ FROM callsite_chain cc
427
+ JOIN stack_profile_callsite spc ON spc.id = cc.parent_id
428
+ WHERE cc.depth < 60
429
+ ),
430
+ top_app AS (
431
+ SELECT cc.sample_id, MIN(cc.depth) AS min_depth
432
+ FROM callsite_chain cc
433
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
434
+ WHERE spf.name LIKE '{prefix}.%'
435
+ GROUP BY cc.sample_id
436
+ )
437
+ SELECT
438
+ spf.name AS function_name,
439
+ t.name AS thread_name,
440
+ COUNT(DISTINCT cc.sample_id) AS sample_count
441
+ FROM callsite_chain cc
442
+ JOIN top_app ta ON ta.sample_id = cc.sample_id AND cc.depth = ta.min_depth
443
+ JOIN stack_profile_frame spf ON spf.id = cc.frame_id
444
+ JOIN perf_sample ps ON ps.id = cc.sample_id
445
+ JOIN thread t ON t.utid = ps.utid
446
+ GROUP BY spf.name, t.name
447
+ ORDER BY sample_count DESC
448
+ LIMIT 20
449
+ """)
450
+ frames["jank_app_callstacks"] = jank_app
451
+
452
+ tp.close()
453
+
454
+ output = {
455
+ "trace_duration_seconds": round(float(duration), 2),
456
+ "memory": memory,
457
+ "cpu": cpu,
458
+ "frames": frames,
459
+ "battery": battery,
460
+ }
461
+
462
+ with open(args.output, "w") as f:
463
+ json.dump(output, f, indent=2, default=str)
464
+
465
+ print(f"\nDados extraídos: {args.output}")
466
+
467
+
468
+ if __name__ == "__main__":
469
+ main()
@@ -0,0 +1 @@
1
+ perfetto>=0.0.6