tp,ui: add PROFILE_FROM_TREE aggregate and expose it in flamegraphs

PROFILE_FROM_TREE(id, parent_id, frame_name, self_value, sample_type,
unit) is a new SQL aggregate that converts any tree-shaped table into
a serialized pprof Profile. The stdlib module `pprof.from_tree`
exposes it as `_pprof_from_tree!`, composing on top of the
`std.trees.*` operators so users can filter, propagate, and emit
pprof through the same primitives.

The Flamegraph widget gains a "Download as pprof" toolbar button.
Encoding lives in the engine; the widget delegates via a callback so
the bytes always match the SQL surface.

The SQL query results panel and SQL table tab gain an "Add Flamegraph"
control mirroring "Add debug track": the user picks the
id/parent_id/name/value columns of any query, optionally overrides
sample type and unit, and the result opens as an ephemeral flamegraph
tab that inherits the toolbar Download button.

Documentation under analysis/pprof-from-tree.md describes the SQL
surface and walks through recipes for heap dumps, callstack samples,
message-queue stacks, and Java heap dominator trees. Diff tests cover
the aggregate and the stdlib macro.

Change-Id: I27106469fc9c74dc45f494ed87a70c3b76f9c089
diff --git a/Android.bp b/Android.bp
index 0f52205..58fb4b0 100644
--- a/Android.bp
+++ b/Android.bp
@@ -17048,6 +17048,14 @@
     ],
 }
 
+// GN: //src/trace_processor/perfetto_sql/stdlib/pprof:pprof
+filegroup {
+    name: "perfetto_src_trace_processor_perfetto_sql_stdlib_pprof_pprof",
+    srcs: [
+        "src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql",
+    ],
+}
+
 // GN: //src/trace_processor/perfetto_sql/stdlib/prelude:prelude
 filegroup {
     name: "perfetto_src_trace_processor_perfetto_sql_stdlib_prelude_prelude",
@@ -17169,6 +17177,7 @@
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_linux_linux",
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_pixel_pixel",
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_pkvm_pkvm",
+        ":perfetto_src_trace_processor_perfetto_sql_stdlib_pprof_pprof",
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_prelude_prelude",
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_sched_sched",
         ":perfetto_src_trace_processor_perfetto_sql_stdlib_slices_slices",
diff --git a/BUILD b/BUILD
index 4afa527..0d15165 100644
--- a/BUILD
+++ b/BUILD
@@ -3981,6 +3981,14 @@
     ],
 )
 
