blob: ae9474b3adb7012447ddf5e35375f50550d3a2ef [file] [log] [blame]
// 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 m from 'mithril';
import {AsyncLimiter} from '../../base/async_limiter';
import {AsyncDisposableStack} from '../../base/disposable_stack';
import {assertExists} from '../../base/logging';
import {Monitor} from '../../base/monitor';
import {uuidv4Sql} from '../../base/uuid';
import {Engine} from '../../trace_processor/engine';
import {
createPerfettoIndex,
createPerfettoTable,
} from '../../trace_processor/sql_utils';
import {
NUM,
NUM_NULL,
STR,
STR_NULL,
UNKNOWN,
} from '../../trace_processor/query_result';
import {
Flamegraph,
FlamegraphQueryData,
FlamegraphState,
FlamegraphView,
} from '../../widgets/flamegraph';
import {Trace} from '../trace';
export interface QueryFlamegraphColumn {
// The name of the column in SQL.
readonly name: string;
// The human readable name describing the contents of the column.
readonly displayName: string;
}
export interface AggQueryFlamegraphColumn extends QueryFlamegraphColumn {
// The aggregation to be run when nodes are merged together in the flamegraph.
//
// TODO(lalitm): consider adding extra functions here (e.g. a top 5 or similar).
readonly mergeAggregation: 'ONE_OR_NULL' | 'SUM';
}
export interface QueryFlamegraphMetric {
// The human readable name of the metric: will be shown to the user to change
// between metrics.
readonly name: string;
// The human readable SI-style unit of `selfValue`. Values will be shown to
// the user suffixed with this.
readonly unit: string;
// SQL statement which need to be run in preparation for being able to execute
// `statement`.
readonly dependencySql?: string;
// A single SQL statement which returns the columns `id`, `parentId`, `name`
// `selfValue`, all columns specified by `unaggregatableProperties` and
// `aggregatableProperties`.
readonly statement: string;
// Additional contextual columns containing data which should not be merged
// between sibling nodes, even if they have the same name.
//
// Examples include the mapping that a name comes from, the heap graph root
// type etc.
//
// Note: the name is always unaggregatable and should not be specified here.
readonly unaggregatableProperties?: ReadonlyArray<QueryFlamegraphColumn>;
// Additional contextual columns containing data which will be displayed to
// the user if there is no merging. If there is merging, currently the value
// will not be shown.
//
// Examples include the source file and line number.
readonly aggregatableProperties?: ReadonlyArray<AggQueryFlamegraphColumn>;
}
export interface QueryFlamegraphState {
state: FlamegraphState;
}
// Given a table and columns on those table (corresponding to metrics),
// returns an array of `QueryFlamegraphMetric` structs which can be passed
// in QueryFlamegraph's attrs.
//
// `tableOrSubquery` should have the columns `id`, `parentId`, `name` and all
// columns specified by `tableMetrics[].name`, `unaggregatableProperties` and
// `aggregatableProperties`.
export function metricsFromTableOrSubquery(
tableOrSubquery: string,
tableMetrics: ReadonlyArray<{name: string; unit: string; columnName: string}>,
dependencySql?: string,
unaggregatableProperties?: ReadonlyArray<QueryFlamegraphColumn>,
aggregatableProperties?: ReadonlyArray<AggQueryFlamegraphColumn>,
): QueryFlamegraphMetric[] {
const metrics = [];
for (const {name, unit, columnName} of tableMetrics) {
metrics.push({
name,
unit,
dependencySql,
statement: `
select *, ${columnName} as value
from ${tableOrSubquery}
`,
unaggregatableProperties,
aggregatableProperties,
});
}
return metrics;
}
// A Perfetto UI component which wraps the `Flamegraph` widget and fetches the
// data for the widget by querying an `Engine`.
export class QueryFlamegraph {
private data?: FlamegraphQueryData;
private readonly selMonitor = new Monitor([() => this.state.state]);
private readonly queryLimiter = new AsyncLimiter();
constructor(
private readonly trace: Trace,
private readonly metrics: ReadonlyArray<QueryFlamegraphMetric>,
private state: QueryFlamegraphState,
) {}
render() {
if (this.selMonitor.ifStateChanged()) {
const metric = assertExists(
this.metrics.find(
(x) => this.state.state.selectedMetricName === x.name,
),
);
const engine = this.trace.engine;
const state = this.state;
this.data = undefined;
this.queryLimiter.schedule(async () => {
this.data = undefined;
this.data = await computeFlamegraphTree(engine, metric, state.state);
});
}
return m(Flamegraph, {
metrics: this.metrics,
data: this.data,
state: this.state.state,
onStateChange: (state) => {
this.state.state = state;
this.trace.scheduleFullRedraw();
},
});
}
}
async function computeFlamegraphTree(
engine: Engine,
{
dependencySql,
statement,
unaggregatableProperties,
aggregatableProperties,
}: QueryFlamegraphMetric,
{filters, view}: FlamegraphState,
): Promise<FlamegraphQueryData> {
const showStack = filters
.filter((x) => x.kind === 'SHOW_STACK')
.map((x) => x.filter);
const hideStack = filters
.filter((x) => x.kind === 'HIDE_STACK')
.map((x) => x.filter);
const showFromFrame = filters
.filter((x) => x.kind === 'SHOW_FROM_FRAME')
.map((x) => x.filter);
const hideFrame = filters
.filter((x) => x.kind === 'HIDE_FRAME')
.map((x) => x.filter);
// Pivot also essentially acts as a "show stack" filter so treat it like one.
const showStackAndPivot = [...showStack];
if (view.kind === 'PIVOT') {
showStackAndPivot.push(view.pivot);
}
const showStackFilter =
showStackAndPivot.length === 0
? '0'
: showStackAndPivot
.map(
(x, i) => `((name like '${makeSqlFilter(x)}' escape '\\') << ${i})`,
)
.join(' | ');
const showStackBits = (1 << showStackAndPivot.length) - 1;
const hideStackFilter =
hideStack.length === 0
? 'false'
: hideStack
.map((x) => `name like '${makeSqlFilter(x)}' escape '\\'`)
.join(' OR ');
const showFromFrameFilter =
showFromFrame.length === 0
? '0'
: showFromFrame
.map(
(x, i) => `((name like '${makeSqlFilter(x)}' escape '\\') << ${i})`,
)
.join(' | ');
const showFromFrameBits = (1 << showFromFrame.length) - 1;
const hideFrameFilter =
hideFrame.length === 0
? 'false'
: hideFrame
.map((x) => `name like '${makeSqlFilter(x)}' escape '\\'`)
.join(' OR ');
const pivotFilter = getPivotFilter(view);
const unagg = unaggregatableProperties ?? [];
const unaggCols = unagg.map((x) => x.name);
const agg = aggregatableProperties ?? [];
const aggCols = agg.map((x) => x.name);
const groupingColumns = `(${(unaggCols.length === 0 ? ['groupingColumn'] : unaggCols).join()})`;
const groupedColumns = `(${(aggCols.length === 0 ? ['groupedColumn'] : aggCols).join()})`;
if (dependencySql !== undefined) {
await engine.query(dependencySql);
}
await engine.query(`include perfetto module viz.flamegraph;`);
const uuid = uuidv4Sql();
await using disposable = new AsyncDisposableStack();
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_materialized_statement_${uuid}`,
statement,
),
);
disposable.use(
await createPerfettoIndex(
engine,
`_flamegraph_materialized_statement_${uuid}_index`,
`_flamegraph_materialized_statement_${uuid}(parentId)`,
),
);
// TODO(lalitm): this doesn't need to be called unless we have
// a non-empty set of filters.
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_source_${uuid}`,
`
select *
from _viz_flamegraph_prepare_filter!(
(
select
s.id,
s.parentId,
s.name,
s.value,
${(unaggCols.length === 0
? [`'' as groupingColumn`]
: unaggCols.map((x) => `s.${x}`)
).join()},
${(aggCols.length === 0
? [`'' as groupedColumn`]
: aggCols.map((x) => `s.${x}`)
).join()}
from _flamegraph_materialized_statement_${uuid} s
),
(${showStackFilter}),
(${hideStackFilter}),
(${showFromFrameFilter}),
(${hideFrameFilter}),
(${pivotFilter}),
${1 << showStackAndPivot.length},
${groupingColumns}
)
`,
),
);
// TODO(lalitm): this doesn't need to be called unless we have
// a non-empty set of filters.
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_filtered_${uuid}`,
`
select *
from _viz_flamegraph_filter_frames!(
_flamegraph_source_${uuid},
${showFromFrameBits}
)
`,
),
);
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_accumulated_${uuid}`,
`
select *
from _viz_flamegraph_accumulate!(
_flamegraph_filtered_${uuid},
${showStackBits}
)
`,
),
);
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_hash_${uuid}`,
`
select *
from _viz_flamegraph_downwards_hash!(
_flamegraph_source_${uuid},
_flamegraph_filtered_${uuid},
_flamegraph_accumulated_${uuid},
${groupingColumns},
${groupedColumns},
${view.kind === 'BOTTOM_UP' ? 'FALSE' : 'TRUE'}
)
union all
select *
from _viz_flamegraph_upwards_hash!(
_flamegraph_source_${uuid},
_flamegraph_filtered_${uuid},
_flamegraph_accumulated_${uuid},
${groupingColumns},
${groupedColumns}
)
order by hash
`,
),
);
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_merged_${uuid}`,
`
select *
from _viz_flamegraph_merge_hashes!(
_flamegraph_hash_${uuid},
${groupingColumns},
${computeGroupedAggExprs(agg)}
)
`,
),
);
disposable.use(
await createPerfettoTable(
engine,
`_flamegraph_layout_${uuid}`,
`
select *
from _viz_flamegraph_local_layout!(
_flamegraph_merged_${uuid}
);
`,
),
);
const res = await engine.query(`
select *
from _viz_flamegraph_global_layout!(
_flamegraph_merged_${uuid},
_flamegraph_layout_${uuid},
${groupingColumns},
${groupedColumns}
)
`);
const it = res.iter({
id: NUM,
parentId: NUM,
depth: NUM,
name: STR,
selfValue: NUM,
cumulativeValue: NUM,
parentCumulativeValue: NUM_NULL,
xStart: NUM,
xEnd: NUM,
...Object.fromEntries(unaggCols.map((m) => [m, STR_NULL])),
...Object.fromEntries(aggCols.map((m) => [m, UNKNOWN])),
});
let postiveRootsValue = 0;
let negativeRootsValue = 0;
let minDepth = 0;
let maxDepth = 0;
const nodes = [];
for (; it.valid(); it.next()) {
const properties = new Map<string, string>();
for (const a of [...agg, ...unagg]) {
const r = it.get(a.name);
if (r !== null) {
properties.set(a.displayName, r as string);
}
}
nodes.push({
id: it.id,
parentId: it.parentId,
depth: it.depth,
name: it.name,
selfValue: it.selfValue,
cumulativeValue: it.cumulativeValue,
parentCumulativeValue: it.parentCumulativeValue ?? undefined,
xStart: it.xStart,
xEnd: it.xEnd,
properties,
});
if (it.depth === 1) {
postiveRootsValue += it.cumulativeValue;
} else if (it.depth === -1) {
negativeRootsValue += it.cumulativeValue;
}
minDepth = Math.min(minDepth, it.depth);
maxDepth = Math.max(maxDepth, it.depth);
}
const sumQuery = await engine.query(
`select sum(value) v from _flamegraph_source_${uuid}`,
);
const unfilteredCumulativeValue = sumQuery.firstRow({v: NUM_NULL}).v ?? 0;
return {
nodes,
allRootsCumulativeValue:
view.kind === 'BOTTOM_UP' ? negativeRootsValue : postiveRootsValue,
unfilteredCumulativeValue,
minDepth,
maxDepth,
};
}
function makeSqlFilter(x: string) {
if (x.startsWith('^') && x.endsWith('$')) {
return x.slice(1, -1);
}
return `%${x}%`;
}
function getPivotFilter(view: FlamegraphView) {
if (view.kind === 'PIVOT') {
return `name like '${makeSqlFilter(view.pivot)}'`;
}
if (view.kind === 'BOTTOM_UP') {
return 'value > 0';
}
return '0';
}
function computeGroupedAggExprs(agg: ReadonlyArray<AggQueryFlamegraphColumn>) {
const aggFor = (x: AggQueryFlamegraphColumn) => {
switch (x.mergeAggregation) {
case 'ONE_OR_NULL':
return `IIF(COUNT() = 1, ${x.name}, NULL) AS ${x.name}`;
case 'SUM':
return `SUM(${x.name}) AS ${x.name}`;
}
};
return `(${agg.length === 0 ? 'groupedColumn' : agg.map((x) => aggFor(x)).join(',')})`;
}