blob: edda17d72da5b22121acad391d289c0bb1b6eb4c [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {PerfettoPlugin} from '../../public/plugin';
import type {Trace} from '../../public/trace';
import {TrackNode} from '../../public/workspace';
import {CounterTrack} from '../../components/tracks/counter_track';
import {SliceTrack} from '../../components/tracks/slice_track';
import {
BreakdownTrackAggType,
BreakdownTracks,
} from '../../components/tracks/breakdown_tracks';
import {uuidv4} from '../../base/uuid';
import {getTimeSpanOfSelectionOrVisibleWindow} from '../../public/utils';
import type {TimeSpan} from '../../base/time';
import {SourceDataset} from '../../trace_processor/dataset';
import {
LONG,
NUM,
NUM_NULL,
STR,
STR_NULL,
} from '../../trace_processor/query_result';
import {createPerfettoTable} from '../../trace_processor/sql_utils';
import {HSLColor} from '../../base/color';
import {makeColorScheme} from '../../components/colorizer';
import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
const KSWAPD_COLOR = makeColorScheme(new HSLColor('#2196F3')); // Blue 500
export default class MemoryViz implements PerfettoPlugin {
static readonly id = 'com.android.MemoryViz';
static readonly dependencies = [StandardGroupsPlugin];
async onTraceLoad(ctx: Trace): Promise<void> {
const memoryGroup = ctx.plugins
.getPlugin(StandardGroupsPlugin)
.getOrCreateStandardGroup(ctx.defaultWorkspace, 'MEMORY');
await ctx.engine.query(`
INCLUDE PERFETTO MODULE intervals.overlap;
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE android.memory.lmk;
`);
await this.addKswapdTrack(ctx, memoryGroup);
await this.addDirectReclaimTracks(ctx, memoryGroup);
await this.addLmkTracks(ctx, memoryGroup);
ctx.commands.registerCommand({
id: `com.android.visualizeMemory`,
name: 'Memory: Visualize (over selection)',
callback: async () => {
await ctx.engine.query(`
INCLUDE PERFETTO MODULE intervals.intersect;
INCLUDE PERFETTO MODULE android.memory.memory_breakdown;
`);
const window = await getTimeSpanOfSelectionOrVisibleWindow(ctx);
const tracks = [
['mem.rss.file', 'RSS File'],
['mem.rss.shmem', 'RSS Shmem'],
['mem.dmabuf_rss', 'DMA buffer RSS'],
['mem.heap', 'Heap Size'],
['mem.locked', 'Locked Memory'],
['GPU Memory', 'GPU Memory'],
];
const trackPromises: Promise<TrackNode | undefined>[] = [
this.createRssAnonSwapTrack(ctx, window),
];
trackPromises.push(
...tracks.map(([trackName, displayName]) =>
this.createBreakdownTrack(ctx, window, trackName, displayName),
),
);
const createdTracks = await Promise.all(trackPromises);
for (const track of createdTracks) {
if (track) {
ctx.defaultWorkspace.pinnedTracksNode.addChildLast(track);
}
}
},
});
}
private async addKswapdTrack(ctx: Trace, parent: TrackNode): Promise<void> {
const tableName = 'memory_viz_kswapd';
await createPerfettoTable({
engine: ctx.engine,
name: tableName,
as: `
SELECT
row_number() OVER (ORDER BY ts) AS id,
ts,
dur,
thread.name AS name
FROM sched
JOIN thread USING (utid)
WHERE thread.name GLOB 'kswapd0*' AND dur > 0
`,
});
const rowCount = await ctx.engine.query(
`SELECT COUNT(*) AS n FROM ${tableName}`,
);
if (rowCount.firstRow({n: NUM}).n === 0) {
return;
}
const uri = `${MemoryViz.id}#kswapd`;
ctx.tracks.registerTrack({
uri,
description:
'Shows when the background page reclaim daemon (kswapd) is running on a CPU. ',
renderer: SliceTrack.create({
trace: ctx,
uri,
dataset: new SourceDataset({
src: tableName,
schema: {id: NUM, ts: LONG, dur: LONG, name: STR},
}),
colorizer: () => KSWAPD_COLOR,
}),
});
parent.addChildInOrder(
new TrackNode({uri, name: 'Kswapd', sortOrder: 101}),
);
}
private async addDirectReclaimTracks(
ctx: Trace,
parent: TrackNode,
): Promise<void> {
const tableName = 'memory_viz_direct_reclaim';
await createPerfettoTable({
engine: ctx.engine,
name: tableName,
as: `
SELECT id, ts, dur, name, process_name, thread_name
FROM thread_slice
WHERE name GLOB 'mm_vmscan_direct_reclaim' AND dur > 0
`,
});
const rowCount = await ctx.engine.query(
`SELECT COUNT(*) AS n FROM ${tableName}`,
);
if (rowCount.firstRow({n: NUM}).n === 0) {
return;
}
const breakdowns = new BreakdownTracks({
trace: ctx,
trackTitle: 'Direct Reclaim',
description:
'Shows synchronous page reclaim events.' +
'This usually indicates severe memory pressure.',
aggregationType: BreakdownTrackAggType.COUNT,
aggregation: {
columns: ['process_name', 'thread_name'],
tsCol: 'ts',
durCol: 'dur',
tableName,
},
slice: {
columns: ['name'],
tsCol: 'ts',
durCol: 'dur',
tableName,
},
sliceIdColumn: 'id',
sortTracks: true,
});
const directReclaimNode = await breakdowns.createTracks();
directReclaimNode.sortOrder = 102;
parent.addChildInOrder(directReclaimNode);
}
private async addLmkTracks(ctx: Trace, parent: TrackNode): Promise<void> {
const tableName = 'memory_viz_lmk_slices';
await createPerfettoTable({
engine: ctx.engine,
name: tableName,
as: `
SELECT
row_number() OVER (ORDER BY ts) AS id,
ts,
0 AS dur,
upid,
pid,
COALESCE(process_name, 'Unknown') AS process_name,
oom_score_adj,
android_oom_adj_score_to_bucket_name(oom_score_adj) AS oom_bucket,
kill_reason
FROM android_lmk_events
`,
});
const buckets = await ctx.engine.query(`
SELECT DISTINCT oom_bucket
FROM ${tableName}
WHERE oom_bucket IS NOT NULL
ORDER BY oom_bucket
`);
if (buckets.numRows() === 0) {
return;
}
const lmkUri = `${MemoryViz.id}#lmk`;
ctx.tracks.registerTrack({
uri: lmkUri,
description:
'Shows Android Low Memory Killer (LMK) events. ' +
'Each event marks a process kill.',
renderer: SliceTrack.create({
trace: ctx,
uri: lmkUri,
dataset: new SourceDataset({
src: tableName,
schema: {
id: NUM,
ts: LONG,
dur: LONG,
upid: NUM_NULL,
pid: NUM_NULL,
process_name: STR,
oom_score_adj: NUM,
oom_bucket: STR,
kill_reason: STR_NULL,
},
}),
sliceName: (row) => row.process_name,
}),
});
const lmkGroup = new TrackNode({
uri: lmkUri,
name: 'LMK',
isSummary: true,
sortOrder: 100,
});
parent.addChildInOrder(lmkGroup);
for (const it = buckets.iter({oom_bucket: STR}); it.valid(); it.next()) {
const bucket = it.oom_bucket;
const uri = `${MemoryViz.id}#lmk.${bucket}`;
ctx.tracks.registerTrack({
uri,
description: `Low Memory Killer events for processes in the '${bucket}' OOM adjustment bucket.`,
renderer: SliceTrack.create({
trace: ctx,
uri,
dataset: new SourceDataset({
src: tableName,
schema: {
id: NUM,
ts: LONG,
dur: LONG,
upid: NUM_NULL,
pid: NUM_NULL,
process_name: STR,
oom_score_adj: NUM,
oom_bucket: STR,
kill_reason: STR_NULL,
},
filter: {col: 'oom_bucket', eq: bucket},
}),
sliceName: (row) => row.process_name,
}),
});
lmkGroup.addChildInOrder(new TrackNode({uri, name: bucket}));
}
}
private async createTrack(
ctx: Trace,
uri: string,
sqlSource: string,
name: string,
description: string,
removable = true,
sortOrder?: number,
): Promise<TrackNode> {
const track = CounterTrack.create({
trace: ctx,
uri,
sqlSource,
});
ctx.tracks.registerTrack({
uri,
renderer: track,
description,
});
return new TrackNode({
uri,
name,
removable,
sortOrder,
});
}
private async createRssAnonSwapTrack(
ctx: Trace,
window: TimeSpan,
): Promise<TrackNode | undefined> {
const uri = `${MemoryViz.id}.rss_anon_swap.${uuidv4()}`;
const sqlSource = this.getSqlSource(window, [
`memory_track_name IN ('mem.rss.anon', 'mem.swap')`,
]);
const rootNode = await this.createTrack(
ctx,
uri,
sqlSource,
'RSS Anon + Swap',
'Sum of mem.rss.anon and mem.swap memory across all processes.',
true,
);
const rssAnonNode = await this.createBreakdownTrack(
ctx,
window,
'mem.rss.anon',
'RSS Anon',
);
const swapNode = await this.createBreakdownTrack(
ctx,
window,
'mem.swap',
'Swap',
);
if (rssAnonNode) {
rootNode.addChildLast(rssAnonNode);
}
if (swapNode) {
rootNode.addChildLast(swapNode);
}
return rootNode;
}
private async createBreakdownTrack(
ctx: Trace,
window: TimeSpan,
trackName: string,
name: string,
): Promise<TrackNode | undefined> {
const uri = `${MemoryViz.id}.${trackName}.${uuidv4()}`;
const sqlSource = this.getSqlSource(window, [
`memory_track_name = '${trackName}'`,
]);
const breakdownNode = await this.createTrack(
ctx,
uri,
sqlSource,
name,
`Sum of ${trackName} memory across all processes.`,
true,
);
const buckets = await ctx.engine.query(`
SELECT DISTINCT bucket
FROM android_process_memory_intervals_by_oom_bucket
WHERE memory_track_name = '${trackName}'
ORDER BY bucket ASC
`);
for (const iter = buckets.iter({}); iter.valid(); iter.next()) {
const bucket = iter.get('bucket') as string;
const bucketNode = await this.createOomBucketTrack(
ctx,
window,
trackName,
bucket,
);
if (bucketNode) {
breakdownNode.addChildInOrder(bucketNode);
}
}
return breakdownNode;
}
private async createOomBucketTrack(
ctx: Trace,
window: TimeSpan,
trackName: string,
bucket: string,
): Promise<TrackNode | undefined> {
const processes = await ctx.engine.query(`
SELECT
upid,
pid,
process_name,
MAX(zygote_adjusted_value) AS max_value
FROM android_process_memory_intervals_by_oom_bucket
WHERE
bucket = '${bucket}' AND
memory_track_name = '${trackName}' AND
ts < ${window.end} AND
ts + dur > ${window.start}
GROUP BY upid, pid, process_name
HAVING max_value > 0
ORDER BY max_value DESC
`);
const numProcesses = processes.numRows();
if (numProcesses === 0) {
return undefined;
}
const plural = numProcesses === 1 ? '' : 'es';
const name = `${bucket} (${numProcesses} process${plural})`;
const uri = `${MemoryViz.id}.${trackName}.${bucket}.${uuidv4()}`;
const sqlSource = this.getSqlSource(window, [
`bucket = '${bucket}'`,
`memory_track_name = '${trackName}'`,
]);
const peak = await ctx.engine.query(
`SELECT MAX(value) AS peak FROM (${sqlSource})`,
);
const peakValue = peak.iter({}).get('peak') as number;
const bucketNode = await this.createTrack(
ctx,
uri,
sqlSource,
name,
`Sum of ${trackName} memory across all processes while in the '${
bucket
}' OOM bucket (0 otherwise).`,
true,
-peakValue, // Sort buckets in descending order of peak memory usage
);
for (
const procIter = processes.iter({});
procIter.valid();
procIter.next()
) {
const upid = procIter.get('upid') as number;
const pid = procIter.get('pid') as number;
const processName = procIter.get('process_name') as string;
const processNode = await this.createSingleProcessTrack(
ctx,
window,
upid,
pid,
processName,
trackName,
bucket,
);
bucketNode.addChildLast(processNode);
}
return bucketNode;
}
private async createSingleProcessTrack(
ctx: Trace,
window: TimeSpan,
upid: number,
pid: number,
processName: string,
trackName: string,
bucket: string,
): Promise<TrackNode> {
const name = `${processName} ${pid}`;
const uri = `${MemoryViz.id}.process.${upid}.${trackName}.${uuidv4()}`;
const sqlSource = this.getSqlSource(window, [
`bucket = '${bucket}'`,
`memory_track_name = '${trackName}'`,
`upid = ${upid}`,
]);
return this.createTrack(
ctx,
uri,
sqlSource,
name,
`Process ${processName} (${pid}) ${trackName} memory while in the '${
bucket
}' OOM bucket (0 otherwise).`,
true,
);
}
private getSqlSource(window: TimeSpan, whereClauses: string[] = []): string {
const whereClause =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
return `
SELECT iss.ts, SUM(IIF(iss.interval_ends_at_ts = FALSE, m.zygote_adjusted_value, 0)) as value FROM interval_self_intersect!((
SELECT
id,
MAX(ts, ${window.start}) as ts,
MIN(ts + dur, ${window.end}) - MAX(ts, ${window.start}) as dur
FROM android_process_memory_intervals_by_oom_bucket
${whereClause} AND ts < ${window.end} and ts + dur > ${window.start}
)) iss JOIN android_process_memory_intervals_by_oom_bucket m USING(id)
GROUP BY group_id
`;
}
}