blob: 2ca1825371b94be245fbb6b2bebb5c7d2190abbd [file] [log] [blame] [edit]
// Copyright (C) 2024 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 {addDebugCounterTrack} from '../../components/tracks/debug_tracks';
import {Time} from '../../base/time';
import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {NUM} from '../../trace_processor/query_result';
import {randomColor} from '../../components/colorizer';
import {
createView,
createVirtualTable,
createPerfettoTable,
DisposableSqlEntity,
} from '../../trace_processor/sql_utils';
import {AsyncDisposableStack} from '../../base/disposable_stack';
const CREATE_BREAKDOWN_TABLE_SQL = `
DROP TABLE IF EXISTS process_memory_breakdown;
CREATE VIRTUAL TABLE process_memory_breakdown
USING
SPAN_OUTER_JOIN(
android_gpu_memory_per_process PARTITIONED upid,
memory_rss_and_swap_per_process PARTITIONED upid
);
`;
const MAX_AGGREGATED_PIDS = 5;
export default class implements PerfettoPlugin {
static readonly id = 'com.google.PixelMemory';
private tablesInitialized = false;
// Helper to set up the breakdown tables idempotently.
private async setupTables(ctx: Trace) {
if (this.tablesInitialized) {
return;
}
await ctx.engine.query('INCLUDE PERFETTO MODULE android.gpu.memory;');
await ctx.engine.query('INCLUDE PERFETTO MODULE linux.memory.process;');
await ctx.engine.query(CREATE_BREAKDOWN_TABLE_SQL);
this.tablesInitialized = true;
}
// Helper to find the max value from a SQL query and add a note to the
// timeline.
private async addMaxMemoryAnnotation(
ctx: Trace,
findMaxSql: string,
noteTarget: string,
) {
try {
const maxResult = await ctx.engine.query(findMaxSql);
// Using .firstRow() as LIMIT 1 is in the SQL
if (maxResult.numRows() > 0) {
const maxRow = maxResult.firstRow({ts: NUM, value: NUM});
const maxTs = BigInt(maxRow.ts);
const maxValueInBytes = maxRow.value;
const maxValueInKib = (maxValueInBytes / 1024.0).toFixed(2);
const noteText = `${maxValueInKib} KiB : Max PID (${noteTarget})`;
const color = randomColor();
ctx.notes.addNote({
timestamp: Time.fromRaw(maxTs),
text: noteText,
color,
});
}
} catch (e) {
console.error('Failed to add max memory annotation:', e);
}
}
// Helper function to handle the aggregation logic for multiple PIDs.
private async createAggregatedTrackAndGetTable(
ctx: Trace,
id: string,
pidList: string[],
sqlValueExpr: string,
titleSuffix: string,
pidsIdentifier: string,
trash: AsyncDisposableStack,
): Promise<string> {
const runId = id.replace(/[#\.]/g, '_');
const valueColNames = pidList.map((pid) => `value_${pid}`);
const aggTableName = `__agg_${runId}`;
// 1. Create a separate VIEW for each PID's memory data.
const pidViews: DisposableSqlEntity[] = [];
for (let i = 0; i < pidList.length; i++) {
const pid = pidList[i];
const viewName = `__view_${runId}_${pid}`;
const createViewAs = `
SELECT
ts,
dur,
(${sqlValueExpr}) AS ${valueColNames[i]}
FROM process_memory_breakdown
WHERE pid = ${pid};
`;
const view = await createView({
engine: ctx.engine,
name: viewName,
as: createViewAs,
});
pidViews.push(view);
trash.use(view);
}
const viewNames = pidViews.map((v) => v.name);
// 2. Iteratively SPAN_OUTER_JOIN the views together.
let previousTableName = viewNames[0];
for (let i = 1; i < pidList.length; i++) {
const newJoinedTableName = `__joined_${runId}_${i}`;
const joinUsing = `SPAN_OUTER_JOIN(${previousTableName}, ${viewNames[i]})`;
const joinedTable = await createVirtualTable({
engine: ctx.engine,
name: newJoinedTableName,
using: joinUsing,
});
trash.use(joinedTable);
previousTableName = joinedTable.name;
}
const finalSelectTable = previousTableName;
const sumOfValues = valueColNames
.map((col) => `IFNULL(${col}, 0)`)
.join(' + ');
// 3. Materialize the aggregated sum into a PERFETTO table
const createAggTableAs = `
SELECT
CAST(ts AS BIGINT) AS ts,
(${sumOfValues}) AS value
FROM ${finalSelectTable}
WHERE ts IS NOT NULL;
`;
const aggTable = await createPerfettoTable({
engine: ctx.engine,
name: aggTableName,
as: createAggTableAs,
});
trash.use(aggTable);
// 4. Add the debug track using the materialized aggregate table
await addDebugCounterTrack({
trace: ctx,
data: {
sqlSource: `SELECT ts, value FROM ${aggTableName} ORDER BY ts`,
columns: ['ts', 'value'],
},
title: `${pidsIdentifier}${titleSuffix}`,
});
// 5. Return the aggregate table name
return aggTableName;
}
// Prepares the SQL and target name for the max memory annotation.
private async prepareAnnotationData(
ctx: Trace,
id: string,
pidList: string[],
sqlValueExpr: string,
titleSuffix: string,
trash: AsyncDisposableStack,
): Promise<{findMaxSql: string; noteTarget: string}> {
if (pidList.length > 1) {
const pidsIdentifierForTracks = pidList.join('_');
const noteTarget = pidList.join('+');
const aggTableName = await this.createAggregatedTrackAndGetTable(
ctx,
id,
pidList,
sqlValueExpr,
titleSuffix,
pidsIdentifierForTracks,
trash,
);
const findMaxSql = `
SELECT ts, value
FROM ${aggTableName}
WHERE value IS NOT NULL
ORDER BY value DESC, ts ASC
LIMIT 1
`;
return {findMaxSql, noteTarget};
} else {
// pidList.length === 1
const noteTarget = pidList[0];
const findMaxSql = `
SELECT
ts,
(${sqlValueExpr}) AS value
FROM process_memory_breakdown
WHERE pid = ${pidList[0]} AND value IS NOT NULL
ORDER BY value DESC, ts ASC
LIMIT 1
`;
return {findMaxSql, noteTarget};
}
}
// Helper to register a command that adds a memory counter track.
private registerMemoryCommand(
ctx: Trace,
id: string,
name: string,
sqlValueExpr: string,
titleSuffix: string,
) {
ctx.commands.registerCommand({
id,
name,
callback: async (pidsArg) => {
// Use the PIDs argument if provided, otherwise prompt.
const pids =
typeof pidsArg === 'string'
? pidsArg
: await ctx.omnibox.prompt(
`Enter up to ${MAX_AGGREGATED_PIDS} process pids, separated by commas (e.g. 1234, 5678)`,
);
if (!pids) return;
const pidList = pids
.split(',')
.map((pid) => pid.trim())
.filter((pid) => pid);
if (pidList.length === 0) {
return;
}
if (pidList.length > MAX_AGGREGATED_PIDS) {
alert(
`Please enter at most ${MAX_AGGREGATED_PIDS} PIDs. You entered ${pidList.length}.`,
);
return;
}
const trash = new AsyncDisposableStack();
await using _disposer = trash;
try {
await this.setupTables(ctx);
// Add individual tracks for each PID.
for (const pid of pidList) {
await addDebugCounterTrack({
trace: ctx,
data: {
sqlSource: `
SELECT
ts,
(${sqlValueExpr}) AS value
FROM process_memory_breakdown
WHERE pid = ${pid}
`,
columns: ['ts', 'value'],
},
title: `${pid}${titleSuffix}`,
});
}
const {findMaxSql, noteTarget} = await this.prepareAnnotationData(
ctx,
id,
pidList,
sqlValueExpr,
titleSuffix,
trash,
);
await this.addMaxMemoryAnnotation(ctx, findMaxSql, noteTarget);
} catch (e) {
console.error(`PixelMemory Plugin: Error in command ${id}:`, e);
alert(
`PixelMemory Plugin Error: ${e instanceof Error ? e.message : e}`,
);
}
},
});
}
async onTraceLoad(ctx: Trace): Promise<void> {
this.registerMemoryCommand(
ctx,
'com.google.ShowPixelTotalMemory',
'Add tracks: show process total memory',
'COALESCE(rss_and_swap, 0) + COALESCE(gpu_memory, 0)',
'_rss_anon_file_swap_shmem_gpu',
);
this.registerMemoryCommand(
ctx,
'com.google.ShowPixelRssAnonShmemSwapGpuMemory',
'Add tracks: show process total memory (excluding file RSS)',
'COALESCE(anon_rss_and_swap, 0) + COALESCE(shmem_rss, 0) + ' +
'COALESCE(gpu_memory, 0)',
'_rss_anon_shmem_swap_gpu',
);
}
}