| // Copyright (C) 2025 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 './styles.scss'; |
| import m from 'mithril'; |
| |
| import type {QueryFlamegraphMetric} from '../../components/query_flamegraph'; |
| import type {PerfettoPlugin} from '../../public/plugin'; |
| import type {Trace} from '../../public/trace'; |
| import {NUM, STR} from '../../trace_processor/query_result'; |
| import {AggregateProfilesPage} from './aggregate_profiles_page'; |
| import { |
| type AggregateProfilesPageState, |
| AGGREGATE_PROFILES_PAGE_STATE_SCHEMA, |
| } from './types'; |
| import type {Store} from '../../base/store'; |
| import {assertExists} from '../../base/assert'; |
| |
| export default class implements PerfettoPlugin { |
| static readonly id = 'dev.perfetto.AggregateProfiles'; |
| private store?: Store<AggregateProfilesPageState>; |
| |
| private migratePageState(init: unknown): AggregateProfilesPageState { |
| const result = AGGREGATE_PROFILES_PAGE_STATE_SCHEMA.safeParse(init); |
| return result.data ?? {}; |
| } |
| |
| async onTraceLoad(trace: Trace): Promise<void> { |
| this.store = trace.mountStore('dev.perfetto.AggregateProfiles', (init) => |
| this.migratePageState(init), |
| ); |
| const profiles = await this.getProfiles(trace); |
| if (profiles.length === 0) { |
| return; |
| } |
| const store = assertExists(this.store); |
| trace.pages.registerPage({ |
| route: '/aggregateprofiles', |
| render: () => |
| m(AggregateProfilesPage, { |
| trace, |
| state: store.state, |
| onStateChange: (state: AggregateProfilesPageState) => { |
| store.edit((draft) => { |
| draft.selectedProfileId = state.selectedProfileId; |
| draft.flamegraphState = state.flamegraphState; |
| }); |
| }, |
| profiles, |
| }), |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| sortOrder: 11, |
| text: 'Aggregate Profiles', |
| href: '#!/aggregateprofiles', |
| icon: 'analytics', |
| }); |
| trace.onTraceReady.addListener(async () => { |
| const hasAnyTracks = trace.workspaces.all[0].flatTracks.length > 0; |
| // TODO(lalitm): it's really bad that we're unconditionally navigating |
| // to the profiles page: really we should check if the user has not already |
| // set a page and then only navigate if no page is set. However: |
| // a) no API exists for checking the current page |
| // b) there is already some code in UI load time which navigates |
| // to the viewer page so we would always fail this check. |
| // So for now just leave this as-is. |
| if (!hasAnyTracks && profiles.length > 0) { |
| trace.navigate('#!/aggregateprofiles'); |
| } |
| }); |
| } |
| |
| private async getProfiles(trace: Trace) { |
| const result = await trace.engine.query( |
| 'SELECT DISTINCT scope FROM __intrinsic_aggregate_profile ORDER BY scope', |
| ); |
| const profiles = []; |
| for (const it = result.iter({scope: STR}); it.valid(); it.next()) { |
| const metrics = await this.getProfileMetrics(trace, it.scope); |
| if (metrics.length > 0) { |
| profiles.push({ |
| id: `profile_${it.scope}`, |
| displayName: it.scope, |
| metrics, |
| }); |
| } |
| } |
| return profiles; |
| } |
| |
| private async getProfileMetrics( |
| trace: Trace, |
| scope: string, |
| ): Promise<QueryFlamegraphMetric[]> { |
| const result = await trace.engine.query(` |
| SELECT |
| id, |
| sample_type_type, |
| sample_type_unit, |
| sample_type_type || ' (' || sample_type_unit || ')' as display_name |
| FROM __intrinsic_aggregate_profile |
| WHERE scope = '${scope}' |
| ORDER BY sample_type_type |
| `); |
| const metrics: QueryFlamegraphMetric[] = []; |
| for ( |
| const it = result.iter({ |
| id: NUM, |
| sample_type_unit: STR, |
| display_name: STR, |
| }); |
| it.valid(); |
| it.next() |
| ) { |
| metrics.push({ |
| name: it.display_name, |
| unit: it.sample_type_unit, |
| nameColumnLabel: 'Symbol', |
| dependencySql: 'include perfetto module callstacks.stack_profile', |
| statement: ` |
| WITH profile_samples AS MATERIALIZED ( |
| SELECT callsite_id, sum(sample.value) AS sample_value |
| FROM __intrinsic_aggregate_sample sample |
| WHERE sample.aggregate_profile_id = ${it.id} |
| GROUP BY callsite_id |
| ) |
| SELECT |
| c.id, |
| c.parent_id as parentId, |
| c.name, |
| c.mapping_name, |
| c.source_file || ':' || c.line_number as source_location, |
| cast_string!(c.inlined) AS inlined, |
| CASE WHEN c.is_leaf_function_in_callsite_frame |
| THEN coalesce(m.sample_value, 0) |
| ELSE 0 |
| END AS value |
| FROM _callstacks_for_stack_profile_samples!(profile_samples) AS c |
| LEFT JOIN profile_samples AS m USING (callsite_id) |
| `, |
| unaggregatableProperties: [ |
| {name: 'mapping_name', displayName: 'Mapping'}, |
| { |
| name: 'inlined', |
| displayName: 'Inlined', |
| isVisible: () => false, |
| }, |
| ], |
| aggregatableProperties: [ |
| { |
| name: 'source_location', |
| displayName: 'Source Location', |
| mergeAggregation: 'ONE_OR_SUMMARY', |
| }, |
| ], |
| optionalMarker: { |
| name: 'Inlined Function', |
| isVisible: (properties: ReadonlyMap<string, string>) => |
| properties.get('inlined') === '1', |
| }, |
| }); |
| } |
| return metrics; |
| } |
| } |