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.
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
classifyNativeFrame,
|
|
summarizeHangBlocking,
|
|
} from "../../src/utils/profiler-shared/native-frame-class";
|
|
import { renderNativeProfilerReport } from "../../src/utils/ios-profiler/render";
|
|
import type { CpuHotspot, UiHang, ProfilerPayload } from "../../src/utils/profiler-shared/types";
|
|
|
|
describe("classifyNativeFrame", () => {
|
|
it("flags goldfish/QEMU emulator GPU-transport frames as system", () => {
|
|
expect(classifyNativeFrame("goldfish_pipe_read_write")).toBe("system");
|
|
expect(classifyNativeFrame("qemu_pipe_read")).toBe("system");
|
|
});
|
|
|
|
it("flags Linux kernel syscall/mm frames as system", () => {
|
|
expect(classifyNativeFrame("do_syscall_64")).toBe("system");
|
|
expect(classifyNativeFrame("gup_fast_fallback")).toBe("system");
|
|
expect(classifyNativeFrame("__x64_sys_read")).toBe("system");
|
|
});
|
|
|
|
it("matches a system frame even when the leaf name is C++-mangled", () => {
|
|
// perf stores mangled names; the bare symbol still appears as a substring.
|
|
expect(classifyNativeFrame("_Z23__pthread_internal_findlPKc")).toBe("system");
|
|
});
|
|
|
|
it("flags arm64 (aarch64) kernel syscall-entry + exception-vector leaves as system", () => {
|
|
// The Android profiler runs on an arm64 emulator/device, whose kernel
|
|
// entry-path symbols are named entirely differently from x86-64: the EL0
|
|
// synchronous-exception vector dispatches to the SVC (syscall) handler,
|
|
// which calls invoke_syscall → __arm64_sys_<name>. None of the x86 patterns
|
|
// (do_syscall_64 / entry_SYSCALL_64 / x64_sys_call) match these, so without
|
|
// arm64 patterns these kernel leaves were misclassified as `app` and got
|
|
// app-flavoured advice. arch/arm64/kernel/{entry.S,entry-common.c,syscall.c}.
|
|
for (const leaf of [
|
|
"el0t_64_sync",
|
|
"el0t_64_sync_handler",
|
|
"el0_svc",
|
|
"el0_svc_common",
|
|
"do_el0_svc",
|
|
"invoke_syscall",
|
|
"el0_da", // data-abort (page-fault) handler
|
|
"el0_ia", // instruction-abort handler
|
|
"__arm64_sys_read",
|
|
"__arch_copy_from_user", // arm64 uaccess copy helpers (copy_{from,to}_user.S)
|
|
"__arch_copy_to_user",
|
|
]) {
|
|
expect(classifyNativeFrame(leaf), `${leaf} should be system`).toBe("system");
|
|
}
|
|
});
|
|
|
|
it("does not misclassify app frames that merely resemble arm64 kernel tokens", () => {
|
|
// The arm64 patterns are word-boundaried so distinctive kernel tokens do not
|
|
// bleed into legitimate app symbols (e.g. a bare `vectors` substring would
|
|
// collide with std::vector). Guard against that regressing.
|
|
for (const leaf of [
|
|
"std::vector<int>::push_back",
|
|
"myVectorsHelper",
|
|
"el0_svc_handler_factory_app",
|
|
"MainActivity.onCreate",
|
|
"nativeRender",
|
|
"folly::detail::function::FunctionTraits",
|
|
]) {
|
|
expect(classifyNativeFrame(leaf), `${leaf} should stay app`).toBe("app");
|
|
}
|
|
});
|
|
|
|
it("treats real app / RN frames as app code", () => {
|
|
expect(classifyNativeFrame("facebook::react::JSIExecutor::callFunction")).toBe("app");
|
|
expect(classifyNativeFrame("_ZN16GrDrawingManager5flushE")).toBe("app");
|
|
expect(classifyNativeFrame(null)).toBe("app");
|
|
});
|
|
});
|
|
|
|
describe("classifyNativeFrame — mapping-based classification", () => {
|
|
it("classes a kernel-mapped leaf as system even when NO name pattern matches", () => {
|
|
// These are real arm64-emulator hotspot leaves whose names match none of the
|
|
// SYSTEM_FRAME_PATTERNS (writel is a goldfish/QEMU MMIO write; mod_node_state
|
|
// is vmstat accounting), so they were misclassified as `app`. Without the
|
|
// mapping signal they are indistinguishable from app code by name alone.
|
|
expect(classifyNativeFrame("writel")).toBe("app"); // name-only: misses
|
|
expect(classifyNativeFrame("mod_node_state")).toBe("app"); // name-only: misses
|
|
// With the leaf's `/kernel` mapping, both are unambiguously system.
|
|
for (const leaf of [
|
|
"writel",
|
|
"mod_node_state",
|
|
"mod_node_page_state",
|
|
"try_to_wake_up",
|
|
"_raw_spin_unlock_irqrestore",
|
|
"arch_counter_get_cntvct",
|
|
]) {
|
|
expect(classifyNativeFrame(leaf, "/kernel"), `${leaf} @ /kernel should be system`).toBe(
|
|
"system"
|
|
);
|
|
}
|
|
});
|
|
|
|
it("accepts the common Perfetto/simpleperf kernel-mapping variants", () => {
|
|
expect(classifyNativeFrame("writel", "[kernel.kallsyms]")).toBe("system");
|
|
expect(classifyNativeFrame("writel", "kallsyms")).toBe("system");
|
|
expect(classifyNativeFrame("writel", "/some/path/kallsyms")).toBe("system");
|
|
});
|
|
|
|
it("falls back to name patterns for user-space (.so) mappings", () => {
|
|
// A user-space mapping is NOT the kernel, so the name patterns still decide:
|
|
// a gfxstream/goldfish encoder living in a .so is still system overhead...
|
|
expect(classifyNativeFrame("goldfish_pipe_read_write", "/vendor/lib64/libGLESv2_enc.so")).toBe(
|
|
"system"
|
|
);
|
|
expect(classifyNativeFrame("gfxstream_vk_thing", "/vendor/lib64/libvulkan_enc.so")).toBe(
|
|
"system"
|
|
);
|
|
// ...but an ordinary app/framework symbol in a .so stays app.
|
|
expect(classifyNativeFrame("_ZN12GrRenderTask10makeClosedEv", "/system/lib64/libhwui.so")).toBe(
|
|
"app"
|
|
);
|
|
expect(classifyNativeFrame("MainActivity.onCreate", "/data/app/com.example/lib/base.apk")).toBe(
|
|
"app"
|
|
);
|
|
});
|
|
|
|
it("does NOT treat a real module path as kernel (no over-broadening)", () => {
|
|
// The kernel test must not match real module paths, or every user-space leaf
|
|
// would be wrongly flagged system. A plain app symbol in a real module stays app.
|
|
for (const mapping of [
|
|
"/system/lib64/libhwui.so",
|
|
"/apex/com.android.art/lib64/libart.so",
|
|
"/vendor/lib64/libGLESv2_enc.so",
|
|
"/data/app/com.example/lib/base.apk",
|
|
]) {
|
|
expect(classifyNativeFrame("someAppSymbol", mapping), `${mapping} must not be kernel`).toBe(
|
|
"app"
|
|
);
|
|
}
|
|
});
|
|
|
|
it("is unchanged on the no-mapping (iOS) path", () => {
|
|
// iOS passes no mapping; classification must be byte-identical to name-only.
|
|
expect(classifyNativeFrame("writel")).toBe("app");
|
|
expect(classifyNativeFrame("writel", undefined)).toBe("app");
|
|
expect(classifyNativeFrame("goldfish_pipe_read_write")).toBe("system");
|
|
expect(classifyNativeFrame("goldfish_pipe_read_write", undefined)).toBe("system");
|
|
expect(classifyNativeFrame("facebook::react::JSIExecutor::callFunction", undefined)).toBe(
|
|
"app"
|
|
);
|
|
// A null/empty mapping must not crash and falls through to the name patterns.
|
|
expect(classifyNativeFrame("writel", null)).toBe("app");
|
|
expect(classifyNativeFrame("do_syscall_64", null)).toBe("system");
|
|
});
|
|
});
|
|
|
|
describe("summarizeHangBlocking", () => {
|
|
it("returns null with no state breakdown", () => {
|
|
expect(summarizeHangBlocking(undefined)).toBeNull();
|
|
expect(summarizeHangBlocking([])).toBeNull();
|
|
});
|
|
|
|
it("classifies a sleep-dominated hang as blocked", () => {
|
|
const r = summarizeHangBlocking([
|
|
{ state: "S", blockedFunction: null, durationMs: 44 },
|
|
{ state: "Running", blockedFunction: null, durationMs: 4 },
|
|
]);
|
|
expect(r).toEqual({ dominantState: "S", kind: "blocked" });
|
|
});
|
|
|
|
it("classifies a Running-dominated hang as executing", () => {
|
|
const r = summarizeHangBlocking([
|
|
{ state: "Running", blockedFunction: null, durationMs: 40 },
|
|
{ state: "S", blockedFunction: null, durationMs: 4 },
|
|
]);
|
|
expect(r?.kind).toBe("executing");
|
|
});
|
|
|
|
it("classifies an R/R+-dominated hang as runnable", () => {
|
|
const r = summarizeHangBlocking([{ state: "R+", blockedFunction: null, durationMs: 30 }]);
|
|
expect(r?.kind).toBe("runnable");
|
|
});
|
|
});
|
|
|
|
// --- render-level assertions -------------------------------------------------
|
|
|
|
function hotspot(over: Partial<CpuHotspot>): CpuHotspot {
|
|
return {
|
|
type: "cpu_hotspot",
|
|
platform: "android",
|
|
dominantFunction: "fn",
|
|
totalWeightMs: 100,
|
|
weightPercentage: 20,
|
|
sampleCount: 10,
|
|
thread: "RenderThread",
|
|
severity: "RED",
|
|
topCallChain: [],
|
|
topCallChains: [],
|
|
duringHang: false,
|
|
timeRangeMs: { first: 0, last: 1000 },
|
|
burstWindows: [],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function hang(over: Partial<UiHang>): UiHang {
|
|
return {
|
|
type: "ui_hang",
|
|
platform: "android",
|
|
hangType: "jank",
|
|
durationMs: 48,
|
|
startTimeFormatted: "00:05.732",
|
|
startNs: 0,
|
|
endNs: 48_000_000,
|
|
suspectedFunctions: [],
|
|
appCallChains: [],
|
|
severity: "RED",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
async function reportFor(bottlenecks: ProfilerPayload["bottlenecks"]): Promise<string> {
|
|
const payload: ProfilerPayload = {
|
|
metadata: { traceFile: null, platform: "Android", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
bottlenecks,
|
|
};
|
|
// traceFile null → no file write, inline report returned.
|
|
const { report } = await renderNativeProfilerReport({ payload, traceFile: null });
|
|
return report;
|
|
}
|
|
|
|
describe("renderNativeProfilerReport — Android advice", () => {
|
|
it("labels emulator/kernel CPU hotspots and does not give them app advice", async () => {
|
|
const report = await reportFor([
|
|
hotspot({ dominantFunction: "goldfish_pipe_read_write", frameClass: "system" }),
|
|
]);
|
|
expect(report).toContain("emulator/kernel");
|
|
expect(report).toMatch(/Re-profile on a real device/);
|
|
// The old blanket advice must not be attached to a system frame.
|
|
expect(report).not.toContain("reduce view hierarchy depth");
|
|
});
|
|
|
|
it("gives actionable advice for a real app CPU hotspot", async () => {
|
|
const report = await reportFor([
|
|
hotspot({ dominantFunction: "myHotFunction", thread: "JS/Hermes", frameClass: "app" }),
|
|
]);
|
|
expect(report).toContain("function_callers");
|
|
expect(report).not.toContain("emulator/kernel overhead");
|
|
});
|
|
|
|
it("tells the user a sleep-dominated hang is a wait, not heavy work", async () => {
|
|
const report = await reportFor([
|
|
hang({
|
|
jankReason: "App Deadline Missed",
|
|
stateBreakdown: [
|
|
{ state: "S", blockedFunction: null, durationMs: 44 },
|
|
{ state: "Running", blockedFunction: null, durationMs: 4 },
|
|
],
|
|
}),
|
|
]);
|
|
expect(report).toMatch(/off-CPU|waiting/);
|
|
expect(report).not.toContain("move heavy work to background queue");
|
|
});
|
|
|
|
it("still recommends moving work off-thread for a CPU-bound hang", async () => {
|
|
const report = await reportFor([
|
|
hang({
|
|
stateBreakdown: [{ state: "Running", blockedFunction: null, durationMs: 40 }],
|
|
}),
|
|
]);
|
|
expect(report).toMatch(/on-CPU|executing/);
|
|
expect(report).toContain("Move heavy work off the main thread");
|
|
});
|
|
|
|
it("Next Steps function_callers skips a leading system hotspot and picks the first app frame", async () => {
|
|
const report = await reportFor([
|
|
hotspot({ dominantFunction: "goldfish_pipe_read_write", frameClass: "system" }),
|
|
hotspot({ dominantFunction: "do_syscall_64", frameClass: "system" }),
|
|
hotspot({ dominantFunction: "myAppHotFunction", thread: "JS/Hermes", frameClass: "app" }),
|
|
]);
|
|
// The callers suggestion must not point at a system frame the report just
|
|
// flagged as not directly actionable.
|
|
expect(report).toContain("mode=`function_callers` function_name=`myAppHotFunction`");
|
|
expect(report).not.toContain("function_name=`goldfish_pipe_read_write`");
|
|
expect(report).not.toContain("function_name=`do_syscall_64`");
|
|
});
|
|
|
|
it("Next Steps omits function_callers when every hotspot is a system frame", async () => {
|
|
const report = await reportFor([
|
|
hotspot({ dominantFunction: "goldfish_pipe_read_write", frameClass: "system" }),
|
|
hotspot({ dominantFunction: "do_syscall_64", frameClass: "system" }),
|
|
]);
|
|
// All system → no actionable callers target, so omit the line entirely
|
|
// rather than contradict the "not directly actionable" note above.
|
|
expect(report).not.toContain("function_callers");
|
|
// Sibling Next Steps guidance is still present.
|
|
expect(report).toContain("mode=`thread_breakdown`");
|
|
});
|
|
|
|
it("Next Steps suggests the top hotspot on iOS (no frameClass) — unchanged", async () => {
|
|
const report = await reportFor([
|
|
hotspot({ platform: "ios", dominantFunction: "iosTopFn", frameClass: undefined }),
|
|
hotspot({ platform: "ios", dominantFunction: "iosSecondFn", frameClass: undefined }),
|
|
]);
|
|
// undefined frameClass is treated as non-system, so iOS keeps using [0].
|
|
expect(report).toContain("mode=`function_callers` function_name=`iosTopFn`");
|
|
expect(report).not.toContain("function_name=`iosSecondFn`");
|
|
});
|
|
});
|