Files
Ignacy Łątka 1f69f5fedd fix(profiler): make Android native-profiler analysis accurate (#338)
## Summary

Found while exercising the new Android (Perfetto) native profiler
end-to-end on an emulator: the **measurement** is solid, but the
**interpretation** layer didn't account for the emulator environment or
its own signals, so it surfaced misleading findings/advice.

This makes the Android analysis honest:

- **CPU hotspots:** on the emulator the hottest frames are the
goldfish/QEMU GPU-transport pipe and Linux kernel syscalls
(`goldfish_pipe_read_write`, `gup_fast_fallback`, `do_syscall_64`), not
app code. They were presented as actionable app hotspots with `reduce
view hierarchy depth` advice. Now they're classified and labelled as
emulator/kernel overhead, with advice that says so (and to re-profile on
a real device); real app frames get actionable advice.
- **UI hangs:** advice was always `move heavy work to background queue`,
even when the printed main-thread state breakdown showed the thread was
**sleeping** (state `S`) for the whole hang — i.e. a GPU/vsync wait, not
CPU work. Advice is now derived from the state breakdown (blocked /
runnable / executing).
- **`hang_stacks`:** an off-CPU (sleeping) hang has no on-CPU stack, so
the section was silently empty. It now explains the empty result and
points at the state breakdown.

## Why

The advice directly contradicted the tool's own state-breakdown data,
and the CPU section was dominated by emulator noise — both erode trust
in the report.

## Test plan

- New `test/android-perfetto/analysis-advice.test.ts` and
`hang-stacks-offcpu.test.ts`.
- `npm test -w @argent/tool-server` — all pass; `tsc --build` +
`typecheck:tests` clean.

> Part of a set of native-profiler fixes found in the same session.
Touches the shared `render.ts` advice paths and the Android
`hang_stacks` renderer.
2026-06-23 10:36:50 +02:00

190 lines
6.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
aggregateCpuHotspots,
type AggregatorInputRow,
} from "../../src/utils/profiler-shared/aggregate";
describe("aggregateCpuHotspots (shared)", () => {
it("returns [] when given no rows", () => {
expect(aggregateCpuHotspots([], { platform: "ios" })).toEqual([]);
});
it("filters out groups below the 3% weight floor", () => {
const rows: AggregatorInputRow[] = [
// 95% of weight — main hotspot
{
dominantFunction: "hotFn",
thread: "Main Thread",
weightNs: 95_000_000,
timestampsNs: [10_000_000],
callChains: [{ chain: ["hotFn"], count: 1 }],
},
// 5% noise — surfaces as a YELLOW hotspot
{
dominantFunction: "warmFn",
thread: "Main Thread",
weightNs: 5_000_000,
timestampsNs: [20_000_000],
callChains: [{ chain: ["warmFn"], count: 1 }],
},
// 0.1% — should be dropped
{
dominantFunction: "coldFn",
thread: "Main Thread",
weightNs: 100_000,
timestampsNs: [30_000_000],
callChains: [{ chain: ["coldFn"], count: 1 }],
},
];
const out = aggregateCpuHotspots(rows, { platform: "android" });
const fns = out.map((b) => b.dominantFunction);
expect(fns).toContain("hotFn");
expect(fns).toContain("warmFn");
expect(fns).not.toContain("coldFn");
});
it("emits severity RED above 15% and YELLOW between 3-15%", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "bigFn",
thread: "T",
weightNs: 50_000_000,
timestampsNs: [1],
callChains: [],
},
{
dominantFunction: "smallFn",
thread: "T",
weightNs: 5_000_000,
timestampsNs: [1],
callChains: [],
},
];
const out = aggregateCpuHotspots(rows, { platform: "android" });
const big = out.find((b) => b.dominantFunction === "bigFn")!;
const small = out.find((b) => b.dominantFunction === "smallFn")!;
expect(big.severity).toBe("RED");
expect(small.severity).toBe("YELLOW");
});
it("stamps platform field on the emitted hotspot", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "fn",
thread: "Main Thread",
weightNs: 100_000_000,
timestampsNs: [1],
callChains: [],
},
];
const ios = aggregateCpuHotspots(rows, { platform: "ios" });
const android = aggregateCpuHotspots(rows, { platform: "android" });
expect(ios[0]!.platform).toBe("ios");
expect(android[0]!.platform).toBe("android");
});
it("breaks samples into burst windows on >500ms gaps", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "fn",
thread: "Main Thread",
weightNs: 100_000_000,
// Two clusters: [0, 100ms, 200ms] then [1000ms, 1100ms]
timestampsNs: [0, 100_000_000, 200_000_000, 1_000_000_000, 1_100_000_000],
callChains: [],
},
];
const out = aggregateCpuHotspots(rows, { platform: "android" });
expect(out[0]!.burstWindows.length).toBe(2);
expect(out[0]!.burstWindows[0]!.sampleCount).toBe(3);
expect(out[0]!.burstWindows[1]!.sampleCount).toBe(2);
});
it("uses SQL-precomputed bursts/first/last/count when present (Android path)", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "doFrame",
thread: "Main Thread",
weightNs: 100_000_000,
// Android passes an EMPTY timestamp array — the burst/first/last/count
// are precomputed SQL-side and must be used verbatim (not re-derived).
timestampsNs: [],
callChains: [{ chain: ["doFrame"], count: 10 }],
precomputedBursts: [
// Deliberately out of order — the aggregator must sort by start.
{ startMs: 900, endMs: 1100, sampleCount: 2 },
{ startMs: 0, endMs: 200, sampleCount: 8 },
],
firstMs: 0,
lastMs: 1100,
sampleCount: 10,
},
];
const out = aggregateCpuHotspots(rows, { platform: "android" });
expect(out).toHaveLength(1);
const hot = out[0]!;
expect(hot.sampleCount).toBe(10);
expect(hot.timeRangeMs).toEqual({ first: 0, last: 1100 });
expect(hot.burstWindows).toEqual([
{ startMs: 0, endMs: 200, sampleCount: 8 },
{ startMs: 900, endMs: 1100, sampleCount: 2 },
]);
// No timestamps + empty hang set → never flagged as during-hang.
expect(hot.duringHang).toBe(false);
});
it("carries dominantMapping through to the emitted hotspot (Android)", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "writel",
dominantMapping: "/kernel",
thread: "Main Thread",
weightNs: 100_000_000,
timestampsNs: [],
callChains: [{ chain: ["writel"], count: 1 }],
precomputedBursts: [{ startMs: 0, endMs: 10, sampleCount: 1 }],
firstMs: 0,
lastMs: 10,
sampleCount: 1,
},
];
const out = aggregateCpuHotspots(rows, { platform: "android" });
expect(out[0]!.dominantMapping).toBe("/kernel");
});
it("omits the dominantMapping key entirely on the iOS path (shape unchanged)", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "fn",
thread: "Main Thread",
weightNs: 100_000_000,
timestampsNs: [1],
callChains: [],
},
];
const out = aggregateCpuHotspots(rows, { platform: "ios" });
// Not just `=== undefined`: the key must be ABSENT so the iOS object shape
// is byte-identical to before this change.
expect("dominantMapping" in out[0]!).toBe(false);
});
it("flags duringHang when any sample timestamp matches the hang set", () => {
const rows: AggregatorInputRow[] = [
{
dominantFunction: "fn",
thread: "T",
weightNs: 100_000_000,
timestampsNs: [50, 100, 150],
callChains: [],
},
];
const hung = aggregateCpuHotspots(rows, {
platform: "ios",
hangSampleTimestamps: new Set([100]),
});
const clean = aggregateCpuHotspots(rows, { platform: "ios" });
expect(hung[0]!.duringHang).toBe(true);
expect(clean[0]!.duringHang).toBe(false);
});
});