blob: 3fb6be84d84f61235b4c96a661238134958bae84 [file] [edit]
// 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 {addDebugSliceTrack} from '../../components/tracks/debug_tracks';
import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {TrackNode} from '../../public/workspace';
import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
import {uuidv4} from '../../base/uuid';
import {getTimeSpanOfSelectionOrVisibleWindow} from '../../public/utils';
import {TimeSpan} from '../../base/time';
export default class MemoryViz implements PerfettoPlugin {
static readonly id = 'com.android.MemoryViz';
async onTraceLoad(ctx: Trace): Promise<void> {
ctx.commands.registerCommand({
id: 'com.android.visualizeMemoryReclaim',
name: 'Memory: reclaim events',
callback: async () => {
await ctx.engine.query(`
INCLUDE PERFETTO MODULE intervals.overlap;
INCLUDE PERFETTO MODULE intervals.intersect;
INCLUDE PERFETTO MODULE slices.with_context;
`);
await addDebugSliceTrack({
trace: ctx,
data: {
sqlSource: `
-- 1. Get and merge Direct Reclaim slices globally.
WITH direct_reclaim_merged AS (
SELECT
m.ts,
m.dur
FROM interval_merge_overlapping!(
(
SELECT ts, dur
FROM thread_slice
WHERE name LIKE 'mm_vmscan_direct_reclaim'
),
0
) m
WHERE m.dur > 0
),
-- 2. Get and merge kswapd0 thread slices.
kswapd_slices AS (
SELECT
ts,
dur
FROM sched
JOIN thread
USING (utid)
WHERE
thread.name = 'kswapd0' AND
dur > 0
),
-- 3. Combine both sources with priorities and unique IDs for intersection.
all_intervals AS (
SELECT
*, row_number() OVER () AS id
FROM (
SELECT
*, 1 AS priority
FROM direct_reclaim_merged
UNION ALL
SELECT
*, 0 AS priority
FROM kswapd_slices
)
),
-- 4. Calculate sub-intervals where the source intervals overlap.
intersected AS (
SELECT
ii.ts,
ii.dur,
ii.group_id,
ii.id
FROM interval_self_intersect!(all_intervals) ii
WHERE ii.interval_ends_at_ts = FALSE
),
-- 5. For each piece of time (group_id), pick the source with the highest priority.
final AS (
SELECT
ii.ts,
ii.dur,
CASE WHEN MAX(ai.priority) = 1 THEN 'direct reclaim' ELSE 'kswapd0' END AS name
FROM intersected ii
JOIN all_intervals ai ON ii.id = ai.id
GROUP BY ii.group_id
)
-- 6. Re-merge same-type intervals fragmented by the self-intersect.
SELECT ts, dur, name FROM interval_merge_overlapping_partitioned!(
final,
(name)
)
`,
},
title: 'Kswapd0 / Direct Reclaim',
});
await ctx.engine.query(`
INCLUDE PERFETTO MODULE android.memory.lmk;
`);
await addDebugSliceTrack({
trace: ctx,
data: {
sqlSource: `
SELECT
ts,
0 as dur,
upid,
pid,
process_name,
oom_score_adj,
android_oom_adj_score_to_bucket_name(oom_score_adj) AS oom_bucket,
kill_reason
FROM android_lmk_events
`,
},
title: 'LMK',
columns: {
name: 'process_name',
},
pivotOn: 'oom_bucket',
});
},
});
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 createTrack(
ctx: Trace,
uri: string,
sqlSource: string,
name: string,
description: string,
removable = true,
sortOrder?: number,
): Promise<TrackNode> {
const track = await createQueryCounterTrack({
trace: ctx,
uri,
materialize: false,
data: {
sqlSource,
},
columns: {
ts: 'ts',
value: 'value',
},
});
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
`;
}
}