tp+ui: filter and merge multi-pprof archives in AggregateProfiles An archive with dozens of pprofs currently imports as an undifferentiated blob: every profile lands on the hard-coded "pprof_file" scope. Scope each profile by its source file instead: TraceFileTracker gains a CurrentFile() accessor and the pprof reader walks the file-nesting chain past the gzip layer to the nearest named ancestor. Locations without a pprof mapping each allocated a fresh dummy mapping, so identical frames never interned across profiles and callsites multiplied by the profile count: a 32 MB / 1000-profile archive peaked at 1.8 GB and OOMed the Wasm heap. Intern the shared fallback mapping and skip re-symbolizing interned frames; the same archive now peaks at 647 MB. In the UI, AggregateProfiles keeps its single-profile page and adds a merge page to organize archives: a DataGrid with one row per profile over a flamegraph. The grid's filters select the working set; merge on sums the selection into one flamegraph (callsites are interned globally, so a single query merges same-stack samples across profiles), merge off steps through the profiles in grid order. Only the selected subset touches the callstack tables, so the page scales to thousands of profiles. Profile-only traces land on the page directly via initialPage. Change-Id: I540938ba4393fa2f209e2b39d9fe668ad05ea7ad
diff --git a/src/trace_processor/importers/common/trace_file_tracker.h b/src/trace_processor/importers/common/trace_file_tracker.h index aaef804..37e67f8 100644 --- a/src/trace_processor/importers/common/trace_file_tracker.h +++ b/src/trace_processor/importers/common/trace_file_tracker.h
@@ -19,6 +19,7 @@ #include <cstddef> #include <cstdint> +#include <optional> #include <string> #include <vector> @@ -44,6 +45,15 @@ void StartParsing(tables::TraceFileTable::Id id, TraceImporterId trace_type); void DoneParsing(tables::TraceFileTable::Id id, size_t size); + // The file currently being parsed (top of the parsing stack), if any. Lets + // importers attribute the rows they emit to their source file. + std::optional<tables::TraceFileTable::Id> CurrentFile() const { + if (parsing_stack_.empty()) { + return std::nullopt; + } + return parsing_stack_.back(); + } + private: tables::TraceFileTable::Id AddFileImpl(StringId name);
diff --git a/src/trace_processor/importers/pprof/pprof_trace_reader.cc b/src/trace_processor/importers/pprof/pprof_trace_reader.cc index 38f6b8d..2f1ab72 100644 --- a/src/trace_processor/importers/pprof/pprof_trace_reader.cc +++ b/src/trace_processor/importers/pprof/pprof_trace_reader.cc
@@ -34,6 +34,7 @@ #include "src/trace_processor/importers/common/create_mapping_params.h" #include "src/trace_processor/importers/common/mapping_tracker.h" #include "src/trace_processor/importers/common/stack_profile_tracker.h" +#include "src/trace_processor/importers/common/trace_file_tracker.h" #include "src/trace_processor/importers/common/virtual_memory_mapping.h" #include "src/trace_processor/storage/trace_storage.h" #include "src/trace_processor/tables/profiler_tables_py.h" @@ -182,7 +183,11 @@ } } if (!mapping) { - mapping = &context_->mapping_tracker->CreateDummyMapping("[unknown]"); + // Intern the fallback so all profiles in an archive share one mapping + // and identical frames dedupe across them. + CreateMappingParams params; + params.name = "[unknown]"; + mapping = &context_->mapping_tracker->InternMemoryMapping(params); } // Extract function information from the first line for frame name @@ -214,6 +219,16 @@ mapping->InternFrame(rel_pc, storage->GetString(frame_name_id)); location_to_frame[loc_decoder.id()] = frame_id; + // Frames interned by a previous profile in the archive already carry + // their symbols; only newly created frames need them. + { + auto frame_row = + (*storage->mutable_stack_profile_frame_table())[frame_id]; + if (frame_row.symbol_set_id().has_value()) { + continue; + } + } + // Create symbol table entries for all line entries (inlined functions) uint32_t symbol_set_id = storage->symbol_table().row_count(); bool has_symbols = false; @@ -283,6 +298,25 @@ } } + // Scope each profile by its source file so multiple pprofs from an archive + // stay distinguishable instead of collapsing onto one "pprof_file" scope. + // Gzipped members parse under an unnamed decompression file, so walk up to + // the nearest named ancestor; fall back to "pprof_file" if there's no usable + // name. + StringId scope_id = pprof_file_string_id_; + const auto& trace_files = context_->storage->trace_file_table(); + auto cur = context_->trace_file_tracker->CurrentFile(); + while (cur.has_value()) { + auto row = trace_files[*cur]; + std::optional<StringId> name = row.name(); + if (name.has_value() && !name->is_null() && + storage->GetString(*name).size() > 0) { + scope_id = *name; + break; + } + cur = row.parent_id(); + } + // Parse sample types and create aggregate_profile entries std::vector<tables::AggregateProfileTable::Id> profile_ids; for (auto it = profile.sample_type(); it; ++it) { @@ -298,7 +332,7 @@ std::string type_str = storage->GetString(type_str_id).ToStdString(); auto profile_id = storage->mutable_aggregate_profile_table() - ->Insert({pprof_file_string_id_, + ->Insert({scope_id, storage->InternString(("pprof " + type_str).c_str()), type_str_id, unit_str_id}) .id;
diff --git a/ui/src/plugins/dev.perfetto.AggregateProfiles/index.ts b/ui/src/plugins/dev.perfetto.AggregateProfiles/index.ts index 4d6b91e..7658623 100644 --- a/ui/src/plugins/dev.perfetto.AggregateProfiles/index.ts +++ b/ui/src/plugins/dev.perfetto.AggregateProfiles/index.ts
@@ -15,35 +15,77 @@ 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 type {Row} from '../../trace_processor/query_result'; +import HeapProfilePlugin, { + traceHasTimelineData, +} from '../dev.perfetto.HeapProfile'; import {AggregateProfilesPage} from './aggregate_profiles_page'; +import {AggregateProfilesMergePage} from './merge_page'; +import {aggregateProfileMetric} from './metrics'; import { type AggregateProfilesPageState, AGGREGATE_PROFILES_PAGE_STATE_SCHEMA, + type MergeColumn, + type MergePageState, + type MergeProfile, + type MergeProfileMetric, + MERGE_PAGE_STATE_SCHEMA, + type SampleType, } from './types'; import type {Store} from '../../base/store'; import {ensureExists} from '../../base/assert'; +interface LoadedProfiles { + readonly profiles: MergeProfile[]; + readonly sampleTypes: SampleType[]; + readonly columns: MergeColumn[]; + readonly rows: Row[]; +} + +// Views the aggregate profiles (pprof, collapsed stack, ...) in the trace. +// A single profile gets a flamegraph with a metric selector; an archive of +// many gets a page that filters and merges them (see merge_page). export default class implements PerfettoPlugin { static readonly id = 'dev.perfetto.AggregateProfiles'; + static readonly dependencies = [HeapProfilePlugin]; private store?: Store<AggregateProfilesPageState>; - - private migratePageState(init: unknown): AggregateProfilesPageState { - const result = AGGREGATE_PROFILES_PAGE_STATE_SCHEMA.safeParse(init); - return result.data ?? {}; - } + private mergeStore?: Store<MergePageState>; async onTraceLoad(trace: Trace): Promise<void> { - this.store = trace.mountStore('dev.perfetto.AggregateProfiles', (init) => - this.migratePageState(init), + const scopes = await trace.engine.query( + 'SELECT count(DISTINCT scope) AS n FROM __intrinsic_aggregate_profile', ); - const profiles = await this.getProfiles(trace); - if (profiles.length === 0) { + const profileCount = scopes.firstRow({n: NUM}).n; + if (profileCount === 0) { return; } + if (profileCount === 1) { + await this.registerSingleProfilePage(trace); + } else { + await this.registerMergePage(trace); + } + trace.sidebar.addMenuItem({ + section: 'current_trace', + sortOrder: 11, + text: 'Aggregate Profiles', + href: '#!/aggregateprofiles', + icon: 'analytics', + }); + if (!(await traceHasTimelineData(trace))) { + // Profile-only traces land here rather than on the empty timeline. + trace.initialPage.suggest('/aggregateprofiles', 10); + } + } + + private async registerSingleProfilePage(trace: Trace): Promise<void> { + this.store = trace.mountStore('dev.perfetto.AggregateProfiles', (init) => { + const parsed = AGGREGATE_PROFILES_PAGE_STATE_SCHEMA.safeParse(init); + return parsed.data ?? {}; + }); + const profiles = await this.getProfiles(trace); const store = ensureExists(this.store); trace.pages.registerPage({ route: '/aggregateprofiles', @@ -60,25 +102,42 @@ 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 registerMergePage(trace: Trace): Promise<void> { + const loaded = await loadMergeProfiles(trace); + // The callstack forest build in this module is the expensive one-off that + // every flamegraph query needs; do it at load so the page stays responsive. + await trace.engine.query( + 'include perfetto module callstacks.stack_profile', + ); + this.mergeStore = trace.mountStore( + 'dev.perfetto.AggregateProfiles.merge', + (init) => { + const parsed = MERGE_PAGE_STATE_SCHEMA.safeParse(init); + return parsed.success ? parsed.data : MERGE_PAGE_STATE_SCHEMA.parse({}); + }, + ); + const store = ensureExists(this.mergeStore); + trace.pages.registerPage({ + route: '/aggregateprofiles', + render: () => + m(AggregateProfilesMergePage, { + trace, + profiles: loaded.profiles, + sampleTypes: loaded.sampleTypes, + columns: loaded.columns, + rows: loaded.rows, + state: store.state, + onStateChange: (s: MergePageState) => { + store.edit((draft) => { + draft.flamegraphState = s.flamegraphState; + draft.merge = s.merge; + draft.columns = s.columns; + draft.filters = s.filters; + }); + }, + }), }); } @@ -88,7 +147,7 @@ ); const profiles = []; for (const it = result.iter({scope: STR}); it.valid(); it.next()) { - const metrics = await this.getProfileMetrics(trace, it.scope); + const metrics = await getProfileMetrics(trace, it.scope); if (metrics.length > 0) { profiles.push({ id: `profile_${it.scope}`, @@ -99,79 +158,107 @@ } 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; +async function getProfileMetrics(trace: Trace, scope: string) { + const result = await trace.engine.query(` + SELECT + id, + 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 = []; + for ( + const it = result.iter({ + id: NUM, + sample_type_unit: STR, + display_name: STR, + }); + it.valid(); + it.next() + ) { + metrics.push( + aggregateProfileMetric(it.display_name, it.sample_type_unit, [it.id]), + ); } + return metrics; +} + +// Loads each profile's sample-type totals as DataGrid rows + columns. Just a +// grouped scan; the callstack tables are only touched for the selected subset, +// so this scales to thousands of profiles. +async function loadMergeProfiles(trace: Trace): Promise<LoadedProfiles> { + const byScope = new Map<string, Map<string, MergeProfileMetric>>(); + const sampleKinds = new Map<string, SampleType>(); + const result = await trace.engine.query(` + SELECT + ap.scope AS scope, + ap.id AS agg_id, + ap.sample_type_type AS type, + ap.sample_type_unit AS unit, + coalesce(sum(s.value), 0) AS total, + count(s.id) AS n + FROM __intrinsic_aggregate_profile ap + LEFT JOIN __intrinsic_aggregate_sample s + ON s.aggregate_profile_id = ap.id + GROUP BY ap.id + ORDER BY ap.scope + `); + for ( + const it = result.iter({ + scope: STR, + agg_id: NUM, + type: STR, + unit: STR, + total: NUM, + n: NUM, + }); + it.valid(); + it.next() + ) { + const key = `${it.type} (${it.unit})`; + sampleKinds.set(key, {key, type: it.type, unit: it.unit}); + let sampleTypes = byScope.get(it.scope); + if (sampleTypes === undefined) { + sampleTypes = new Map(); + byScope.set(it.scope, sampleTypes); + } + sampleTypes.set(key, {aggId: it.agg_id, total: it.total, count: it.n}); + } + + const profiles: MergeProfile[] = Array.from( + byScope.entries(), + ([scope, sampleTypes]) => ({scope, sampleTypes}), + ); + + const columns: MergeColumn[] = [{field: 'c0', title: 'profile', kind: 'id'}]; + for (const st of sampleKinds.values()) { + columns.push({ + field: `c${columns.length}`, + title: st.key, + kind: 'numeric', + unit: st.unit, + sampleKey: st.key, + }); + } + + const rows: Row[] = profiles.map((p) => { + const row: Row = {c0: p.scope}; + for (const c of columns) { + if (c.sampleKey !== undefined) { + row[c.field] = p.sampleTypes.get(c.sampleKey)?.total ?? null; + } + } + return row; + }); + + return { + profiles, + sampleTypes: Array.from(sampleKinds.values()), + columns, + rows, + }; }
diff --git a/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.scss b/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.scss new file mode 100644 index 0000000..bc1cdd6 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.scss
@@ -0,0 +1,123 @@ +// 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. + +.pf-aggregate-merge { + height: 100%; + width: 100%; + + display: flex; + flex-direction: column; + + // Height is set inline (drag-adjusted); the last row grows to fill. + &__row { + flex: 0 0 auto; + width: 100%; + min-height: 0; + overflow: hidden; + + &--grow { + flex: 1 1 0; + min-height: 160px; + } + } + + &__summary { + font-size: var(--pf-font-size-s); + color: var(--pf-color-text-muted); + } + + &__grid { + height: 100%; + width: 100%; + } + + &__flame { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } + + &__flame-head { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 6px 10px; + border-bottom: 1px solid var(--pf-color-border); + } + + &__flame-nav { + margin-left: auto; + display: flex; + align-items: center; + gap: 2px; + } + + &__flame-pos { + min-width: 56px; + text-align: center; + font-variant-numeric: tabular-nums; + font-size: var(--pf-font-size-s); + color: var(--pf-color-text-muted); + } + + &__flame-body { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + + & > * { + flex: 1 1 0; + min-height: 0; + } + } + + &__flame-single { + display: flex; + flex-direction: column; + } + + &__flame-cell-title { + flex: 0 0 auto; + padding: 3px 10px; + font-family: var(--pf-font-monospace); + font-size: var(--pf-font-size-xs); + color: var(--pf-color-text-muted); + background: var(--pf-color-background-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__flame-cell-body { + flex: 1 1 0; + min-height: 0; + } + + // Clicking navigates the un-merged flamegraph to this profile. + &__profile { + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &--current { + color: var(--pf-color-primary); + font-weight: 600; + } + } +}
diff --git a/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.ts b/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.ts new file mode 100644 index 0000000..91176f4 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.AggregateProfiles/merge_page.ts
@@ -0,0 +1,454 @@ +// 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 './merge_page.scss'; +import m from 'mithril'; + +import type {Trace} from '../../public/trace'; +import type {QueryFlamegraphMetric} from '../../components/query_flamegraph'; +import {FlamegraphPanel} from '../../components/flamegraph_panel'; +import {Flamegraph, displaySize} from '../../widgets/flamegraph'; +import type {FlamegraphState} from '../../widgets/flamegraph'; +import {Anchor} from '../../widgets/anchor'; +import {Switch} from '../../widgets/switch'; +import {Button} from '../../widgets/button'; +import {EmptyState} from '../../widgets/empty_state'; +import {ResizeHandle} from '../../widgets/resize_handle'; +import {DataGrid} from '../../components/widgets/datagrid/datagrid'; +import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source'; +import type {ColumnSchema} from '../../components/widgets/datagrid/datagrid_schema'; +import type {Column, Filter} from '../../components/widgets/datagrid/model'; +import type {Row, SqlValue} from '../../trace_processor/query_result'; +import {Monitor} from '../../base/monitor'; +import {aggregateProfileMetric, displayUnit} from './metrics'; +import type { + MergeColumn, + MergeProfile, + MergePageState, + SampleType, +} from './types'; + +// Drag floor fallback until the grid header has been measured. +const MIN_GRID_HEIGHT = 64; + +export interface AggregateProfilesMergePageAttrs { + readonly trace: Trace; + readonly profiles: ReadonlyArray<MergeProfile>; + readonly sampleTypes: ReadonlyArray<SampleType>; + readonly columns: ReadonlyArray<MergeColumn>; + readonly rows: ReadonlyArray<Row>; + readonly state: MergePageState; + readonly onStateChange: (state: MergePageState) => void; +} + +// A profile picker over a flamegraph: the grid's filters select the working +// set of profiles; merge on sums them into one flamegraph, merge off steps +// through them one at a time in the grid's order. +export class AggregateProfilesMergePage implements m.ClassComponent<AggregateProfilesMergePageAttrs> { + private attrs?: AggregateProfilesMergePageAttrs; + private source?: InMemoryDataSource; + private profileByScope?: ReadonlyMap<string, MergeProfile>; + + // Rebuilt when the working set changes (see rebuild()). Metric arrays must + // stay reference-stable across renders or the flamegraph refetches. + private mergedMetrics?: QueryFlamegraphMetric[]; + private mergedCount = 0; + private perProfile?: ReadonlyArray<{ + scope: string; + metrics: QueryFlamegraphMetric[]; + }>; + private profileIndex = 0; + // One-entry cache keeping the shown profile's flamegraph state stable when + // it has to diverge from the master (see profileState()). + private shownState?: { + master?: FlamegraphState; + scope: string; + state: FlamegraphState; + }; + private readonly monitor = new Monitor([ + () => this.attrs?.state.filters, + () => this.attrs?.state.merge, + // Sort only affects the un-merged view (step order); ignore it merged. + () => (this.attrs?.state.merge ? undefined : this.sortKey()), + ]); + + private gridHeight = 300; + private gridRowEl?: HTMLElement; + + oncreate(): void { + window.addEventListener('keydown', this.onKeyDown); + } + + onremove(): void { + window.removeEventListener('keydown', this.onKeyDown); + } + + view({attrs}: m.CVnode<AggregateProfilesMergePageAttrs>): m.Children { + this.attrs = attrs; + this.profileByScope ??= new Map(attrs.profiles.map((p) => [p.scope, p])); + this.monitor.ifStateChanged(() => this.rebuild(attrs)); + + return m( + '.pf-aggregate-merge', + m( + '.pf-aggregate-merge__row', + { + style: {height: `${this.gridHeight}px`}, + oncreate: (v: m.VnodeDOM) => { + this.gridRowEl = v.dom as HTMLElement; + }, + }, + this.renderGrid(attrs), + ), + m(ResizeHandle, { + onResize: (deltaPx: number) => { + this.gridHeight = Math.max( + this.minGridHeight(), + this.gridHeight + deltaPx, + ); + if (this.gridRowEl) { + this.gridRowEl.style.height = `${this.gridHeight}px`; + } + }, + onResizeEnd: () => m.redraw(), + }), + m( + '.pf-aggregate-merge__row.pf-aggregate-merge__row--grow', + this.renderFlamegraph(attrs), + ), + ); + } + + // The grid collapses down to its chrome (toolbar + column headers); only + // body rows shrink away. + private minGridHeight(): number { + const row = this.gridRowEl; + const header = row?.querySelector('.pf-grid__header'); + if (!row || !header) return MIN_GRID_HEIGHT; + return ( + header.getBoundingClientRect().bottom - row.getBoundingClientRect().top + ); + } + + private renderGrid(attrs: AggregateProfilesMergePageAttrs): m.Children { + const current = attrs.state.merge + ? undefined + : this.perProfile?.[this.profileIndex]?.scope; + const schema: ColumnSchema = {}; + for (const c of attrs.columns) { + schema[c.field] = { + title: c.title, + columnType: + c.kind === 'id' + ? 'identifier' + : c.kind === 'numeric' + ? 'quantitative' + : 'text', + cellRenderer: + c.kind === 'numeric' + ? (value: SqlValue) => + typeof value === 'number' + ? displaySize(value, displayUnit(c.unit ?? '')) + : '' + : c.kind === 'id' + ? (value: SqlValue) => { + const scope = String(value); + return m( + 'span.pf-aggregate-merge__profile' + + (scope === current + ? '.pf-aggregate-merge__profile--current' + : ''), + {onclick: () => this.jumpToProfile(attrs, scope)}, + scope, + ); + } + : undefined, + }; + } + return m(DataGrid, { + className: 'pf-aggregate-merge__grid', + fillHeight: true, + schema, + data: attrs.rows as Row[], + columns: this.columns(attrs), + onColumnsChanged: (columns: readonly Column[]) => + attrs.onStateChange({...attrs.state, columns: columns as Column[]}), + filters: this.filters(attrs), + onFiltersChanged: (f: readonly Filter[]) => + attrs.onStateChange({...attrs.state, filters: f as Filter[]}), + }); + } + + private renderFlamegraph(attrs: AggregateProfilesMergePageAttrs): m.Children { + const count = attrs.state.merge + ? this.mergedCount + : (this.perProfile?.length ?? 0); + const idx = this.clampIndex(this.perProfile?.length ?? 0); + const nav = + !attrs.state.merge && count > 0 + ? m( + '.pf-aggregate-merge__flame-nav', + m(Button, { + icon: 'chevron_left', + compact: true, + disabled: idx <= 0, + title: 'Previous profile', + onclick: () => this.stepProfile(-1), + }), + m('.pf-aggregate-merge__flame-pos', `${idx + 1} / ${count}`), + m(Button, { + icon: 'chevron_right', + compact: true, + disabled: idx >= count - 1, + title: 'Next profile', + onclick: () => this.stepProfile(1), + }), + ) + : null; + return m( + '.pf-aggregate-merge__flame', + m( + '.pf-aggregate-merge__flame-head', + m(Switch, { + label: 'Merge', + checked: attrs.state.merge, + onchange: () => + attrs.onStateChange({...attrs.state, merge: !attrs.state.merge}), + }), + m( + '.pf-aggregate-merge__summary', + `${attrs.state.merge ? 'Merging' : 'Showing'} ${count} of ` + + `${attrs.rows.length} profiles`, + ), + nav, + ), + m( + '.pf-aggregate-merge__flame-body', + attrs.state.merge + ? this.renderMergedFlame(attrs) + : this.renderProfileFlame(attrs, idx), + ), + ); + } + + private renderMergedFlame( + attrs: AggregateProfilesMergePageAttrs, + ): m.Children { + const metrics = this.mergedMetrics; + if (metrics === undefined) { + return this.renderEmpty(); + } + return m(FlamegraphPanel, { + trace: attrs.trace, + metrics, + state: Flamegraph.updateState(attrs.state.flamegraphState, metrics), + onStateChange: (s) => + attrs.onStateChange({...attrs.state, flamegraphState: s}), + }); + } + + private renderProfileFlame( + attrs: AggregateProfilesMergePageAttrs, + idx: number, + ): m.Children { + const p = this.perProfile?.[idx]; + if (p === undefined) { + return this.renderEmpty(); + } + const isUrl = + p.scope.startsWith('http://') || p.scope.startsWith('https://'); + return m( + '.pf-aggregate-merge__flame-single', + m( + '.pf-aggregate-merge__flame-cell-title', + {title: p.scope}, + isUrl ? m(Anchor, {href: p.scope, target: '_blank'}, p.scope) : p.scope, + ), + m( + '.pf-aggregate-merge__flame-cell-body', + m(FlamegraphPanel, { + trace: attrs.trace, + metrics: p.metrics, + state: this.profileState(attrs, p), + onStateChange: (s) => + attrs.onStateChange({...attrs.state, flamegraphState: s}), + }), + ), + ); + } + + private renderEmpty(): m.Children { + return m(EmptyState, { + icon: 'filter_alt', + title: 'No profiles match', + detail: 'Adjust the filters to select profiles.', + }); + } + + // The shown profile's flamegraph state: the shared master state, with the + // selected metric swapped out if this profile lacks it. Cached so the state + // object is reference-stable across renders. + private profileState( + attrs: AggregateProfilesMergePageAttrs, + p: {scope: string; metrics: QueryFlamegraphMetric[]}, + ): FlamegraphState { + const master = attrs.state.flamegraphState; + const cached = this.shownState; + if ( + cached !== undefined && + cached.master === master && + cached.scope === p.scope + ) { + return cached.state; + } + const state = Flamegraph.updateState(master, p.metrics); + this.shownState = {master, scope: p.scope, state}; + return state; + } + + private filters(attrs: AggregateProfilesMergePageAttrs): readonly Filter[] { + return (attrs.state.filters ?? []) as Filter[]; + } + + private columns(attrs: AggregateProfilesMergePageAttrs): Column[] { + const cols = attrs.state.columns as Column[] | undefined; + if (cols !== undefined && cols.length > 0) { + return cols; + } + return attrs.columns.map((c) => ({id: c.field, field: c.field})); + } + + private sortKey(): string | undefined { + const attrs = this.attrs; + if (attrs === undefined) return undefined; + const c = this.columns(attrs).find((c) => c.sort !== undefined); + return c && `${c.field}:${c.sort}`; + } + + // The filtered profiles driving the flamegraph, in the grid's sort order. + // Evaluated by the same DataGrid machinery that renders the grid. + private workingRows( + attrs: AggregateProfilesMergePageAttrs, + ): ReadonlyArray<Row> { + this.source ??= new InMemoryDataSource(attrs.rows); + const sorted = this.columns(attrs).find((c) => c.sort !== undefined); + const result = this.source.useRows({ + mode: 'flat', + columns: attrs.columns.map((c) => ({field: c.field, alias: c.field})), + filters: this.filters(attrs), + sort: sorted?.sort + ? {alias: sorted.field, direction: sorted.sort} + : undefined, + }); + return result.rows ?? []; + } + + private clampIndex(count: number): number { + if (count <= 0) return 0; + this.profileIndex = Math.min(count - 1, Math.max(0, this.profileIndex)); + return this.profileIndex; + } + + private stepProfile(delta: number): void { + const n = this.perProfile?.length ?? 0; + if (n === 0) return; + this.profileIndex = Math.min(n - 1, Math.max(0, this.profileIndex + delta)); + m.redraw(); + } + + // Clicking a profile in the grid shows its flamegraph, leaving merge mode + // if needed. + private jumpToProfile( + attrs: AggregateProfilesMergePageAttrs, + scope: string, + ): void { + if (attrs.state.merge) { + attrs.onStateChange({...attrs.state, merge: false}); + this.rebuild({...attrs, state: {...attrs.state, merge: false}}); + } + const idx = this.perProfile?.findIndex((p) => p.scope === scope) ?? -1; + if (idx >= 0) { + this.profileIndex = idx; + } + m.redraw(); + } + + // Left/right arrows step between profiles in the un-merged view, unless a + // text field is focused. + private readonly onKeyDown = (e: KeyboardEvent): void => { + if (this.attrs?.state.merge !== false) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const tag = (e.target as HTMLElement | null)?.tagName ?? ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if ((this.perProfile?.length ?? 0) <= 1) return; + this.stepProfile(e.key === 'ArrowLeft' ? -1 : 1); + e.preventDefault(); + }; + + // Merge on: one flamegraph, a metric per sample-type, each summing the + // working set's aggregate profiles. Merge off: one metric set per profile. + private rebuild(attrs: AggregateProfilesMergePageAttrs): void { + const rows = this.workingRows(attrs); + const idField = attrs.columns.find((c) => c.kind === 'id')?.field ?? 'c0'; + const profileOf = (row: Row) => + this.profileByScope?.get(String(row[idField])); + + if (attrs.state.merge) { + this.perProfile = undefined; + const metrics: QueryFlamegraphMetric[] = []; + for (const st of attrs.sampleTypes) { + const ids = rows + .map((r) => profileOf(r)?.sampleTypes.get(st.key)?.aggId) + .filter((id) => id !== undefined); + if (ids.length > 0) { + metrics.push(aggregateProfileMetric(st.key, st.unit, ids)); + } + } + this.mergedMetrics = metrics.length > 0 ? metrics : undefined; + this.mergedCount = rows.length; + if (this.mergedMetrics !== undefined) { + // Reconcile the persisted state against the new metric set once, so + // renders can reuse it by reference. + const reconciled = Flamegraph.updateState( + attrs.state.flamegraphState, + this.mergedMetrics, + ); + if (reconciled !== attrs.state.flamegraphState) { + attrs.onStateChange({...attrs.state, flamegraphState: reconciled}); + } + } + return; + } + + this.mergedMetrics = undefined; + const shownScope = this.perProfile?.[this.profileIndex]?.scope; + this.perProfile = rows.flatMap((row) => { + const p = profileOf(row); + if (p === undefined) return []; + const metrics = attrs.sampleTypes.flatMap((st) => { + const met = p.sampleTypes.get(st.key); + return met + ? [aggregateProfileMetric(st.key, st.unit, [met.aggId])] + : []; + }); + return metrics.length > 0 ? [{scope: p.scope, metrics}] : []; + }); + // Keep the shown profile selected across working-set changes. + const keep = this.perProfile.findIndex((p) => p.scope === shownScope); + if (keep >= 0) { + this.profileIndex = keep; + } + } +}
diff --git a/ui/src/plugins/dev.perfetto.AggregateProfiles/metrics.ts b/ui/src/plugins/dev.perfetto.AggregateProfiles/metrics.ts new file mode 100644 index 0000000..d3802b9 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.AggregateProfiles/metrics.ts
@@ -0,0 +1,81 @@ +// 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 {QueryFlamegraphMetric} from '../../components/query_flamegraph'; + +// Maps pprof units onto the flamegraph's unit vocabulary ('ns' and 'B' are +// human-scaled by displaySize). +export function displayUnit(unit: string): string { + switch (unit.toLowerCase()) { + case 'nanoseconds': + return 'ns'; + case 'bytes': + return 'B'; + default: + return unit; + } +} + +// Flamegraph metric summing the given aggregate profiles. Callsites are +// interned globally, so a single query merges same-stack samples across +// profiles. +export function aggregateProfileMetric( + name: string, + unit: string, + aggIds: ReadonlyArray<number>, +): QueryFlamegraphMetric { + return { + name, + unit: displayUnit(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 IN (${aggIds.join(',')}) + 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', + }, + }; +}
diff --git a/ui/src/plugins/dev.perfetto.AggregateProfiles/types.ts b/ui/src/plugins/dev.perfetto.AggregateProfiles/types.ts index 805831d..16a10b4 100644 --- a/ui/src/plugins/dev.perfetto.AggregateProfiles/types.ts +++ b/ui/src/plugins/dev.perfetto.AggregateProfiles/types.ts
@@ -30,3 +30,42 @@ readonly displayName: string; readonly metrics: ReadonlyArray<QueryFlamegraphMetric>; } + +// Persisted merge page state. `columns`/`filters` mirror the DataGrid's +// controlled props; kept loose here and cast at the use site. +export const MERGE_PAGE_STATE_SCHEMA = z.object({ + flamegraphState: FLAMEGRAPH_STATE_SCHEMA.optional(), + merge: z.boolean().default(true), + columns: z.array(z.any()).optional(), + filters: z.array(z.any()).default([]), +}); +export type MergePageState = z.infer<typeof MERGE_PAGE_STATE_SCHEMA>; + +// One (profile, sample-type) total. `aggId` is the __intrinsic_aggregate_profile +// row the flamegraph merge query filters on. +export interface MergeProfileMetric { + readonly aggId: number; + readonly total: number; + readonly count: number; +} + +// One source pprof, keyed by its file scope. +export interface MergeProfile { + readonly scope: string; + readonly sampleTypes: ReadonlyMap<string, MergeProfileMetric>; +} + +// A pprof sample-type present across the loaded profiles. +export interface SampleType { + readonly key: string; // "cpu (nanoseconds)" + readonly type: string; + readonly unit: string; +} + +export interface MergeColumn { + readonly field: string; // positional grid field id ("c0", "c1", ...) + readonly title: string; + readonly kind: 'id' | 'numeric' | 'categorical'; + readonly unit?: string; + readonly sampleKey?: string; // for sample-type columns: the SampleType.key +}
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts index 46b29da..c3fe18c 100644 --- a/ui/src/widgets/flamegraph.ts +++ b/ui/src/widgets/flamegraph.ts
@@ -2005,7 +2005,8 @@ } } -function displaySize(totalSize: number, unit: string): string { +// Exported so other views showing flamegraph metrics format identically. +export function displaySize(totalSize: number, unit: string): string { if (unit === '' || unit === 'count') return totalSize.toLocaleString(); if (totalSize === 0) return `0 ${unit}`; let step: number;