blob: 6291974e765ddc596d797839c3437c5b81ffaa05 [file] [view] [edit]
# Migrating to PerfettoSQL Pipe Syntax
This guide is for people who already know PerfettoSQL. It covers why we are
introducing pipe syntax, how it differs from existing macros and virtual
tables, and how to migrate existing queries.
> **Warning:** Pipe syntax is **experimental and unstable**. Do **not** rely on
> it in dashboards, scripts, or production queries. Anything can and will break
> without notice. Outside the PerfettoSQL standard library, you must opt in on
> your connection with `PERFETTO PRAGMA pipelines = 1;`.
Related pages:
- [Getting Started with PerfettoSQL Pipe Syntax](/docs/analysis/perfetto-sql-pipe-getting-started.md)
- [PerfettoSQL Pipe Syntax Reference](/docs/analysis/perfetto-sql-pipe-syntax.md)
- [PerfettoSQL Pipe Operator Implementation Notes](/docs/design-docs/perfetto-sql-pipelines.md)
---
## Why Pipe Syntax?
Writing trace analysis queries in standard SQLite has several recurring pain
points:
1. **Table macros and virtual tables do not compose well.** Operations like
interval intersection and tree traversal do not exist in SQL. Perfetto
exposed them through SQLite virtual tables like `SPAN_JOIN` or C++ table
functions wrapped in macros like `_interval_intersect!`,
`_graph_aggregating_scan!`, and `_callstacks_self_to_cumulative!`. Chaining
two or three of these together requires deeply nested subqueries or a sequence
of intermediate views and tables.
2. **Manual `id` plumbing and back-joins.** `_interval_intersect!` only returns
`ts`, `dur`, `id_0`, `id_1`, and so on. To read any other columns from your
inputs, every input needs an `id` column or an `_ii_subquery!` wrapper,
followed by a `JOIN` per input table on `id_0`, `id_1`, etc.
3. **Manual indexing and temporary tables for trees.** Summing values up a
callstack or heap graph with `_graph_aggregating_scan!` requires
materializing a raw table, creating a `PERFETTO INDEX` on `parent_id` so the
leaf-finding `NOT IN` query does not go quadratic, running the scan, and
joining the result back onto the raw table by `id`.
4. **Hard-to-read macro errors and SQLite virtual table overhead.** PerfettoSQL
macros are text substitutions, so a mistake in an argument produces a SQLite
error pointing inside the expanded macro body. At runtime, passing rows
between SQLite and custom operators forces every row through SQLite's
row-at-a-time virtual table interface.
### What changes with pipes
PerfettoSQL pipe syntax uses linear `|>` stages, modeled on
[GoogleSQL pipe syntax](https://cloud.google.com/bigquery/docs/pipe-syntax), and
runs them on a columnar batch executor:
- **`INTERVAL INTERSECTION OF` and `TREE ACCUMULATE` are part of the language.**
They carry input columns through automatically. You do not need `id_0`/`id_1`
back-joins, `_ii_subquery!`, or `parent_id` indexes.
- **Direct dataframe scans.** When a pipeline reads a finalized `PERFETTO TABLE`
such as `slice` or `sched`, it reads the table's columnar storage directly in
C++ without going through SQLite.
- **Compile-time column checks and pruning.** The compiler checks column names
and types up front, points at the offending token on error, and drops unused
columns and unused tree aggregates before execution.
---
## Opting In
In `trace_processor_shell`, the Perfetto UI, the Python API, and diff tests,
enable pipe syntax on your connection with:
```sql
PERFETTO PRAGMA pipelines = 1;
```
You can turn it back off with `PERFETTO PRAGMA pipelines = 0;`. Inside the
PerfettoSQL standard library, pipelines are enabled by default without the
pragma.
---
## What Goes in SQL vs. the Pipeline?
Pipe syntax is being built incrementally. Today, pipelines support two trace
operators, `INTERVAL INTERSECTION OF` and `TREE ACCUMULATE`, and six
column-reshaping stages: `SELECT`, `EXTEND`, `DROP`, `RENAME`, `SET`, and `AS`.
When migrating a query today, split the work between SQL and the pipeline as
follows:
| Task | Where to do it today |
| :--- | :--- |
| Filtering rows, standard `JOIN`s, `GROUP BY`, window functions, and computed expressions | Inside the `FROM (SELECT ...)` source subquery, or in a standard SQL query over the resulting `PERFETTO TABLE`. |
| Intersecting time intervals across two or more tables | `INTERVAL INTERSECTION OF (...) [PER ...]` at the start of a pipeline. |
| Summing values up or down a parent-child tree | `\|> TREE ACCUMULATE UP \| DOWN SUM(col) AS total` stage. |
| Picking, dropping, renaming, or swapping columns | `\|> SELECT`, `\|> EXTEND`, `\|> DROP`, `\|> RENAME`, `\|> SET`, `\|> AS` stages. |
| Sorting final output or limiting rows | In a `SELECT` with `ORDER BY` or `LIMIT` over the `PERFETTO TABLE` created by the pipeline. |
> **Note:** A pipeline can be run as a top-level statement or as the body of
> `CREATE [OR REPLACE] PERFETTO TABLE ... AS <pipeline>`. It cannot yet be used
> as the body of `CREATE PERFETTO VIEW`, `CREATE PERFETTO FUNCTION`, or a `WITH`
> CTE.
---
## Migrating Interval Intersections
Replace `_interval_intersect!` and `SPAN_JOIN` with `INTERVAL INTERSECTION OF`.
### Before: `_interval_intersect!`
```sql
INCLUDE PERFETTO MODULE intervals.intersect;
CREATE PERFETTO TABLE _estimates_w_tasks_attribution AS
SELECT ii.ts, ii.dur, ii.cpu, uw.estimated_mw, s.utid
FROM _interval_intersect!(
(
_ii_subquery!(_unioned_wattson_estimates_mw),
_ii_subquery!(_wattson_task_slices)
),
(cpu)
) AS ii
JOIN _unioned_wattson_estimates_mw AS uw
ON uw._auto_id = id_0
JOIN _wattson_task_slices AS s
ON s._auto_id = id_1;
```
### After: `INTERVAL INTERSECTION OF`
```sql
CREATE PERFETTO TABLE _estimates_w_tasks_attribution AS
INTERVAL INTERSECTION OF (
_unioned_wattson_estimates_mw AS estimate,
_wattson_task_slices AS task
) PER cpu
|> SELECT ts, dur, cpu, estimate.estimated_mw, task.utid;
```
### Migration checklist for `INTERVAL INTERSECTION OF`
1. **Drop the back-joins and `_ii_subquery!` wrappers.**
`_interval_intersect!` required every input to have an `id` column so you
could join `id_0` and `id_1` back to the input tables.
`INTERVAL INTERSECTION OF` carries the operands' columns automatically and
only requires `ts` and `dur` on its inputs. Only keep `_ii_subquery!` if you
specifically need SQLite's `_auto_id` exposed as `alias.id` in your output.
2. **Give every operand a distinct alias.**
Write `INTERVAL INTERSECTION OF (a AS x, b AS y)`. Omitting `AS <alias>` on
a subquery or reusing an alias across operands is a compile error.
3. **Use `PER` instead of partition column lists or `PARTITIONED`.**
Write `PER cpu` or `PER utid, track_id`. For unpartitioned intersections,
omit `PER` altogether.
4. **Qualify operand columns, and leave region `ts`, `dur`, and `PER` columns bare.**
Bare `ts` and `dur` are the intersected region's start and duration. Bare
`cpu` is the partition key shared by the matched rows. All other operand
columns, including each operand's original unclipped `x.ts` and `x.dur`, must
be qualified with the operand's alias, such as `x.utid` or `y.freq`. Always
follow `INTERVAL INTERSECTION OF` with `|> SELECT ...`; without a `SELECT`
stage, every operand's `ts` and `dur` remain in the output row and produce
duplicate column names.
5. **Watch for behavioral differences.**
- **Rows with `dur IS NULL` or `ts IS NULL` are skipped.** If an input table
computes duration with `lead(ts) OVER (...) - ts AS dur`, its final row has
`dur IS NULL`. `_interval_intersect!` coerced `NULL` duration to `0`;
`INTERVAL INTERSECTION OF` skips rows where `ts` or `dur` is `NULL`.
- **Negative `ts` or `dur` is an error.** Unfinished slices with `dur = -1`
are rejected with `ts or dur is below zero`. Filter `WHERE dur >= 0` or
`WHERE dur > 0` in the input.
- **Overlapping intervals within an operand work.** `SPAN_JOIN` produces
wrong results when intervals overlap within a single table, whereas
`INTERVAL INTERSECTION OF` handles overlapping intervals automatically.
---
## Migrating Tree Aggregations
Replace `_graph_aggregating_scan!`, `_callstacks_self_to_cumulative!`, and
recursive CTE subtree or path sums with `TREE ACCUMULATE UP` and
`TREE ACCUMULATE DOWN`.
### Before: `_graph_aggregating_scan!`
```sql
CREATE PERFETTO TABLE _heap_graph_class_tree_cumulatives AS
SELECT
a.id,
a.cumulative_count,
a.cumulative_size
FROM _graph_aggregating_scan!(
(
SELECT id AS source_node_id, parent_id AS dest_node_id
FROM _heap_graph_class_tree
WHERE parent_id IS NOT NULL
),
(
SELECT
id,
self_count AS cumulative_count,
self_size AS cumulative_size
FROM _heap_graph_class_tree
WHERE id NOT IN (
SELECT parent_id FROM _heap_graph_class_tree WHERE parent_id IS NOT NULL
)
),
(cumulative_count, cumulative_size),
(
WITH agg AS (
SELECT
t.id,
SUM(t.cumulative_count) AS child_count,
SUM(t.cumulative_size) AS child_size
FROM $table t
GROUP BY t.id
)
SELECT
a.id,
a.child_count + r.self_count AS cumulative_count,
a.child_size + r.self_size AS cumulative_size
FROM agg a
JOIN _heap_graph_class_tree r USING (id)
)
) AS a;
```
### After: `TREE ACCUMULATE UP`
```sql
CREATE PERFETTO TABLE _heap_graph_class_tree_cumulatives AS
FROM _heap_graph_class_tree
|> TREE ACCUMULATE UP
SUM(self_count) AS cumulative_count,
SUM(self_size) AS cumulative_size;
```
### Migration checklist for `TREE ACCUMULATE`
1. **Delete intermediate raw tables, `parent_id` indexes, and back-joins.**
You can pipe directly from a macro or subquery:
```sql
CREATE PERFETTO TABLE _linux_perf_callstacks AS
FROM _callstacks_for_callsites!((SELECT callsite_id FROM perf_sample))
|> TREE ACCUMULATE UP SUM(self_count) AS cumulative_count;
```
`TREE ACCUMULATE` keeps all columns of the input row and appends
`cumulative_count` at the end. You do not need to `JOIN` the result back to
the input table or create a `PERFETTO INDEX` on `parent_id`.
2. **Make sure the source has `id` and `parent_id` columns.**
`TREE ACCUMULATE` looks up bare `id` and `parent_id` in the current row.
Root nodes must have `parent_id IS NULL`.
3. **Totals always include the node itself.**
`TREE ACCUMULATE UP SUM(x) AS total` adds `x` for the node to `x` for all of
its descendants. `TREE ACCUMULATE DOWN SUM(x) AS path` adds `x` for the node
to `x` for all of its ancestors. When migrating hand-written
`_graph_aggregating_scan!` queries, check whether the old step query forgot
to add `r.self_*` for non-leaf nodes.
4. **Preserve `ORDER BY id` on public tables if required.**
`TREE ACCUMULATE UP` emits rows in child-first order, and `DOWN` emits rows
in parent-first order. If a public stdlib table or test expects rows sorted
by `id`, query the resulting table with `SELECT ... ORDER BY id`.
---
## Common Pitfalls
### 1. Every source column needs a valid, distinct name
When a pipeline reads from a SQL subquery, the compiler checks every column
returned by that subquery before column pruning runs, including columns that a
later stage drops:
- **Unaliased expressions fail:**
```sql
-- ERROR: expected every column to have a valid name, but '1 + 1' is not one:
-- give it one with AS
FROM (SELECT id, parent_id, 1 + 1 FROM tree)
|> TREE ACCUMULATE UP SUM(id) AS total
|> SELECT id, total;
```
- **Duplicate column names in a subquery fail:** SQLite renames the second `x`
in `SELECT 1 AS x, 2 AS x` to `x:1`. The pipeline compiler rejects this with:
`expected distinct column names, but there are two named 'x', which SQLite renamed to 'x' and 'x:1'`.
### 2. Wrap `WHERE` and `JOIN` in parentheses inside `FROM`
A pipeline's `FROM` clause only accepts a table or view name, or a parenthesized
`SELECT` subquery:
```sql
-- ERROR:
FROM slice WHERE dur > 0 |> TREE ACCUMULATE UP SUM(dur) AS total;
-- OK:
FROM (SELECT id, parent_id, dur FROM slice WHERE dur > 0)
|> TREE ACCUMULATE UP SUM(dur) AS total;
```
### 3. Table aliases snapshot the row at the point they are bound
In pipe stages like `DROP`, `RENAME`, `SET`, and `EXTEND`, a table alias
remembers the columns the row had when the alias was created:
- `FROM t |> DROP x |> SELECT t.x` still works and recovers `x`.
- `FROM t |> RENAME x AS y |> SELECT t.x` still accesses the column as `t.x`,
while bare `y` accesses it by its new name.
- `|> SELECT ...` builds a new row and clears all previous aliases.
- `|> AS u` replaces all previous aliases with `u`, snapshotting the current
row.