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;