+# GN target: //src/trace_processor/perfetto_sql/stdlib/pprof:pprof
+perfetto_filegroup(
+    name = "src_trace_processor_perfetto_sql_stdlib_pprof_pprof",
+    srcs = [
+        "src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql",
+    ],
+)
+
 # GN target: //src/trace_processor/perfetto_sql/stdlib/prelude/after_eof:after_eof
 perfetto_filegroup(
     name = "src_trace_processor_perfetto_sql_stdlib_prelude_after_eof_after_eof",
@@ -4203,6 +4211,7 @@
         ":src_trace_processor_perfetto_sql_stdlib_linux_linux",
         ":src_trace_processor_perfetto_sql_stdlib_pixel_pixel",
         ":src_trace_processor_perfetto_sql_stdlib_pkvm_pkvm",
+        ":src_trace_processor_perfetto_sql_stdlib_pprof_pprof",
         ":src_trace_processor_perfetto_sql_stdlib_prelude_prelude",
         ":src_trace_processor_perfetto_sql_stdlib_sched_sched",
         ":src_trace_processor_perfetto_sql_stdlib_slices_slices",
diff --git a/docs/analysis/pprof-from-tree.md b/docs/analysis/pprof-from-tree.md
new file mode 100644
index 0000000..2428d49
--- /dev/null
+++ b/docs/analysis/pprof-from-tree.md
@@ -0,0 +1,293 @@
+# Generating pprof from any tree
+
+PerfettoSQL ships a `PROFILE_FROM_TREE` aggregate that converts any
+`(id, parent_id, frame_name, value)` hierarchy into a serialized
+[pprof Profile](https://github.com/google/pprof/blob/main/proto/profile.proto).
+A thin stdlib layer (`pprof.from_tree`) wraps it on top of the existing
+`std.trees.*` operators (`_tree_from_table`, `_tree_filter`,
+`_tree_propagate_down`) so all pprof queries share one composable
+pipeline:
+
+```
+              raw rows                            tree pointer
+  ┌───────────────────────────┐    _tree_from_table!    ┌──────────┐
+  │ (id, parent_id, name,     │ ────────────────────► │ TREE_PTR │
+  │  value, …)                │                        └────┬─────┘
+  └───────────────────────────┘                             │
+                                            optional ┌─────┴────────┐
+                                          composition │ _tree_filter │
+                                                      │ _tree_propagate_down
+                                                      └─────┬────────┘
+                                                            │
+                                            _pprof_from_tree!│
+                                                            ▼
+                                                      pprof bytes
+```
+
+The Perfetto UI exposes the same primitive in two places:
+
+- A permanent **Download as pprof** button in the top-right of every
+  flamegraph (heap profile, perf samples, java heap, slice flamegraph,
+  and any user-driven flamegraph).
+- An **Add flamegraph** popup on every data-grid surface (SQL query
+  results panel, SQL table tab) that lets the user pick the
+  `id`/`parent_id`/`name`/`value` columns and the metric `sample_type`
+  / `unit`, then opens a flamegraph tab driven by those columns. The
+  resulting tab inherits the toolbar Download button.
+
+## SQL surface
+
+Two layers, with composition flowing through `std.trees.*`:
+
+```sql
+-- Layer 1: raw aggregate, no stdlib dependency.
+SELECT PROFILE_FROM_TREE(
+  id,         -- INTEGER, unique per row
+  parent_id,  -- INTEGER NULL, NULL marks a root
+  name,       -- TEXT NULL, frame label
+  value,      -- INTEGER NULL, rows with NULL or value <= 0 emit
+              -- no Sample but remain available as ancestors
+  sample_type,-- TEXT, e.g. 'space', 'allocations', 'wall'
+  unit        -- TEXT, e.g. 'bytes', 'count', 'nanoseconds'
+)
+FROM tree;
+
+-- Layer 2: stdlib macro for queries that compose with std.trees.*.
+INCLUDE PERFETTO MODULE pprof.from_tree;
+SELECT _pprof_from_tree!(
+  _tree_from_table!(
+    (SELECT id, parent_id, name, dur AS value
+     FROM slice WHERE dur > 0),
+    (name, value)),
+  name, value,        -- column names inside the tree pointer
+  'wall', 'nanoseconds');
+```
+
+Both return a BLOB of raw (uncompressed) pprof Profile bytes.
+
+### Errors
+
+The aggregate fails the query (with a clear message) when:
+
+- two rows share the same `id`
+- a non-NULL `parent_id` references an `id` not present in the input
+- the parent chain of a sample contains a cycle
+
+## Recipes
+
+Save any of the recipes below to a file and run them through
+`trace_processor_shell -Q`. The aggregate returns a BLOB of raw
+Profile bytes, which the shell prints as a hex string; redirect to a
+file, decode with the snippet below, and inspect with `pprof`:
+
+```sh
+out/linux/trace_processor_shell -Q query.sql trace.pftrace > /tmp/out.hex
+python3 -c "import re; \
+  d=open('/tmp/out.hex').read(); \
+  open('/tmp/out.pb','wb').write(bytes.fromhex(re.sub(r'[^0-9A-Fa-f]','',
+    [l for l in d.splitlines() if l.startswith('\"')][1].strip('\"'))))"
+pprof -text /tmp/out.pb
+```
+
+### 1. Native heap dump (heapprofd)
+
+```sql
+INCLUDE PERFETTO MODULE pprof.from_tree;
+
+SELECT _pprof_from_tree!(
+  _tree_from_table!(
+    (SELECT
+       c.id        AS id,
+       c.parent_id AS parent_id,
+       COALESCE(f.name, '<anon>') AS name,
+       COALESCE(
+         (SELECT SUM(size) FROM heap_profile_allocation a
+          WHERE a.callsite_id = c.id AND a.size > 0), 0) AS value
+     FROM stack_profile_callsite c
+     JOIN stack_profile_frame    f ON f.id = c.frame_id),
+    (name, value)),
+  name, value, 'space', 'bytes');
+```
+
+`pprof -text` of the resulting profile (system_server heap dump from
+cuttlefish):
+
+```
+Type: space
+Showing nodes accounting for 42kB, 100% of 42kB total
+      flat  flat%   sum%        cum   cum%
+      42kB   100%   100%       42kB   100%  malloc
+         0     0%   100%       42kB   100%  __pthread_start
+         0     0%   100%       14kB 33.33%  android::BinderObserver::flushStats
+         0     0%   100%       10kB 23.81%  android::BinderStatsCollector::consumeData
+```
+
+### 2. Callstack sampling (linux.perf)
+
+```sql
+INCLUDE PERFETTO MODULE pprof.from_tree;
+
+SELECT _pprof_from_tree!(
+  _tree_from_table!(
+    (SELECT
+       c.id        AS id,
+       c.parent_id AS parent_id,
+       COALESCE(f.name, '<unknown>') AS name,
+       COALESCE(
+         (SELECT COUNT(*) FROM perf_sample p
+          WHERE p.callsite_id = c.id), 0) AS value
+     FROM stack_profile_callsite c
+     JOIN stack_profile_frame    f ON f.id = c.frame_id),
+    (name, value)),
+  name, value, 'samples', 'count');
+```
+
+`pprof -text` of a 5s callstack-sampling capture targeting
+`system_server` on cuttlefish:
+
+```
+Type: samples
+Showing nodes accounting for 7, 100% of 7 total
+      flat  flat%   sum%        cum   cum%
+         2 28.57% 28.57%          2 28.57%  _raw_spin_unlock_irqrestore
+         1 14.29% 42.86%          5 71.43%  do_syscall_64
+         1 14.29% 57.14%          1 14.29%  art::InvokeVirtualOrInterfaceWithVarArgs
+         1 14.29% 71.43%          1 14.29%  avc_has_perm_noaudit
+```
+
+### 3. Message-queue stacks (track events + flow)
+
+Cross-thread message handling does not fit in the parent/child slice
+hierarchy: the producer of work and the consumer live on different
+threads. Track events emit `message_queue_send` slices linked by
+`flow` rows to the matching `message_queue_receive` slices, and a new
+send issued from inside a receive chains back to its incoming receive
+via `ancestor_slice`. Composing those two edge sets gives a real
+cross-thread call tree; counting one unit at every leaf produces a
+"messages delivered" pprof rooted at the originating thread:
+
+```sql
+INCLUDE PERFETTO MODULE pprof.from_tree;
+INCLUDE PERFETTO MODULE slices.with_context;
+
+WITH
+  -- send -> receive: from the flow attached to the send.
+  send_to_receive AS (
+    SELECT DISTINCT slice_out AS parent_id, slice_in AS id
+    FROM slice
+    JOIN flow ON slice_out = slice.id
+    WHERE slice.name = 'message_queue_send'
+  ),
+  -- receive -> next send: a send started under an enclosing receive
+  -- inherits that receive as its parent in the cross-thread stack.
+  receive_to_send AS (
+    SELECT s2r.parent_id AS parent_id, slice.id
+    FROM slice
+    JOIN ancestor_slice(slice.id) anc
+    JOIN flow ON anc.id = flow.slice_in
+    JOIN send_to_receive s2r ON s2r.id = anc.id
+    WHERE anc.name = 'message_queue_receive'
+      AND slice.name = 'message_queue_send'
+  ),
+  edges AS (
+    SELECT parent_id, id FROM send_to_receive
+    UNION
+    SELECT parent_id, id FROM receive_to_send
+  )
+SELECT _pprof_from_tree!(
+  _tree_from_table!(
+    (SELECT
+       e.id,
+       e.parent_id,
+       FORMAT('%s/%s', t.process_name, t.thread_name) AS name,
+       (NOT EXISTS (SELECT 1 FROM edges c WHERE c.parent_id = e.id)) AS value
+     FROM edges e
+     LEFT JOIN thread_slice t ON t.id = e.id),
+    (name, value)),
+  name, value, 'messages', 'count');
+```
+
+### 4. Java heap dominator tree
+
+The dominator tree of a Java heap is exposed by
+`android.memory.heap_graph.dominator_tree`. Combine it with object
+type names and self-sizes:
+
+```sql
+INCLUDE PERFETTO MODULE android.memory.heap_graph.dominator_tree;
+INCLUDE PERFETTO MODULE pprof.from_tree;
+
+SELECT _pprof_from_tree!(
+  _tree_from_table!(
+    (SELECT
+       d.id            AS id,
+       d.idom_id       AS parent_id,
+       COALESCE(c.name, '<unknown>') AS name,
+       o.self_size     AS value
+     FROM heap_graph_dominator_tree d
+     JOIN heap_graph_object        o USING (id)
+     LEFT JOIN heap_graph_class    c ON c.id = o.type_id),
+    (name, value)),
+  name, value, 'space', 'bytes');
+```
+
+`pprof -text` of a 64MB Java heap dump from `system_server`:
+
+```
+Type: space
+Showing nodes accounting for 114.31MB, 90.50% of 126.31MB total
+Dropped 12426 nodes (cum <= 0.63MB)
+      flat  flat%   sum%        cum   cum%
+   60.86MB 48.18% 48.18%    60.86MB 48.18%  double[]
+   11.78MB  9.32% 57.50%    13.05MB 10.33%  java.lang.Class
+    8.87MB  7.03% 64.53%     8.87MB  7.03%  android.location.GnssAntennaInfo$PhaseCenterOffset
+    7.61MB  6.02% 70.55%    68.46MB 54.20%  double[][]
+    7.33MB  5.80% 76.35%     7.33MB  5.80%  java.lang.String
+    5.07MB  4.01% 80.37%    86.20MB 68.25%  android.location.GnssAntennaInfo$Builder
+    3.80MB  3.01% 83.38%    72.26MB 57.21%  android.location.GnssAntennaInfo$SphericalCorrections
+```
+
+## UI
+
+### Download as pprof
+
+The Perfetto Flamegraph widget renders a `download` icon button in its
+toolbar (top-right of the filter bar). Clicking it runs the active
+metric's tree through the C++ `profile_from_tree` aggregate and
+triggers a browser download named `<metric>.pb`. The bytes can be
+inspected with:
+
+```sh
+go install github.com/google/pprof@latest
+pprof -text downloaded.pb
+```
+
+The export delegates to the engine instead of re-encoding the proto in
+TypeScript, so the bytes are guaranteed to match what the SQL surface
+emits. UI filters are not applied to the export; the full tree
+underlying the metric is what's written to disk.
+
+### Add flamegraph (every data grid)
+
+Wherever the existing **Add debug track** popup is exposed (the SQL
+query result panel, the SQL table tab), there is now a sibling
+**Add flamegraph** popup. The form has a title field, dropdowns to
+choose the `id` / `parent_id` / `name` / `value` columns from the
+underlying query (auto-picked when columns of those names exist), and
+two free-text fields for the metric `sample_type` and `unit`.
+
+Submitting opens an ephemeral tab driven by the chosen columns; the
+tab uses the same `Flamegraph` widget so the Download as pprof button
+is available immediately.
+
+## Verification
+
+```sh
+tools/gn gen out/linux
+tools/ninja -C out/linux trace_processor_shell
+
+out/linux/trace_processor_shell -Q query.sql /path/to/trace.pftrace \
+  > /tmp/out.hex
+# Decode hex to binary; see the Recipes section above for the snippet.
+pprof -text /tmp/out.pb
+```
diff --git a/docs/toc.md b/docs/toc.md
index 4e2a858..8994ef6 100644
--- a/docs/toc.md
+++ b/docs/toc.md
@@ -120,6 +120,7 @@
       - [Batch Trace Processor](analysis/batch-trace-processor.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf}
 
     - [Trace Summarization](analysis/trace-summary.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf}
+    - [Generating pprof from any tree](analysis/pprof-from-tree.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf}
     - [Converting from Perfetto](quickstart/traceconv.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome}
 
   - [FAQ](faq.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf}
diff --git a/src/trace_processor/perfetto_sql/stdlib/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/BUILD.gn
index db575b4..fa4dbaf 100644
--- a/src/trace_processor/perfetto_sql/stdlib/BUILD.gn
+++ b/src/trace_processor/perfetto_sql/stdlib/BUILD.gn
@@ -30,6 +30,7 @@
     "linux",
     "pixel",
     "pkvm",
+    "pprof",
     "prelude",
     "sched",
     "slices",
diff --git a/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn
new file mode 100644
index 0000000..33c7c44
--- /dev/null
+++ b/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn
@@ -0,0 +1,19 @@
+# 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("../../../../../gn/perfetto_sql.gni")
+
+perfetto_sql_source_set("pprof") {
+  sources = [ "from_tree.sql" ]
+}
diff --git a/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql b/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql
new file mode 100644
index 0000000..56b7b6c
--- /dev/null
+++ b/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql
@@ -0,0 +1,73 @@
+--
+-- Copyright 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
+--
+--     https://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.
+
+INCLUDE PERFETTO MODULE std.trees.table_conversion;
+
+-- Emit a serialized pprof Profile proto from a TREE pointer produced by
+-- the `std.trees.*` operators (`_tree_from_table`, `_tree_filter`,
+-- `_tree_propagate_down`, ...).
+--
+-- The pointer must carry the columns named by `name_col` (TEXT, frame
+-- label) and `value_col` (INTEGER, self contribution). Rows whose value
+-- is NULL or non-positive emit no Sample but remain available as
+-- ancestors of deeper rows.
+--
+-- Returns a BLOB of raw (uncompressed) Profile proto bytes.
+--
+-- Simple recipe — wrap any (id, parent_id, ...) table:
+--
+--   SELECT _pprof_from_tree!(
+--     _tree_from_table!(
+--       (SELECT id, parent_id, name, dur AS value
+--        FROM slice WHERE dur > 0),
+--       (name, value)
+--     ),
+--     name, value, 'wall', 'nanoseconds'
+--   );
+--
+-- Composable recipe — drop slices below 1ms then emit pprof:
+--
+--   SELECT _pprof_from_tree!(
+--     _tree_filter(
+--       _tree_from_table!(
+--         (SELECT id, parent_id, name, dur AS value FROM slice),
+--         (name, value)
+--       ),
+--       _tree_where(_tree_constraint('value', '>', 1000000))
+--     ),
+--     name, value, 'wall', 'nanoseconds'
+--   );
+--
+-- For callers that don't want to take a dependency on `std.trees.*`,
+-- the underlying `profile_from_tree(id, parent_id, name, value,
+-- sample_type, unit)` aggregate is callable directly.
+CREATE PERFETTO MACRO _pprof_from_tree(
+  -- TREE pointer produced by an operator in `std.trees.*`.
+  tree_ptr Expr,
+  -- Frame-name column inside the tree pointer.
+  name_col ColumnName,
+  -- Value column inside the tree pointer.
+  value_col ColumnName,
+  -- Sample type label: appears in pprof's sample_type[0].type.
+  sample_type Expr,
+  -- Sample unit: appears in pprof's sample_type[0].unit (e.g. 'bytes').
+  unit Expr
+)
+RETURNS Expr
+AS (
+  SELECT
+    profile_from_tree(id, parent_id, $name_col, $value_col, $sample_type, $unit)
+  FROM _tree_to_table!($tree_ptr, ($name_col, $value_col))
+);
diff --git a/src/trace_processor/plugins/pprof_functions/BUILD.gn b/src/trace_processor/plugins/pprof_functions/BUILD.gn
index 4b40739..ed0002b 100644
--- a/src/trace_processor/plugins/pprof_functions/BUILD.gn
+++ b/src/trace_processor/plugins/pprof_functions/BUILD.gn
@@ -24,8 +24,10 @@
   deps = [
     "../../../../gn:default_deps",
     "../../../../gn:sqlite",
+    "../../../../include/perfetto/protozero",
     "../../../../include/perfetto/trace_processor:basic_types",
     "../../../../protos/perfetto/trace_processor:zero",
+    "../../../../protos/third_party/pprof:zero",
     "../../../base",
     "../../core/plugin",
     "../../perfetto_sql/engine",
diff --git a/src/trace_processor/plugins/pprof_functions/pprof_functions.cc b/src/trace_processor/plugins/pprof_functions/pprof_functions.cc
index f89c28c..09acaa2 100644
--- a/src/trace_processor/plugins/pprof_functions/pprof_functions.cc
+++ b/src/trace_processor/plugins/pprof_functions/pprof_functions.cc
@@ -16,11 +16,15 @@
 
 #include "src/trace_processor/plugins/pprof_functions/pprof_functions.h"
 
+#include <cinttypes>
 #include <cstddef>
 #include <cstdint>
 #include <cstdlib>
 #include <memory>
+#include <optional>
 #include <string>
+#include <unordered_map>
+#include <unordered_set>
 #include <utility>
 #include <vector>
 
@@ -29,8 +33,11 @@
 #include "perfetto/base/status.h"
 #include "perfetto/ext/base/status_macros.h"
 #include "perfetto/ext/base/status_or.h"
+#include "perfetto/protozero/packed_repeated_fields.h"
+#include "perfetto/protozero/scattered_heap_buffer.h"
 #include "perfetto/trace_processor/basic_types.h"
 #include "protos/perfetto/trace_processor/stack.pbzero.h"
+#include "protos/third_party/pprof/profile.pbzero.h"
 #include "src/trace_processor/core/plugin/plugin.h"
 #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h"
 #include "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h"
@@ -202,6 +209,289 @@
   }
 };
 
+// Aggregate that converts an `(id, parent_id, frame_name, self_value)` tree
+// — together with constant `sample_type` and `unit` strings — into a
+// serialized pprof Profile proto.
+//
+// Conventions:
+// - `id` must be unique per row (duplicates fail the aggregate).
+// - `parent_id` NULL marks a root; multiple roots are allowed.
+// - `parent_id` referencing an unknown id fails the aggregate.
+// - `self_value` <= 0 (or NULL) means no Sample is emitted for that node
+//   but the location is still available as an ancestor for samples below.
+// - `sample_type` and `unit` are read from the first row only; SQL callers
+//   are expected to pass them as constants. Later rows are not re-checked.
+//
+// Order independence: rows are buffered during Step and resolved in Final,
+// so any SQL ordering is correct.
+class TreeAggregateContext {
+ public:
+  base::Status Step(size_t argc, sqlite3_value** argv) {
+    if (argc != 6) {
+      return base::ErrStatus(
+          "PROFILE_FROM_TREE: expected 6 args (id, parent_id, frame_name, "
+          "self_value, sample_type, unit); got %zu",
+          argc);
+    }
+
+    base::StatusOr<SqlValue> id =
+        sqlite::utils::ExtractArgument(argc, argv, "id", 0, SqlValue::kLong);
+    if (!id.ok()) {
+      return id.status();
+    }
+
+    Node node;
+    node.id = id->AsLong();
+
+    if (sqlite3_value_type(argv[1]) != SQLITE_NULL) {
+      base::StatusOr<SqlValue> parent_id = sqlite::utils::ExtractArgument(
+          argc, argv, "parent_id", 1, SqlValue::kLong);
+      if (!parent_id.ok()) {
+        return parent_id.status();
+      }
+      node.parent_id = parent_id->AsLong();
+    }
+
+    if (sqlite3_value_type(argv[2]) != SQLITE_NULL) {
+      base::StatusOr<SqlValue> name = sqlite::utils::ExtractArgument(
+          argc, argv, "frame_name", 2, SqlValue::kString);
+      if (!name.ok()) {
+        return name.status();
+      }
+      node.name = name->AsString();
+    }
+
+    if (sqlite3_value_type(argv[3]) != SQLITE_NULL) {
+      base::StatusOr<SqlValue> value = sqlite::utils::ExtractArgument(
+          argc, argv, "self_value", 3, SqlValue::kLong);
+      if (!value.ok()) {
+        return value.status();
+      }
+      node.self_value = value->AsLong();
+    }
+
+    if (sample_type_.empty()) {
+      base::StatusOr<SqlValue> stype = sqlite::utils::ExtractArgument(
+          argc, argv, "sample_type", 4, SqlValue::kString);
+      if (!stype.ok()) {
+        return stype.status();
+      }
+      sample_type_ = stype->AsString();
+
+      base::StatusOr<SqlValue> u = sqlite::utils::ExtractArgument(
+          argc, argv, "unit", 5, SqlValue::kString);
+      if (!u.ok()) {
+        return u.status();
+      }
+      unit_ = u->AsString();
+    }
+
+    auto [_, inserted] = id_to_index_.emplace(node.id, nodes_.size());
+    if (!inserted) {
+      return base::ErrStatus("PROFILE_FROM_TREE: duplicate id %" PRId64,
+                             node.id);
+    }
+    nodes_.push_back(std::move(node));
+    return base::OkStatus();
+  }
+
+  void Final(sqlite3_context* ctx) {
+    base::Status status = Build(ctx);
+    if (!status.ok()) {
+      sqlite::utils::SetError(ctx, "PROFILE_FROM_TREE", status);
+    }
+  }
+
+ private:
+  struct Node {
+    int64_t id = 0;
+    std::optional<int64_t> parent_id;
+    std::string name;
+    int64_t self_value = 0;
+  };
+
+  // Adds `s` to the staged string_table if not already present. Indices
+  // are 0-based; index 0 is always "" per the pprof format.
+  int64_t InternString(const std::string& s) {
+    auto it = string_index_.find(s);
+    if (it != string_index_.end()) {
+      return it->second;
+    }
+    auto index = static_cast<int64_t>(string_table_.size());
+    string_table_.push_back(s);
+    string_index_[s] = index;
+    return index;
+  }
+
+  base::Status Build(sqlite3_context* ctx) {
+    protozero::HeapBuffered<third_party::perftools::profiles::pbzero::Profile>
+        profile;
+
+    // protozero only allows one open child submessage at a time. We
+    // therefore stage every string in `string_table_` first, so writing
+    // a submessage never needs to insert a new top-level string_table
+    // field while the child is still open.
+    InternString("");
+
+    if (sample_type_.empty()) {
+      // No rows. Emit a valid, empty Profile (just the empty string).
+      profile->add_string_table(string_table_[0]);
+      std::string out = profile.SerializeAsString();
+      sqlite::result::TransientBytes(ctx, out.data(),
+                                     static_cast<int>(out.size()));
+      return base::OkStatus();
+    }
+
+    int64_t type_idx = InternString(sample_type_);
+    int64_t unit_idx = InternString(unit_);
+
+    // Stage one Function per unique frame_name and remember the
+    // assigned function id keyed by name. Function ids start at 1.
+    std::unordered_map<std::string, uint64_t> name_to_function_id;
+    struct StagedFunction {
+      uint64_t id;
+      int64_t name_idx;
+    };
+    std::vector<StagedFunction> staged_functions;
+    auto get_function_id = [&](const std::string& name) -> uint64_t {
+      auto it = name_to_function_id.find(name);
+      if (it != name_to_function_id.end()) {
+        return it->second;
+      }
+      uint64_t id = name_to_function_id.size() + 1;
+      name_to_function_id[name] = id;
+      staged_functions.push_back({id, InternString(name)});
+      return id;
+    };
+
+    // Location id == nodes_index + 1 (dense, stable).
+    std::vector<uint64_t> location_function_id(nodes_.size());
+    for (size_t i = 0; i < nodes_.size(); ++i) {
+      location_function_id[i] = get_function_id(nodes_[i].name);
+    }
+
+    // Validate the parent chain: every non-NULL parent_id must point at
+    // a known id. Cycle detection is deferred to the sample walk where
+    // it is per-sample.
+    std::vector<std::optional<size_t>> parent_index(nodes_.size());
+    for (size_t i = 0; i < nodes_.size(); ++i) {
+      const auto& n = nodes_[i];
+      if (!n.parent_id) {
+        continue;
+      }
+      auto it = id_to_index_.find(*n.parent_id);
+      if (it == id_to_index_.end()) {
+        return base::ErrStatus("PROFILE_FROM_TREE: id %" PRId64
+                               " has parent_id %" PRId64
+                               " which was not seen in the input",
+                               n.id, *n.parent_id);
+      }
+      parent_index[i] = it->second;
+    }
+
+    {
+      auto* st = profile->add_sample_type();
+      st->set_type(type_idx);
+      st->set_unit(unit_idx);
+    }
+
+    for (const auto& fn : staged_functions) {
+      auto* f = profile->add_function();
+      f->set_id(fn.id);
+      f->set_name(fn.name_idx);
+      f->set_system_name(fn.name_idx);
+    }
+
+    for (size_t i = 0; i < nodes_.size(); ++i) {
+      auto* loc = profile->add_location();
+      loc->set_id(static_cast<uint64_t>(i + 1));
+      auto* line = loc->add_line();
+      line->set_function_id(location_function_id[i]);
+    }
+
+    // For every node with a positive self_value emit one Sample whose
+    // location stack is the path from the node up to the root. pprof's
+    // Sample.location_id and Sample.value are packed-varint repeated
+    // fields; pbzero exposes them via PackedVarInt + set_*.
+    for (size_t i = 0; i < nodes_.size(); ++i) {
+      const auto& n = nodes_[i];
+      if (n.self_value <= 0) {
+        continue;
+      }
+      protozero::PackedVarInt locs;
+      std::unordered_set<size_t> visited;
+      for (std::optional<size_t> cur = i; cur; cur = parent_index[*cur]) {
+        if (!visited.insert(*cur).second) {
+          return base::ErrStatus(
+              "PROFILE_FROM_TREE: cycle detected at id %" PRId64,
+              nodes_[*cur].id);
+        }
+        locs.Append(static_cast<uint64_t>(*cur + 1));
+      }
+      protozero::PackedVarInt vals;
+      vals.Append(n.self_value);
+
+      auto* sample = profile->add_sample();
+      sample->set_location_id(locs);
+      sample->set_value(vals);
+    }
+
+    for (const auto& s : string_table_) {
+      profile->add_string_table(s);
+    }
+
+    std::string out = profile.SerializeAsString();
+    sqlite::result::TransientBytes(ctx, out.data(),
+                                   static_cast<int>(out.size()));
+    return base::OkStatus();
+  }
+
+  std::vector<Node> nodes_;
+  std::unordered_map<int64_t, size_t> id_to_index_;
+  std::vector<std::string> string_table_;
+  std::unordered_map<std::string, int64_t> string_index_;
+  std::string sample_type_;
+  std::string unit_;
+};
+
+base::Status TreeStepStatus(sqlite3_context* ctx,
+                            size_t argc,
+                            sqlite3_value** argv) {
+  auto** agg_context_ptr = static_cast<TreeAggregateContext**>(
+      sqlite3_aggregate_context(ctx, sizeof(TreeAggregateContext*)));
+  if (!agg_context_ptr) {
+    return base::ErrStatus("Failed to allocate aggregate context");
+  }
+  if (!*agg_context_ptr) {
+    *agg_context_ptr = new TreeAggregateContext();
+  }
+  return (*agg_context_ptr)->Step(argc, argv);
+}
+
+struct ProfileFromTree {
+  static constexpr char kName[] = "PROFILE_FROM_TREE";
+  static constexpr int kArgCount = 6;
+  using UserData = TraceProcessorContext;
+
+  static void Step(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
+    PERFETTO_CHECK(argc >= 0);
+    base::Status status = TreeStepStatus(ctx, static_cast<size_t>(argc), argv);
+    if (!status.ok()) {
+      sqlite::utils::SetError(ctx, kName, status);
+    }
+  }
+
+  static void Final(sqlite3_context* ctx) {
+    auto** agg_context_ptr =
+        static_cast<TreeAggregateContext**>(sqlite3_aggregate_context(ctx, 0));
+    if (!agg_context_ptr) {
+      return;
+    }
+    (*agg_context_ptr)->Final(ctx);
+    delete (*agg_context_ptr);
+  }
+};
+
 }  // namespace
 
 namespace pprof_functions {
@@ -215,6 +505,7 @@
       PerfettoSqlConnection*,
       std::vector<AggregateFunctionRegistration>& out) override {
     out.push_back(MakeAggregateRegistration<ProfileBuilder>(trace_context_));
+    out.push_back(MakeAggregateRegistration<ProfileFromTree>(trace_context_));
   }
 };
 
