blob: a13b209f7fd2f748f113c18531ec24b72c0391aa [file] [view] [edit]
# Lazy relations and DuckDB interop for the Trace Processor Python API
**Authors:** @LalitMaganti
**Status:** Draft
**PR:** N/A
## Problem
The Trace Processor Python API is built around `tp.query(sql)`. The call runs
the query to completion and returns every row. This makes three things harder
than they should be.
**Results are slow to convert and lose their types.** Results travel as the
row-major `QueryResult.CellsBatch` proto, which has a type tag per cell.
`as_pandas_dataframe()` builds a NumPy array of Python objects from it, so
every pandas column has the `object` dtype. `as_polars_dataframe()` goes
through Python lists and produces polars `Object` columns. Neither can be
exported as Arrow. This makes every hand-off to another tool both slow and
lossy.
**Tools working on a TP result cannot push work back into TP.** Users
increasingly combine TP with DuckDB or polars: they join trace data with
experiment metadata, write Parquet, or keep analysing in a dataframe library.
Today they first materialise the whole TP result in Python, then filter it in
the other tool. Any filter or projection written in DuckDB or polars runs after
TP has already produced every row and every column.
**There is no lazy handle on a query.** A query result cannot be passed around
and composed before it runs, or used as a table inside another query.
DuckDB's Python API is the reference point for what users expect. Its relations
are lazy. Arrow is its interchange format. Python objects can be referenced from
SQL by name. Its query engine pushes filters and projections into the sources it
reads.
## Decision
Pending
## Design
The design has four parts. Each part is useful without the parts after it.
1. Arrow as a query result format.
2. A compile-only statement RPC.
3. `tp.lazy_query()`: a lazy relation over a PerfettoSQL query.
4. Filter and projection pushdown from DuckDB into relations.
`tp.query()` does not change. Its eager behaviour, its error behaviour and its
result type stay exactly as they are today.
### 1. Arrow results
Add a result format field to `QueryArgs`. When the client asks for Arrow, the
server encodes each result as an Arrow IPC stream instead of `CellsBatch`
messages. The server reuses `core/dataframe/arrow_serializer.h`, which already
backs `export(..., 'arrow_tar')`.
SQLite is dynamically typed, so the server must pick one Arrow type per column:
* A column backed by a typed dataframe column keeps that column's type. This
covers tables, `PERFETTO TABLE`s and views which only select their columns.
* Any other column is typed from its first non-null value. Integers are
promoted to doubles if doubles appear later. Any other mix of types is an
error. The error message names the column and suggests a `CAST`.
* A column with only nulls is typed as null.
A server which does not know the field answers with `invalid_request`. The
client then falls back to `CellsBatch`, so a new client works against an old
`trace_processor_shell`.
On the Python side, query results gain `as_arrow_table()` and the Arrow
PyCapsule `__arrow_c_stream__` protocol. Through the PyCapsule protocol,
pyarrow, polars, pandas and DuckDB can all consume a result without code
specific to each library.
This lands in two steps. The first step converts the `CellsBatch` cells to
Arrow in Python, typing each column from the cell types in it. This gives typed
interop immediately, with no protocol change. The server-side encoding above is
the second step, and makes the conversion faster without changing the Python
API.
`as_pandas_dataframe()` and `as_polars_dataframe()` do not change, for backwards
compatibility. Their columns keep the `object` dtype. Typed pandas and polars
output comes from `tp.lazy_query()`, described in part 3.
### 2. Compile-only statement RPC
Laziness needs three things before execution: the query's schema, its errors,
and the separation of setup statements from the final query. Add a mode to
`TPM_STATEMENT_STREAMING` or a new method. Given a multi-statement query, the
server will:
1. Execute every statement except the last.
2. Compile the last statement without stepping it.
3. Return the column names and types, whether the statement is read-only, and
any error.
A statement counts as read-only only if SQLite reports it as such *and* it calls
no function with side effects. TP will mark functions with side effects
explicitly. Today these are `RUN_METRIC` and the file I/O intrinsics.
The existing statement-at-a-time RPC is not enough on its own: it reports where
a statement ends only after executing it.
### 3. `tp.lazy_query()`: lazy relations
`tp.lazy_query(sql)` returns a `LazyQuery`. The name says what distinguishes it
from `tp.query()`: nothing runs until the result is consumed. A shorter name
such as `sql()` would read as a synonym for `query()`.
Its semantics deliberately match DuckDB's `con.sql()`. These were verified
against DuckDB 2.0.0:
| Behaviour | DuckDB 2.0 `con.sql()` | `tp.lazy_query()` |
| --- | --- | --- |
| Statements that change state | run at the call | run at the call |
| Final `SELECT` | lazy relation | lazy relation |
| Syntax and binding errors | raised at the call | raised at the call |
| Columns and types | known before execution | known before execution |
| Consuming twice | runs twice; no cache | runs twice; no cache |
| Later changes to the database | visible | visible |
| Side-effecting final `SELECT` | lazy | run at the call |
The last row is the one deliberate difference. A final statement which is not
read-only, as defined in part 2, runs at the call. This keeps
`tp.lazy_query("SELECT RUN_METRIC(...)")` working the way users expect.
Consuming a relation runs its query. A relation is consumed by iterating it, by
`.df()`, `.pl()` and `.arrow()`, and through `__arrow_c_stream__`.
`.to_table(name)` materialises the relation as a `PERFETTO TABLE` for explicit
caching.
Results are typed by default. `.arrow()` and `.pl()` give each column an Arrow
or polars type, with NULLs as nulls. `.df()` converts through Arrow, so pandas
columns get numeric dtypes even when they contain NULLs. `lazy_query()` has no
existing callers, so this needs none of the compatibility compromises that
`as_pandas_dataframe()` would.
Relations compose inside TP. Passing a relation by name, as in
`tp.lazy_query("SELECT * FROM binder WHERE dur > 1e6", binder=rel)`, inlines the
relation's SQL as a subquery. Both relations live in the same TP instance, so
this costs nothing. The outer query's filters are then pushed down by SQLite;
see
[Why wrapping a query preserves pushdown](#why-wrapping-a-query-preserves-pushdown).
Relations have no dataframe-style methods such as `.filter()` or `.select()`.
Users compose relations in SQL, or push down from DuckDB as described next.
### 4. DuckDB pushdown into relations
The goal is for DuckDB to query a relation like a table, with DuckDB's filters
and projections pushed into TP:
```python
binder = tp.lazy_query("""
INCLUDE PERFETTO MODULE slices.with_context;
SELECT ts, dur, thread_name, depth
FROM thread_slice WHERE name GLOB 'binder*'
""")
duckdb.sql("SELECT thread_name, dur FROM binder WHERE dur > 5e6 AND depth = 0")
```
TP receives the user's query wrapped with DuckDB's projection and filters:
```sql
SELECT "dur", "thread_name", "depth"
FROM (SELECT ts, dur, thread_name, depth
FROM thread_slice WHERE name GLOB 'binder*')
WHERE ("depth" = 0) AND ("dur" > 5000000)
```
#### Mechanism
In duckdb-python 2.0, the object behind a Python variable can take part in
DuckDB's optimizer by being an instance of `ArrowScanSource`. For each filter it
is willing to push, DuckDB calls `accepts(predicate)`. The predicate is a small
tree of columns, literals, comparisons, `AND`, `OR`, `NOT`, `IN` and
`IS [NOT] NULL`. DuckDB then calls `stream(columns, filters)` with the projected
columns and the accepted filters, and expects an Arrow stream back. DuckDB's own
polars and pyarrow support is built on this protocol.
The protocol lives in the private module `duckdb._sources`. It is undocumented
and its only users are duckdb-python's own tests. We accept this dependency
because the integration below degrades gracefully: if the protocol breaks,
queries get slower, not wrong.
#### Integration
* **Feature detection.** On import, the integration tries
`from duckdb._sources import ArrowScanSource, nodes`. It checks that the
pieces it uses exist. It does not pin DuckDB versions.
* **Optional base class.** `LazyQuery` derives from `ArrowScanSource` only when
the protocol is available. Otherwise it is a plain object with
`__arrow_c_stream__`. DuckDB can still query it by variable name, and reads
the full result.
* **Default to refusing.** `accepts()` refuses anything it does not recognise:
unknown node types, literal types outside an allow-list, and any exception
raised during translation. A refused predicate stays in DuckDB.
* **Semantic equivalence.** A predicate is accepted only when DuckDB and SQLite
evaluate it identically. That means integer, float and string comparisons,
`IN`, `IS [NOT] NULL` and the boolean connectives. NaN literals are refused
because SQLite has no NaN. Boolean literals are refused, as is any literal
whose type mapping is ambiguous.
* **Tolerant signatures.** `stream()` accepts `*args` and `**kwargs`, so a new
optional argument does not break it.
* **Off switch.** Pushdown can be disabled with
`tp.lazy_query(..., duckdb_pushdown=False)` or with an environment variable.
#### Error reporting
* If `duckdb>=2` is installed but the protocol is missing, the integration warns
once. The warning says that pushdown is unavailable for that DuckDB version,
that results are still correct, and where the documentation is. It stays
silent when DuckDB is absent or older than 2.0.
* An exception raised inside the source is re-raised with Perfetto context: the
operation, how to disable pushdown, and where to report it.
#### Documentation and testing
* The Python API docs describe what is pushed down, the dependency on DuckDB
internals and the tested versions. They show how to verify pushdown with
`EXPLAIN`, which lists `Filters:` under `PYTHON_ARROW_SCAN`, and how to
disable it.
* A CI job runs against the latest DuckDB release. It asserts that pushdown
actually happens, so a protocol change fails loudly in our CI before users
notice.
* A differential test runs each query with and without pushdown and requires
identical results. It covers NULLs, strings, mixed numeric types and empty
results.
#### Why wrapping a query preserves pushdown
Pushdown only pays off if SQLite carries the outer filter into the inner query.
Measured on `test/data/android_startup_real.perfetto_trace` with
`SELECT * FROM (<base>) WHERE <filter>`:
| Base query | Filter pushed into TP | Wrapped | Full base |
| --- | --- | --- | --- |
| Table `slice` | yes | 0.1 ms | 3.2 ms |
| View with joins `thread_slice` | yes | 0.1 ms | 3.4 ms |
| Explicit `LEFT JOIN` | yes | 0.1 ms | 16.6 ms |
| `GROUP BY`, filter on the key | yes | 0.4 ms | 28.9 ms |
| Window, filter on the partition key | yes | 0.4 ms | n/a |
| `GROUP BY`, filter on an aggregate | no | 34.6 ms | 29.8 ms |
| Window, filter on another column | no | 159 ms | 159 ms |
| `LIMIT` inside the base | no | 25 ms | 11 ms |
The cases which are not pushed are the ones where pushing would change the
result. DuckDB stops at the same boundaries.
#### Limits
DuckDB does not offer `LIKE`, `GLOB`, `LIMIT` or join-derived filters to
sources. These run in DuckDB after TP has produced the rows.
#### Upstream
We will ask duckdb-python to make the source protocol public, ideally through a
dunder such as `__duckdb_source__()` so that objects can opt in without
subclassing. We will also ask for prefix `LIKE` and `LIMIT` to be offered to
sources. A public protocol removes the main risk of this design. Our prototype
is a concrete second user to point to.
## Alternatives considered
### Make `tp.query()` lazy
Pro:
* No new API.
Con:
* `query()` is called purely for its side effects. In this repository,
`breakdown.py` runs `DROP VIEW`, `INCLUDE` and `CREATE VIEW` through it and
never reads the result. A lazy `query()` would silently skip these calls.
* `SELECT`s with side effects exist, such as `RUN_METRIC` and the file I/O
intrinsics. So DuckDB's rule of making only `SELECT`s lazy is not safe either.
* Errors would move from the call to the first use. `api_integrationtest.py`
relies on errors being raised at the call.
* Results returned out of a `with TraceProcessor(...)` block would run after the
instance is closed.
Making it safe needs flushing pending results before every state change and on
`close()`. An opt-in `tp.lazy_query()` with DuckDB's semantics is simpler and
breaks nothing.
### A dataframe-style relation API
This means methods such as `.filter()`, `.select()` and `.aggregate()` which
compile to SQL. TP already pushes predicates through wrapped queries, so the
compilation would be easy.
Con:
* It duplicates what users can already write in SQL.
* It adds a large API surface to maintain.
* It does not address the actual need: pushdown from the tools users already
use.
### A native DuckDB extension
This is the prototype in #7757, #7758 and #7759. It covers `ATTACH`,
`perfetto_query()`, filter pushdown and `PERFETTO(t) (...)` blocks. We evaluated
it against DuckDB 2.0.0.
Pro:
* It works in the DuckDB CLI and in every DuckDB client, not only Python.
* It supports running one SQL statement over many traces in parallel.
* DuckDB 2.0 grammar extensions can replace `parser_override` for block syntax.
A test extension parsed multi-statement blocks containing strings, comments,
macros and `$` parameters, and loaded into both the stock CLI and the Python
client.
Con:
* C API v2 ships with every function marked unstable (duckdb/duckdb#27410). So
even an extension built on it is tied to the exact DuckDB version.
* Grammar extensions and `ATTACH` are C++-only.
* The stock 2.0 CLI exports no DuckDB symbols. Extensions must statically link
DuckDB, which made the test extension 14 MB. They must also be built against
libstdc++, while Perfetto's standalone Linux build defaults to its own libc++.
* Block syntax has rough edges. The interactive CLI splits a block at any line
ending in `;`. duckdb-python's `.sql()` rejects `$name` inside blocks.
Comments in the body are lost.
We defer the extension until C API v2 functions are marked stable. At that point
the core functions can be ported to it and published as a community extension.
### A polars LazyFrame bridge
`tp.lazy_query()` could return a polars LazyFrame built with the public
`register_io_source`. DuckDB pushes filters into LazyFrames, and polars passes
them on to the IO source. A prototype pushed DuckDB filters and projections into
TP using only public APIs.
Con:
* Polars passes predicates as expressions. The only way to inspect them is
`Expr.meta.serialize(format="json")`, whose format polars does not keep stable
across versions. `IN` lists arrive as Arrow IPC bytes.
* It makes polars a hard dependency, even for users who only want DuckDB.
* Adding methods of our own requires subclassing LazyFrame, which relies on
polars internals.
### Expose TP tables to DuckDB directly
This means a source per TP table, with DuckDB building the query. A prototype
worked using the same protocol as part 4.
Con:
* The unit users work with is a PerfettoSQL query, which includes stdlib
modules, macros and table functions, not a raw table.
* DuckDB and SQLite semantics differ for parts of the query that DuckDB would
run, such as integer division, NULL ordering and collation.
### Compile relations to PerfettoSQL pipe syntax
Each relation step would map onto a pipe stage. The pipeline executor reads TP
dataframes directly.
Con:
* Pipe syntax is experimental.
* Its `WHERE` stage only supports numeric comparisons joined by `AND`.
* It has no `LIMIT` or join stage.
* It cannot appear inside a subquery.
It remains a possible backend for relations later. Adopting it would not change
the Python surface.
## Open questions
* Which pandas dtypes should `LazyQuery.df()` use for columns with NULLs:
pandas' nullable dtypes such as `Int64`, or `ArrowDtype`? Both represent NULL
as `pd.NA`, which behaves differently from `None` in boolean contexts.
* Does type inference by first non-null value hold up on real stdlib output, or
should columns of mixed types become strings instead of an error?
* Which functions besides `RUN_METRIC` and the file I/O intrinsics need the
side-effect flag?
* How should `BatchTraceProcessor` expose relations? One relation per trace, a
union, or both?
* Should `register(name, df)` be part of this RFC? It would upload Arrow data
for use inside PerfettoSQL using the existing `DeserializeFromArrow`.
* Is `ArrowScanSource` likely to change shape before duckdb-python 2.1? It is
worth asking upstream before building on it.