mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1f69f5fedd
## 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.
190 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|