Profiling
June 18, 2026 · View on GitHub
run_benchmarks.py --profile enables DuckDB JSON profiling for the selected cases and writes files to
bench/results/profiles/<case>/query_profile.json.
Example:
uv run --frozen python bench/run_benchmarks.py \
--profile \
--filter current/transform/1k/flat_core
In profile mode the number of runs is automatically reduced to 1 unless --runs is set explicitly.
The JSONO input for jsono is prepared before the profiled SQL, so the profile reflects the operation
itself rather than jsono.
For a CPU flamegraph it is more convenient to run the same filter through an external profiler (samply, Instruments, perf).
analyze_profile.py parses samply JSON when the profile is recorded with presymbolization.
By default it aggregates self-time across all execution threads — the honest view under
DuckDB's intra-query parallelism, where a single thread misleads (the main thread mixes in
coordination overhead, a lone worker over-weights its slice). Pass --thread <name> for a
single-thread per-row view; --min-thread-samples sets the idle-thread floor (default 50).
profile_driver.py is the standalone samply driver: it resolves one jsono benchmark case through the
same registry as run_benchmarks.py, prepares the input table once, then runs that case's timed SQL in a
tight loop so the flamegraph is dominated by the extension's C++ hotspots instead of DuckDB driver overhead.
samply record -- uv run --frozen python bench/profile_driver.py \
build/release/extension/jsono/jsono.duckdb_extension \
group_merge_jsono/100k/many_groups_ignore_nulls \
150 8
For local field-sample cases, pass the same flag used by the benchmark runner:
samply record -- uv run --frozen python bench/profile_driver.py \
--include-field-sample \
build/release/extension/jsono/jsono.duckdb_extension \
merge_patch/245760/retail_sample_nested \
80 8
Arguments are <extension_path> <filter> [iterations=150] [threads]. The filter uses the same substring
matching as run_benchmarks.py, but it must match exactly one jsono case; ambiguous filters fail with the
matched case list. It pairs with the git-ignored bench/optimization-log/jsono_review_regression_map.md for
regression triage.
Row-group pruning
run_benchmarks.py --row-groups prints the parquet row groups a timed query actually scanned next to
the timing, so shred-leaf filter pushdown is measurable directly rather than inferred from wall-clock:
uv run --frozen python bench/run_benchmarks.py --row-groups --threads 1 --filter prune_filter
# current/prune_filter/100k/synthetic_shredded [t1]... 0.7ms, …, row_groups 1/9
# current/prune_filter/100k/synthetic_native [t1]... 0.4ms, …, row_groups 1/9
row_groups <scanned>/<total> is OPERATOR_ROW_GROUPS_SCANNED / OPERATOR_TOTAL_ROW_GROUPS_TO_SCAN
on the scan operator. scanned < total means the filter pruned row groups; scanned == total means
no pruning; — means the timed query had no direct table scan (e.g. it filtered an in-memory temp
table that was answered without a scan node). The two prune_filter scenarios write one shredded,
event_ts-clustered Parquet (untimed) and time a selective band filter over it — synthetic_shredded
filters the typed shred leaf, synthetic_native the plain control column, and they prune identically,
proving the shred carries usable per-row-group statistics. --row-groups also works for any other case;
a scan of an in-memory temp table reports N/N (one native row group, no pruning).
The metric is persisted per result in latest.json as row_groups: [{operator, scanned, total}].
Manual recipe
These two operator metrics (populated by the parquet reader and the native table scan since DuckDB
1.5.4) are not in the default/detailed profiling set, so they must be requested explicitly,
and they do not appear in the query_tree / EXPLAIN ANALYZE text box — only in the JSON profile:
PRAGMA enable_profiling='json';
PRAGMA custom_profiling_settings='{"OPERATOR_NAME":"true","OPERATOR_TYPE":"true","OPERATOR_ROW_GROUPS_SCANNED":"true","OPERATOR_TOTAL_ROW_GROUPS_TO_SCAN":"true"}';
PRAGMA profiling_output='/tmp/prof.json';
-- the query under test, e.g.
SELECT count(*) FROM 'shredded_clustered.parquet'
WHERE CAST(t->>'$.event_ts' AS BIGINT) BETWEEN 1717000000 AND 1717120000;
PRAGMA disable_profiling;
Then walk the JSON tree for nodes with operator_type == "TABLE_SCAN" and read
operator_row_groups_scanned / operator_total_row_groups_to_scan (parse_row_group_scans in
run_benchmarks.py does exactly this). Caveats: a no-filter SELECT count(*) over Parquet is answered
from file metadata with no scan operator (no metric); pruning only works through typed shred leaves
(a residual ->> path reads an opaque blob with no per-row-group stats and never prunes); and the data
must be clustered on the leaf (ORDER BY on write) for row-group ranges to be disjoint.