diff --git a/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py b/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py
index 032849e..3a7cb47 100644
--- a/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py
+++ b/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py
@@ -196,6 +196,70 @@
                 C (0x0)
             """))
 
+  def test_profile_from_tree(self):
+    return DiffTestBlueprint(
+        trace=DataPath("perf_sample.pb"),
+        query="""
+        WITH t(id, parent_id, name, value) AS (
+          VALUES
+            (1, NULL, 'main', 0),
+            (2, 1,    'foo',  100),
+            (3, 1,    'bar',  50),
+            (4, 2,    'baz',  25)
+        )
+        SELECT HEX(profile_from_tree(
+          id, parent_id, name, value, 'wall', 'nanoseconds'))
+        FROM t
+        """,
+        out=BinaryProto(
+            message_type="perfetto.third_party.perftools.profiles.Profile",
+            post_processing=PrintProfileProto,
+            contents="""
+            Sample:
+              Values: 100
+              Stack:
+                foo (0x0)
+                main (0x0)
+
+            Sample:
+              Values: 25
+              Stack:
+                baz (0x0)
+                foo (0x0)
+                main (0x0)
+
+            Sample:
+              Values: 50
+              Stack:
+                bar (0x0)
+                main (0x0)
+            """))
+
+  def test_pprof_from_tree_macro(self):
+    return DiffTestBlueprint(
+        trace=DataPath("perf_sample.pb"),
+        query="""
+        INCLUDE PERFETTO MODULE pprof.from_tree;
+        WITH t(id, parent_id, name, value) AS (
+          VALUES
+            (1, NULL, 'root',  0),
+            (2, 1,    'leaf', 7)
+        )
+        SELECT HEX(_pprof_from_tree!(
+          _tree_from_table!(t, (name, value)),
+          name, value, 'samples', 'count'))
+        """,
+        out=BinaryProto(
+            message_type="perfetto.third_party.perftools.profiles.Profile",
+            post_processing=PrintProfileProto,
+            contents="""
+            Sample:
+              Values: 7
+              Stack:
+                leaf (0x0)
+                root (0x0)
+            """))
+
   def test_annotated_callstack(self):
     return DiffTestBlueprint(
         trace=DataPath("perf_sample_annotations.pftrace"),
diff --git a/ui/src/assets/widgets/flamegraph.scss b/ui/src/assets/widgets/flamegraph.scss
index fc6db29..6ce2aad 100644
--- a/ui/src/assets/widgets/flamegraph.scss
+++ b/ui/src/assets/widgets/flamegraph.scss
@@ -72,6 +72,10 @@
     align-self: center;
   }
 
+  .pf-flamegraph-filter-bar-spacer {
+    flex: 0 0 8px;
+  }
+
   .pf-flamegraph-filter-label {
     font-weight: 500;
     flex-shrink: 0;
diff --git a/ui/src/components/details/sql_table_tab.ts b/ui/src/components/details/sql_table_tab.ts
index 13dabd9..aadea91 100644
--- a/ui/src/components/details/sql_table_tab.ts
+++ b/ui/src/components/details/sql_table_tab.ts
@@ -20,6 +20,7 @@
 import {DetailsShell} from '../../widgets/details_shell';
 import {Popup, PopupPosition} from '../../widgets/popup';
 import {AddDebugTrackMenu} from '../tracks/add_debug_track_menu';
+import {AddFlamegraphMenu} from '../tracks/add_flamegraph_menu';
 import {getSelectableColumns, SqlTableState} from '../widgets/sql/table/state';
 import {SqlTable} from '../widgets/sql/table/table';
 import type {SqlTableDefinition} from '../widgets/sql/table/table_description';
@@ -126,6 +127,7 @@
     const debugTrackColumns = Object.values(columns).filter(
       (c) => !c.startsWith('__'),
     );
+    const sourceQuery = `SELECT ${debugTrackColumns.join(', ')} FROM (${selectStatement})`;
     const addDebugTrack = m(
       Popup,
       {
@@ -134,13 +136,26 @@
       },
       m(AddDebugTrackMenu, {
         trace: this.tableState.trace,
-        query: `SELECT ${debugTrackColumns.join(', ')} FROM (${selectStatement})`,
+        query: sourceQuery,
+        availableColumns: debugTrackColumns,
+      }),
+    );
+    const addFlamegraph = m(
+      Popup,
+      {
+        trigger: m(Button, {label: 'Add flamegraph'}),
+        position: PopupPosition.Top,
+      },
+      m(AddFlamegraphMenu, {
+        trace: this.tableState.trace,
+        query: sourceQuery,
         availableColumns: debugTrackColumns,
       }),
     );
     return [
       ...navigation,
       addDebugTrack,
+      addFlamegraph,
       m(
         PopupMenu,
         {
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts
index e891113..8bfb29e 100644
--- a/ui/src/components/query_flamegraph.ts
+++ b/ui/src/components/query_flamegraph.ts
@@ -16,6 +16,7 @@
 import {AsyncLimiter} from '../base/async_limiter';
 import {AsyncDisposableStack} from '../base/disposable_stack';
 import {assertExists} from '../base/assert';
+import {download} from '../base/download_utils';
 import {uuidv4Sql} from '../base/uuid';
 import type {Engine} from '../trace_processor/engine';
 import {
@@ -23,6 +24,7 @@
   createPerfettoTable,
 } from '../trace_processor/sql_utils';
 import {
+  BLOB,
   NUM,
   NUM_NULL,
   STR,
@@ -221,6 +223,52 @@
         filters: [],
       },
       onStateChange,
+      onDownloadPprof:
+        metrics === undefined
+          ? undefined
+          : (metricName) => this.downloadPprof(metrics, metricName),
+    });
+  }
+
+  // Serializes the unfiltered tree behind `metric` as a pprof Profile by
+  // delegating to the trace_processor `_pprof_from_tree!` aggregate, then
+  // triggers a browser download. Filters applied in the UI do not affect
+  // the exported profile.
+  private async downloadPprof(
+    metrics: ReadonlyArray<QueryFlamegraphMetric>,
+    metricName: string,
+  ): Promise<void> {
+    const metric = metrics.find((x) => x.name === metricName);
+    if (metric === undefined) return;
+    const engine = this.trace.engine;
+    if (metric.dependencySql !== undefined) {
+      await engine.query(metric.dependencySql);
+    }
+    await engine.query('include perfetto module pprof.from_tree;');
+
+    const uuid = uuidv4Sql();
+    await using disposable = new AsyncDisposableStack();
+    disposable.use(
+      await createPerfettoTable({
+        engine,
+        name: `_pprof_source_${uuid}`,
+        as: metric.statement,
+      }),
+    );
+    const result = await engine.query(`
+      select profile_from_tree(
+        id, parentId, name, value,
+        ${sqliteString(metric.name)}, ${sqliteString(metric.unit)}
+      ) as bytes
+      from _pprof_source_${uuid}
+    `);
+    const it = result.iter({bytes: BLOB});
+    if (!it.valid()) return;
+    const safeName = metric.name.replace(/[^A-Za-z0-9._-]+/g, '_');
+    download({
+      content: it.bytes,
+      fileName: `${safeName || 'flamegraph'}.pb`,
+      mimeType: 'application/octet-stream',
     });
   }
 
diff --git a/ui/src/components/tracks/add_flamegraph_menu.ts b/ui/src/components/tracks/add_flamegraph_menu.ts
new file mode 100644
index 0000000..18e70c8
--- /dev/null
+++ b/ui/src/components/tracks/add_flamegraph_menu.ts
@@ -0,0 +1,229 @@
+// 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 m from 'mithril';
+import {findRef} from '../../base/dom_utils';
+import type {Trace} from '../../public/trace';
+import {Form, FormGrid, FormLabel, FormSection} from '../../widgets/form';
+import {Select} from '../../widgets/select';
+import {TextInput} from '../../widgets/text_input';
+import {addQueryFlamegraphTab} from './query_flamegraph_tab';
+
+interface AddFlamegraphMenuAttrs {
+  readonly trace: Trace; // Required for opening the flamegraph tab.
+  // A list of available columns in the query results - used to work out sensible defaults for each field.
+  readonly availableColumns: ReadonlyArray<string>;
+  // The actual query that produces the rows.
+  readonly query: string;
+
+  // Called when the user opens the flamegraph.
+  readonly onAdd?: () => void;
+}
+
+const TITLE_FIELD_REF = 'FLAMEGRAPH_TITLE_FIELD';
+
+function chooseDefaultColumn(
+  columns: ReadonlyArray<string>,
+  name: string,
+): string | undefined {
+  // Search for exact match.
+  const exactMatch = columns.find((col) => col === name);
+  if (exactMatch) return exactMatch;
+
+  // Search for partial match (handles `<table>_id`-style aliases).
+  const partialMatch = columns.find((col) => col.endsWith(`_${name}`));
+  if (partialMatch) return partialMatch;
+
+  return undefined;
+}
+
+interface ConfigurationOptions {
+  id: string;
+  parentId: string;
+  name: string;
+  value: string;
+}
+
+export class AddFlamegraphMenu
+  implements m.ClassComponent<AddFlamegraphMenuAttrs>
+{
+  private title = '';
+  private sampleType = 'samples';
+  private unit = 'count';
+  private readonly options: Partial<ConfigurationOptions>;
+
+  constructor({attrs}: m.Vnode<AddFlamegraphMenuAttrs>) {
+    const columns = attrs.availableColumns;
+
+    // Initialize the settings to some sensible defaults.
+    this.options = {
+      id: chooseDefaultColumn(columns, 'id'),
+      parentId: chooseDefaultColumn(columns, 'parent_id'),
+      name: chooseDefaultColumn(columns, 'name'),
+      value:
+        chooseDefaultColumn(columns, 'self_value') ??
+        chooseDefaultColumn(columns, 'value') ??
+        chooseDefaultColumn(columns, 'self_size') ??
+        chooseDefaultColumn(columns, 'self_count') ??
+        chooseDefaultColumn(columns, 'dur'),
+    };
+  }
+
+  oncreate({dom}: m.VnodeDOM<AddFlamegraphMenuAttrs>) {
+    this.focusTitleField(dom);
+  }
+
+  private focusTitleField(dom: Element) {
+    const element = findRef(dom, TITLE_FIELD_REF);
+    if (element) {
+      if (element instanceof HTMLInputElement) {
+        element.focus();
+      }
+    }
+  }
+
+  view({attrs}: m.Vnode<AddFlamegraphMenuAttrs>) {
+    return m(
+      Form,
+      {
+        className: 'pf-add-flamegraph-menu',
+        onSubmit: () => {
+          attrs.onAdd?.();
+          this.openFlamegraph(attrs);
+        },
+        submitLabel: 'Add Flamegraph',
+        cancelLabel: 'Cancel',
+      },
+      m(FormLabel, {for: 'flamegraph_title'}, 'Title'),
+      m(
+        TextInput,
+        {
+          id: 'flamegraph_title',
+          ref: TITLE_FIELD_REF,
+          onkeydown: (e: KeyboardEvent) => {
+            // Allow Esc to close popup.
+            if (e.key === 'Escape') return;
+          },
+          oninput: (e: InputEvent) => {
+            if (!e.target) return;
+            this.title = (e.target as HTMLInputElement).value;
+          },
+          placeholder: 'Enter flamegraph title...',
+        },
+        this.title,
+      ),
+      m(
+        FormSection,
+        {label: 'Column mapping'},
+        m(
+          FormGrid,
+          this.renderFormSelectInput('Id *', 'id', attrs.availableColumns),
+          this.renderFormSelectInput(
+            'Parent id *',
+            'parentId',
+            attrs.availableColumns,
+          ),
+          this.renderFormSelectInput('Name *', 'name', attrs.availableColumns),
+          this.renderFormSelectInput(
+            'Self value *',
+            'value',
+            attrs.availableColumns,
+          ),
+          m(FormLabel, {for: 'flamegraph_sample_type'}, 'Sample type'),
+          m(TextInput, {
+            id: 'flamegraph_sample_type',
+            value: this.sampleType,
+            oninput: (e: InputEvent) => {
+              this.sampleType = (e.target as HTMLInputElement).value;
+            },
+          }),
+          m(FormLabel, {for: 'flamegraph_unit'}, 'Unit'),
+          m(TextInput, {
+            id: 'flamegraph_unit',
+            value: this.unit,
+            oninput: (e: InputEvent) => {
+              this.unit = (e.target as HTMLInputElement).value;
+            },
+          }),
+        ),
+      ),
+    );
+  }
+
+  private renderFormSelectInput<K extends keyof ConfigurationOptions>(
+    label: m.Children,
+    optionKey: K,
+    options: ReadonlyArray<string>,
+  ) {
+    return [
+      m(FormLabel, {for: optionKey}, label),
+      m(
+        Select,
+        {
+          id: optionKey,
+          required: true,
+          oninput: (e: Event) => {
+            if (!e.target) return;
+            const newValue = (e.target as HTMLSelectElement).value;
+            if (newValue === '') {
+              delete this.options[optionKey];
+            } else {
+              this.options[optionKey] = newValue;
+            }
+          },
+        },
+        m(
+          'option',
+          {
+            selected: this.options[optionKey] === undefined,
+            value: '',
+            hidden: true,
+            disabled: true,
+          },
+          'Select a column...',
+        ),
+        options.map((opt) =>
+          m(
+            'option',
+            {selected: this.options[optionKey] === opt, value: opt},
+            opt,
+          ),
+        ),
+      ),
+    ];
+  }
+
+  private openFlamegraph(attrs: AddFlamegraphMenuAttrs) {
+    const {id, parentId, name, value} = this.options;
+    if (
+      id === undefined ||
+      parentId === undefined ||
+      name === undefined ||
+      value === undefined
+    ) {
+      return;
+    }
+    addQueryFlamegraphTab({
+      trace: attrs.trace,
+      title: this.title.trim() || 'flamegraph',
+      sourceQuery: attrs.query,
+      idColumn: id,
+      parentIdColumn: parentId,
+      nameColumn: name,
+      valueColumn: value,
+      sampleType: this.sampleType.trim() || 'samples',
+      unit: this.unit.trim() || 'count',
+    });
+  }
+}
diff --git a/ui/src/components/tracks/query_flamegraph_tab.ts b/ui/src/components/tracks/query_flamegraph_tab.ts
new file mode 100644
index 0000000..a35e6ee
--- /dev/null
+++ b/ui/src/components/tracks/query_flamegraph_tab.ts
@@ -0,0 +1,93 @@
+// 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 {Trace} from '../../public/trace';
+import {metricsFromTableOrSubquery, QueryFlamegraph} from '../query_flamegraph';
+import {
+  FLAMEGRAPH_STATE_SCHEMA,
+  type FlamegraphState,
+} from '../../widgets/flamegraph';
+
+export interface AddQueryFlamegraphArgs {
+  readonly trace: Trace;
+  readonly title: string;
+  // SQL subquery exposing the user's data. Treated as a TableOrSubquery.
+  readonly sourceQuery: string;
+  readonly idColumn: string;
+  readonly parentIdColumn: string;
+  readonly nameColumn: string;
+  readonly valueColumn: string;
+  // Metric metadata - shows up in pprof and in the flamegraph header.
+  readonly sampleType: string;
+  readonly unit: string;
+}
+
+// Opens an ephemeral Flamegraph tab driven by a user-supplied SQL query.
+// The tab reuses the same `QueryFlamegraph` component used by the heap
+// profile / java heap / slice flamegraph paths, so it inherits the
+// shared "Download pprof" toolbar button.
+export function addQueryFlamegraphTab(args: AddQueryFlamegraphArgs): void {
+  const {
+    trace,
+    title,
+    sourceQuery,
+    idColumn,
+    parentIdColumn,
+    nameColumn,
+    valueColumn,
+    sampleType,
+    unit,
+  } = args;
+
+  // The flamegraph contract expects columns named exactly id, parentId,
+  // name and selfValue. Project the user's chosen columns onto that
+  // shape via a subquery.
+  const projectedSubquery = `(
+    select
+      ${idColumn} as id,
+      ${parentIdColumn} as parentId,
+      ${nameColumn} as name,
+      ${valueColumn} as selfValue
+    from (${sourceQuery})
+  )`;
+
+  const metrics = metricsFromTableOrSubquery({
+    tableMetrics: [{name: sampleType, unit, columnName: 'selfValue'}],
+    tableOrSubquery: projectedSubquery,
+  });
+
+  const flamegraph = new QueryFlamegraph(trace);
+  let state: FlamegraphState = FLAMEGRAPH_STATE_SCHEMA.parse({
+    selectedMetricName: sampleType,
+    filters: [],
+    view: {kind: 'TOP_DOWN'},
+  });
+  const uri = `query_flamegraph#${title}-${Date.now()}`;
+  trace.tabs.registerTab({
+    uri,
+    isEphemeral: true,
+    content: {
+      getTitle: () => title,
+      render: () =>
+        flamegraph.render({
+          metrics,
+          state,
+          onStateChange: (next: FlamegraphState) => {
+            state = next;
+          },
+        }),
+    },
+  });
+  trace.tabs.showTab(uri);
+}
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts b/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts
index d63f4a8..8a38f18 100644
--- a/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts
+++ b/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts
@@ -16,6 +16,7 @@
 import {classNames} from '../../base/classnames';
 import {Icons} from '../../base/semantic_icons';
 import {AddDebugTrackMenu} from '../../components/tracks/add_debug_track_menu';
+import {AddFlamegraphMenu} from '../../components/tracks/add_flamegraph_menu';
 import type {DataSource} from '../../components/widgets/datagrid/data_source';
 import {DataGrid, renderCell} from '../../components/widgets/datagrid/datagrid';
 import type {
@@ -198,6 +199,22 @@
       }),
     );
 
+    const flamegraphButton = m(
+      Popup,
+      {
+        trigger: m(Button, {
+          label: 'Add flamegraph',
+          icon: 'local_fire_department',
+        }),
+        position: PopupPosition.Top,
+      },
+      m(AddFlamegraphMenu, {
+        trace: attrs.trace,
+        query: data.lastStatementSql,
+        availableColumns: data.columns,
+      }),
+    );
+
     const multiStatementWarning =
       data.statementWithOutputCount > 1 &&
       m(
@@ -219,7 +236,7 @@
         fillHeight: true,
         emptyStateMessage: 'Query returned no rows',
         toolbarItemsLeft: toolbarLeft,
-        toolbarItemsRight: [linkingButton, debugTrackButton],
+        toolbarItemsRight: [linkingButton, debugTrackButton, flamegraphButton],
         showExportButton: true,
       }),
     ];
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts
index 73b83ac..50a38a2 100644
--- a/ui/src/widgets/flamegraph.ts
+++ b/ui/src/widgets/flamegraph.ts
@@ -195,6 +195,12 @@
   readonly data: FlamegraphQueryData | undefined;
 
   readonly onStateChange: (filters: FlamegraphState) => void;
+
+  // Optional handler for the toolbar "Download as pprof" button. When
+  // omitted the button is hidden; this keeps the widget free of any
+  // engine/IO dependencies and lets the engine-aware wrapper
+  // (`QueryFlamegraph`) own the SQL pipeline.
+  readonly onDownloadPprof?: (metricName: string) => void | Promise<void>;
 }
 
 type FilterType =
@@ -927,6 +933,17 @@
           m(RadioGroup.Button, {value: 'bottom-up'}, 'Bottom Up'),
         ],
       ),
+      attrs.onDownloadPprof !== undefined && [
+        m('.pf-flamegraph-filter-bar-spacer'),
+        m(Button, {
+          icon: 'download',
+          compact: true,
+          title: 'Download as pprof',
+          disabled: attrs.data === undefined || attrs.data.nodes.length === 0,
+          onclick: () =>
+            attrs.onDownloadPprof?.(attrs.state.selectedMetricName),
+        }),
+      ],
     );
   }