Skip to content

Commit dd76614

Browse files
committed
Add profiling support to memscope
1 parent 8117dde commit dd76614

8 files changed

Lines changed: 875 additions & 16 deletions

File tree

ui/src/plugins/dev.perfetto.Memscope/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default class implements PerfettoPlugin {
5252
} else {
5353
return m(ConnectionPage, {
5454
onConnected: (result) => {
55-
session = new LiveSession(result);
55+
session = new LiveSession(app, result);
5656
session.onSnapshot(() => m.redraw());
5757
},
5858
});

ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
2121
import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session';
2222
import {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session';
2323
import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';
24+
import {App} from '../../../public/app';
2425
import {ConnectionResult} from '../views/connection';
26+
import {
27+
ProfileSession,
28+
createProcessProfileSession,
29+
type ProfileState,
30+
} from './profile_session';
2531

2632
const SNAPSHOT_INTERVAL_MS = 3_000; // How over to take a snapshot of the runnign trace and extract data.
2733
const INITIAL_SNAPSHOT_INTERVAL_MS = 1_000; // Use a shorter interval for the first snapshot to get data on screen faster.
@@ -83,6 +89,7 @@ export type OnSnapshotCallback = (data: SnapshotData) => void;
8389
* notifies registered callbacks.
8490
*/
8591
export class LiveSession {
92+
private readonly app: App;
8693
private session?: TracingSession;
8794
private engine?: WasmEngineProxy;
8895
private readonly sessionName: string;
@@ -113,7 +120,48 @@ export class LiveSession {
113120
// True when the last snapshot took longer than the configured interval.
114121
snapshotOverrun = false;
115122

116-
constructor(conn: ConnectionResult) {
123+
// Active process profile (if any).
124+
activeProfile?: ProfileSession;
125+
private profileStartMs?: number;
126+
127+
/** The PID being profiled, or undefined if no profile is active. */
128+
get profilePid(): number | undefined {
129+
return this.activeProfile?.pid;
130+
}
131+
132+
/** The process name being profiled. */
133+
get profileProcessName(): string | undefined {
134+
return this.activeProfile?.processName;
135+
}
136+
137+
/** The x-axis timestamp (s relative to ts0) at which profiling started. */
138+
get profileStartX(): number | undefined {
139+
return this.activeProfile?.startX;
140+
}
141+
142+
/** The state of the active profile. */
143+
get profileState(): ProfileState | undefined {
144+
return this.activeProfile?.state;
145+
}
146+
147+
/** Whether a profile is currently active. */
148+
get isProfiling(): boolean {
149+
return this.activeProfile !== undefined;
150+
}
151+
152+
/** Formatted duration of the active profile, e.g. "1m 22s". Empty when stopping. */
153+
get profileDuration(): string {
154+
if (this.profileStartMs === undefined) return '';
155+
if (this.activeProfile?.state === 'stopping') return '';
156+
const elapsed = Math.floor((Date.now() - this.profileStartMs) / 1000);
157+
const mins = Math.floor(elapsed / 60);
158+
const secs = elapsed % 60;
159+
if (mins > 0) return `${mins}m ${secs}s`;
160+
return `${secs}s`;
161+
}
162+
163+
constructor(app: App, conn: ConnectionResult) {
164+
this.app = app;
117165
this.device = conn.device;
118166
this.linuxTarget = conn.linuxTarget;
119167
this.deviceName = conn.deviceName;
@@ -151,6 +199,48 @@ export class LiveSession {
151199
}
152200
}
153201

202+
/** Starts a heap profiling session for a single process. */
203+
async startProfile(pid: number, processName: string): Promise<void> {
204+
if (this.activeProfile) {
205+
await this.activeProfile.cancel();
206+
}
207+
this.activeProfile = await createProcessProfileSession(
208+
this.linuxTarget ?? this.device!,
209+
pid,
210+
processName,
211+
this.data?.xMax ?? 0,
212+
);
213+
this.profileStartMs = Date.now();
214+
}
215+
216+
/** Stops the active profile and opens the trace in the main UI. */
217+
async stopAndOpenProfile(): Promise<void> {
218+
const profile = this.activeProfile;
219+
if (!profile) return;
220+
const processName = profile.processName;
221+
const pid = profile.pid;
222+
await profile.stop();
223+
const traceData = profile.getTraceData();
224+
this.clearProfile();
225+
if (traceData) {
226+
const fileName = `heap-${processName}-${pid}.perfetto-trace`;
227+
const buffer = traceData.buffer as ArrayBuffer;
228+
this.app.openTraceFromBuffer({buffer, title: fileName, fileName});
229+
}
230+
}
231+
232+
/** Cancels the active profile and discards data. */
233+
async cancelProfile(): Promise<void> {
234+
if (!this.activeProfile) return;
235+
await this.activeProfile.cancel();
236+
this.clearProfile();
237+
}
238+
239+
private clearProfile(): void {
240+
this.activeProfile = undefined;
241+
this.profileStartMs = undefined;
242+
}
243+
154244
/** Stops the tracing session, polling, and disposes of the engine. */
155245
async dispose(): Promise<void> {
156246
// Mark disposed first so any in-flight poll() bails out before touching
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (C) 2026 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import protos from '../../../protos';
16+
import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
17+
import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session';
18+
import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';
19+
20+
const DUMP_INTERVAL_MS = 10_000;
21+
const PROC_STATS_BUFFER_SIZE_KB = 4 * 1024;
22+
const HEAPPROFD_BUFFER_SIZE_KB = 128 * 1024;
23+
const JAVA_HPROF_BUFFER_SIZE_KB = 256 * 1024;
24+
const STATS_POLL_INTERVAL_MS = 3000; // How often we poll the buffer size.
25+
26+
export type ProfileState = 'recording' | 'stopping' | 'finished' | 'error';
27+
28+
export interface ProfileSession {
29+
readonly pid: number;
30+
readonly processName: string;
31+
readonly startX: number;
32+
readonly state: ProfileState;
33+
readonly error?: string;
34+
readonly bufferUsagePct?: number;
35+
/** Stops recording and waits for the trace data to be ready. */
36+
stop(): Promise<void>;
37+
/** Cancels recording and discards trace data. */
38+
cancel(): Promise<void>;
39+
/** Returns the trace buffer once state is 'finished'. */
40+
getTraceData(): Uint8Array | undefined;
41+
}
42+
43+
export async function createProcessProfileSession(
44+
targetOrDevice: TracedWebsocketTarget | AdbDevice,
45+
pid: number,
46+
processName: string,
47+
startX: number,
48+
): Promise<ProfileSession> {
49+
const config = buildProcessProfileConfig(pid);
50+
const result =
51+
targetOrDevice instanceof TracedWebsocketTarget
52+
? await targetOrDevice.startTracing(config)
53+
: await createAdbTracingSession(targetOrDevice, config);
54+
if (!result.ok) {
55+
// TODO: Put this in the error state of the returned object
56+
return {
57+
pid,
58+
processName,
59+
startX,
60+
state: 'error',
61+
error: `Failed to start profile: ${result.error}`,
62+
async stop() {},
63+
async cancel() {},
64+
getTraceData() {
65+
return undefined;
66+
},
67+
};
68+
}
69+
70+
let bufferUsagePct: number | undefined;
71+
const session = result.value;
72+
let state: ProfileState = 'recording';
73+
let error: string | undefined = undefined;
74+
75+
const intervalHandle = setInterval(async () => {
76+
bufferUsagePct = await session.getBufferUsagePct();
77+
}, STATS_POLL_INTERVAL_MS);
78+
79+
session.onSessionUpdate.addListener(() => {
80+
if (session.state === 'FINISHED') {
81+
state = 'finished';
82+
} else if (session.state === 'ERRORED') {
83+
state = 'error';
84+
error = session.logs
85+
.filter((l) => l.isError)
86+
.map((l) => l.message)
87+
.join('; ');
88+
}
89+
});
90+
91+
return {
92+
pid,
93+
processName,
94+
startX,
95+
get state() {
96+
return state;
97+
},
98+
get bufferUsagePct() {
99+
return bufferUsagePct;
100+
},
101+
get error() {
102+
return error;
103+
},
104+
/** Stops recording and waits for the trace data to be ready. */
105+
async stop() {
106+
if (state !== 'recording') return;
107+
clearInterval(intervalHandle);
108+
state = 'stopping';
109+
await session.stop();
110+
// Wait for the session to reach FINISHED if it hasn't already.
111+
if (session.state !== 'FINISHED') {
112+
await new Promise<void>((resolve) => {
113+
const sub = session.onSessionUpdate.addListener(() => {
114+
if (session.state === 'FINISHED' || session.state === 'ERRORED') {
115+
sub[Symbol.dispose]();
116+
resolve();
117+
}
118+
});
119+
});
120+
}
121+
state = session.state === 'FINISHED' ? 'finished' : 'error';
122+
},
123+
async cancel(): Promise<void> {
124+
if (state !== 'recording') return;
125+
clearInterval(intervalHandle);
126+
state = 'error';
127+
await session.cancel();
128+
},
129+
/** Returns the trace buffer once state is 'finished'. */
130+
getTraceData(): Uint8Array | undefined {
131+
return session.getTraceData();
132+
},
133+
};
134+
}
135+
136+
/** Builds the TraceConfig for a single-process heap profiling session. */
137+
function buildProcessProfileConfig(pid: number): protos.ITraceConfig {
138+
return {
139+
compressionType:
140+
protos.TraceConfig.CompressionType.COMPRESSION_TYPE_DEFLATE,
141+
buffers: [
142+
{
143+
name: 'process_stats',
144+
sizeKb: PROC_STATS_BUFFER_SIZE_KB,
145+
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.DISCARD,
146+
},
147+
{
148+
name: 'heapprofd',
149+
sizeKb: HEAPPROFD_BUFFER_SIZE_KB,
150+
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
151+
},
152+
{
153+
name: 'java_hprof',
154+
sizeKb: JAVA_HPROF_BUFFER_SIZE_KB,
155+
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
156+
},
157+
],
158+
dataSources: [
159+
{
160+
config: {
161+
name: 'linux.process_stats',
162+
targetBufferName: 'process_stats',
163+
processStatsConfig: {
164+
scanAllProcessesOnStart: true, // Necessary for track names.
165+
},
166+
},
167+
},
168+
{
169+
config: {
170+
name: 'android.heapprofd',
171+
targetBufferName: 'heapprofd',
172+
heapprofdConfig: {
173+
pid: [pid],
174+
samplingIntervalBytes: 32 * 1024, // Slightly larger than default to avoid overhead.
175+
shmemSizeBytes: 16 * 1024 * 1024, // Arbitrary, could use default.
176+
blockClient: true, // Important for trace integrity.
177+
continuousDumpConfig: {
178+
dumpIntervalMs: DUMP_INTERVAL_MS, // Important for getting regular heap snapshots to see how memory usage evolves over time.
179+
},
180+
},
181+
},
182+
},
183+
{
184+
config: {
185+
name: 'android.java_hprof',
186+
targetBufferName: 'java_hprof',
187+
javaHprofConfig: {
188+
pid: [pid],
189+
continuousDumpConfig: {
190+
dumpIntervalMs: DUMP_INTERVAL_MS, // Required for Java profiles.
191+
},
192+
},
193+
},
194+
},
195+
],
196+
};
197+
}

ui/src/plugins/dev.perfetto.Memscope/styles.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
@import "./components/color_chip.scss";
1717
@import "./components/panel.scss";
1818
@import "./views/dashboard.scss";
19+
@import "./views/profile_page.scss";
1920

2021
.pf-memscope-page__container {
2122
display: flex;

0 commit comments

Comments
 (0)