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 TABLEs 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:

BehaviourDuckDB 2.0 con.sql()tp.lazy_query()
Statements that change staterun at the callrun at the call
Final SELECTlazy relationlazy relation
Syntax and binding errorsraised at the callraised at the call
Columns and typesknown before executionknown before execution
Consuming twiceruns twice; no cacheruns twice; no cache
Later changes to the databasevisiblevisible
Side-effecting final SELECTlazyrun 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.

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:

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:

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 queryFilter pushed into TPWrappedFull base
Table sliceyes0.1 ms3.2 ms
View with joins thread_sliceyes0.1 ms3.4 ms
Explicit LEFT JOINyes0.1 ms16.6 ms
GROUP BY, filter on the keyyes0.4 ms28.9 ms
Window, filter on the partition keyyes0.4 msn/a
GROUP BY, filter on an aggregateno34.6 ms29.8 ms
Window, filter on another columnno159 ms159 ms
LIMIT inside the baseno25 ms11 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.
  • SELECTs with side effects exist, such as RUN_METRIC and the file I/O intrinsics. So DuckDB's rule of making only SELECTs 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.