This guide covers DataFrame performance profiling, optimization, and benchmarking across platforms.
BenchBox provides tools for:
- Profiling DataFrame query execution
- Comparing DataFrame vs SQL performance
- Capturing and analyzing query plans
- Measuring lazy evaluation overhead
from benchbox.core.dataframe.profiling import DataFrameProfiler
profiler = DataFrameProfiler(platform="polars")
with profiler.profile_query("q1") as ctx:
result = df.filter(col("l_shipdate") <= lit(cutoff)).collect()
ctx.set_rows(len(result))
profile = ctx.get_profile()
print(f"Query: {profile.query_id}")
print(f"Execution time: {profile.execution_time_ms:.2f}ms")
print(f"Rows processed: {profile.rows_processed}")For platforms with lazy evaluation (Polars, PySpark, DataFusion):
with profiler.profile_query("q1") as ctx:
ctx.start_planning()
lazy_result = df.filter(...).group_by(...).agg(...)
ctx.end_planning()
ctx.start_collect()
result = lazy_result.collect()
ctx.end_collect()
ctx.set_rows(len(result))
profile = ctx.get_profile()
print(f"Planning: {profile.planning_time_ms:.2f}ms")
print(f"Collect: {profile.collect_time_ms:.2f}ms")
print(f"Lazy overhead: {profile.lazy_overhead_percent:.1f}%")for query_id in ["q1", "q2", "q3"]:
with profiler.profile_query(query_id) as ctx:
execute_query(query_id)
stats = profiler.get_statistics()
print(f"Queries: {stats['query_count']}")
print(f"Total time: {stats['total_execution_time_ms']:.0f}ms")
print(f"Average time: {stats['avg_execution_time_ms']:.2f}ms")
print(f"Lazy evaluation queries: {stats['lazy_evaluation_queries']}")
print(f"Avg lazy overhead: {stats['avg_lazy_overhead_percent']:.1f}%")Capture the plan before calling collect():
from benchbox.core.dataframe.profiling import capture_polars_plan
lazy_df = (
df.lazy()
.filter(col("l_shipdate") <= lit(cutoff))
.group_by("l_returnflag")
.agg(col("l_quantity").sum())
)
plan = capture_polars_plan(lazy_df)
print(plan.plan_text)
print("Optimization hints:", plan.optimization_hints)
result = lazy_df.collect()from benchbox.core.dataframe.profiling import capture_datafusion_plan
plan = capture_datafusion_plan(df)
print(plan.plan_text)from benchbox.core.dataframe.profiling import capture_pyspark_plan
plan = capture_pyspark_plan(spark_df)
print(plan.plan_text)The generic function selects the capture method for the platform automatically:
from benchbox.core.dataframe.profiling import capture_query_plan
plan = capture_query_plan(df, platform="polars")
if plan:
print(plan.plan_text)
for hint in plan.optimization_hints:
print(f" - {hint}")Compare execution modes for platforms supporting both. The sql_times dictionary holds SQL execution times in milliseconds from a previous benchmark run:
from benchbox.core.dataframe.profiling import (
DataFrameProfiler,
compare_execution_modes,
)
df_profiler = DataFrameProfiler(platform="polars-df")
for query_id in benchmark.query_ids:
with df_profiler.profile_query(query_id):
benchmark.execute_dataframe_query(query_id)
sql_times = {"q1": 45.2, "q2": 123.4, "q3": 89.1}
comparisons = compare_execution_modes(
df_profiler.get_profiles(),
sql_times
)
for comp in comparisons:
print(f"{comp.query_id}: DataFrame={comp.dataframe_time_ms:.1f}ms, "
f"SQL={comp.sql_time_ms:.1f}ms, Winner={comp.winner}")
for note in comp.notes:
print(f" - {note}")| Characteristic | Value | Notes |
|---|---|---|
| Execution Model | Lazy with streaming | Query plan optimized before execution |
| Memory Efficiency | High | Columnar, zero-copy operations |
| Parallelism | Automatic | Thread pool based on CPU cores |
| Best For | Medium-large datasets | Up to ~100GB on single node |
Optimization Tips:
- Use
.lazy()for query optimization - Enable streaming for very large datasets:
--execution-engine streaming - Minimize
.collect()calls - Filter early to reduce data volume
| Characteristic | Value | Notes |
|---|---|---|
| Execution Model | Eager | Each operation executes immediately |
| Memory Efficiency | Moderate | Higher memory footprint |
| Parallelism | Limited | Single-threaded by default |
| Best For | Small-medium datasets | Up to ~10GB |
Optimization Tips:
- Use PyArrow backend:
--platform-option dtype_backend=pyarrow - Minimize DataFrame copies
- Use vectorized operations
- Filter data early
| Characteristic | Value | Notes |
|---|---|---|
| Execution Model | Lazy | Similar to Polars |
| Memory Efficiency | High | Arrow-native |
| Parallelism | Automatic | Configurable thread pool |
| Best For | Analytical queries | Arrow ecosystem integration |
| Characteristic | Value | Notes |
|---|---|---|
| Execution Model | Lazy | Catalyst optimizer |
| Memory Efficiency | Variable | Depends on configuration |
| Parallelism | Distributed | Cluster-wide execution |
| Best For | Very large datasets | Terabyte+ scale |
polars_result=$(benchbox run --platform polars-df --benchmark tpch --scale 1 --output ./polars-run --quiet)
pandas_result=$(benchbox run --platform pandas-df --benchmark tpch --scale 1 --output ./pandas-run --quiet)
duckdb_result=$(benchbox run --platform duckdb-df --benchmark tpch --scale 1 --output ./duckdb-run --quiet)
benchbox compare "$polars_result" "$pandas_result"
benchbox compare "$polars_result" "$duckdb_result"from pathlib import Path
import json
def load_results(path: str) -> dict:
with open(path) as f:
return json.load(f)
def compare_platforms(result_paths: list[str]) -> dict:
results = {Path(p).stem: load_results(p) for p in result_paths}
comparison = {}
for platform, data in results.items():
query_times = {
q["query_id"]: q["execution_time_ms"]
for q in data.get("query_results", [])
}
comparison[platform] = query_times
return comparison
comparison = compare_platforms([
"results/polars_sf1.json",
"results/pandas_sf1.json",
"results/duckdb_sf1.json"
])
for query_id in comparison["polars_sf1"]:
times = {p: comparison[p].get(query_id) for p in comparison}
fastest = min(times, key=lambda x: times[x] or float('inf'))
print(f"{query_id}: {fastest} ({times[fastest]:.0f}ms)")import tracemalloc
tracemalloc.start()
result = df.filter(...).collect()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Current memory: {current / 1024 / 1024:.1f} MB")
print(f"Peak memory: {peak / 1024 / 1024:.1f} MB")The peak memory from tracemalloc is converted to MB before it is recorded:
import tracemalloc
with profiler.profile_query("q1") as ctx:
tracemalloc.start()
result = df.filter(...).collect()
ctx.set_rows(len(result))
_, peak = tracemalloc.get_traced_memory()
ctx.set_peak_memory(peak / 1024 / 1024)
tracemalloc.stop()
profile = ctx.get_profile()
print(f"Peak memory: {profile.peak_memory_mb:.1f} MB")Always measure before making changes:
baseline_profiles = []
for i in range(3):
with profiler.profile_query(f"q1_run{i}"):
execute_query()
stats = profiler.get_statistics()
print(f"Baseline avg: {stats['avg_execution_time_ms']:.2f}ms")Understand what the optimizer is doing:
plan = capture_query_plan(lazy_df, "polars")
print(plan.plan_text)For platforms supporting both. The eager run applies only where eager execution is available:
with profiler.profile_query("q1_lazy") as ctx:
ctx.start_planning()
lazy_result = df.lazy().filter(...).group_by(...).agg(...)
ctx.end_planning()
ctx.start_collect()
result = lazy_result.collect()
ctx.end_collect()
with profiler.profile_query("q1_eager") as ctx:
result = df.filter(...).group_by(...).agg(...)
lazy_profile = profiler.get_profile("q1_lazy")
eager_profile = profiler.get_profile("q1_eager")
print(f"Lazy: {lazy_profile.execution_time_ms:.2f}ms "
f"(overhead: {lazy_profile.lazy_overhead_percent:.1f}%)")
print(f"Eager: {eager_profile.execution_time_ms:.2f}ms")| Platform | Recommended Max SF | Memory Needed |
|---|---|---|
| Pandas | 1-10 | ~6GB per SF |
| Polars | 10-100 | ~4GB per SF |
| DataFusion | 10-100 | ~4GB per SF |
| PySpark | 100+ | Cluster dependent |
plan = capture_polars_plan(lazy_df)
if plan.optimization_hints:
print("Optimization opportunities:")
for hint in plan.optimization_hints:
print(f" - {hint}")- DataFrame Platforms - Platform overview
- DataFrame Tuning - Configuration options
- Polars Platform - Polars-specific details
- Result Analysis - Analyzing benchmark results