Skip to content

Commit 460030f

Browse files
FrancescAltedclaude
andcommitted
docs: rewrite the pandas engine guide around the readable-UDF pitch
The guide opened with `add_one(col) -> col + 1`, which measures 0.53x on a 1M x 8 frame — i.e. it led with a case where `engine=blosc2.jit` is twice as slow as a plain `apply`. The actual payoff only appeared in a benchmark block at the bottom. Restructure so the first screenful explains why and when the engine helps: - Replace the running example with Yeo-Johnson (the power transform behind sklearn's PowerTransformer). Its parameter is fitted per column, so applying it across every column is the correct semantics rather than an artifact, and it is multi-statement, which is what the readability argument rests on. - Add a two-panel plot measuring the two conditions separately: speedup vs row count (break-even between 100k and 1M rows) and vs the number of fused operations (2.1x for one, 3.8x for five). - Keep `col + 1` as an explicit counter-example under "when not to use it". - Move the `axis=` discussion and Limitations to the end as reference material. Add an honest comparison against `pd.eval`/numexpr, which a reader arriving from pandas' Enhancing performance page will ask about: numexpr is somewhat faster on in-memory frames (3.14x vs 2.13x), and the argument for the Blosc2 engine is that you write a Python function rather than a quoted string. Blosc2's compressed/out-of-core strength does not apply on this path, since pandas materialises each column as a NumPy array first. Document three gotchas found while measuring: - Plain `apply` passes a Series (`std()` defaults to ddof=1) while the engine passes an ndarray (ddof=0), so identical source computes different numbers. - `np.where` evaluates both arms, so each must be clamped to its own domain; without this the Yeo-Johnson example raises a negative base to a fractional power for ~159k of 1M elements and floods the run with NaN warnings. - `np.sign` is unsupported on traced expressions. Also say "evaluates" rather than "compiles": with the default `jit=None`, plain expressions go to miniexpr's bytecode interpreter, not a JIT-compiled kernel. The win here is fusion, not compilation. bench/bench_pandas_engine.py gains the two sweeps, a numexpr reference series and the plot, so every figure quoted in the guide is reproducible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95ab1b8 commit 460030f

3 files changed

Lines changed: 305 additions & 61 deletions

File tree

‎bench/bench_pandas_engine.py‎

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,22 @@
88
# Benchmark: DataFrame.apply(f, engine=blosc2.jit) vs plain DataFrame.apply(f)
99
#
1010
# engine=blosc2.jit calls the vectorized function once per column (the
11-
# default axis=0), so the win comes from the Blosc2/numexpr compute engine
12-
# (operator fusion, multi-threading) beating plain NumPy on a
13-
# multi-operation elementwise expression over a large 1D array. This script
14-
# measures that on a 1,000,000-row, 8-column frame.
11+
# default axis=0), so the win comes from the Blosc2 compute engine (operator
12+
# fusion, multi-threading) beating plain NumPy, which evaluates the function
13+
# body one operation at a time and allocates a full-size temporary at each
14+
# step.
15+
#
16+
# Two conditions must both hold for the engine to pay off, and this script
17+
# measures each one separately:
18+
#
19+
# * enough rows -- below ~50k the per-call setup dominates (rows sweep)
20+
# * enough ops -- a single operation has nothing to fuse (ops sweep)
21+
#
22+
# numexpr is measured alongside as a reference point: on in-memory frames it
23+
# is somewhat faster than the Blosc2 engine, so the argument for
24+
# engine=blosc2.jit is that you write a readable Python function instead of a
25+
# quoted expression string, not that it wins a raw speed race. See
26+
# doc/guides/pandas_engine.md.
1527
#
1628
# Note: axis=1 (row-wise) is NOT a good fit for this engine. It still calls
1729
# the function once per row in a Python loop either way, and for a handful
@@ -22,27 +34,76 @@
2234
#
2335
# Each measurement is the minimum of NRUNS repetitions to reduce noise.
2436

37+
from pathlib import Path
2538
from time import perf_counter
2639

40+
import numexpr
2741
import numpy as np
2842
import pandas as pd
2943

3044
import blosc2
3145

32-
NRUNS = 3
46+
NRUNS = 5
3347
NROWS = 1_000_000
3448
NCOLS = 8
3549

50+
ROW_SWEEP = (1_000, 10_000, 100_000, 1_000_000, 5_000_000)
51+
52+
OUT_DIR = Path(__file__).resolve().parent.parent / "doc" / "guides" / "pandas_engine"
53+
54+
# dataviz reference palette, same values as bench/optim_tips/common.py
55+
COLOR_TIP = "#1baf7a"
56+
INK = "#0b0b0b"
57+
MUTED = "#898781"
58+
GRID = "#e1e0d9"
3659

37-
def make_df():
60+
61+
def make_df(nrows=NROWS, ncols=NCOLS):
3862
rng = np.random.default_rng(0)
3963
return pd.DataFrame(
40-
{f"c{i}": rng.random(NROWS) for i in range(NCOLS)},
64+
{f"c{i}": rng.normal(size=nrows) for i in range(ncols)},
4165
)
4266

4367

44-
def transform(col):
45-
return np.sin(col) * np.cos(col) + col**2 - np.sqrt(np.abs(col)) + np.exp(-col)
68+
def yeo_johnson(col, lam=0.5):
69+
"""Power transform behind sklearn's PowerTransformer, applied per column.
70+
71+
np.where evaluates both arms over the whole column, so each is clamped to
72+
its own domain: without the np.maximum calls, the unselected arm raises a
73+
negative base to a fractional power and floods the run with NaNs and
74+
"invalid value encountered in power" warnings.
75+
"""
76+
pos = (np.power(np.maximum(col, 0.0) + 1.0, lam) - 1.0) / lam
77+
neg = -(np.power(np.maximum(-col, 0.0) + 1.0, 2.0 - lam) - 1.0) / (2.0 - lam)
78+
return np.where(col >= 0, pos, neg)
79+
80+
81+
# The same transform as a single numexpr expression: legal, but this is what
82+
# the readability argument is about.
83+
YEO_JOHNSON_NX = (
84+
"where(c >= 0, "
85+
"((maximum(c, 0.0) + 1.0) ** lam - 1.0) / lam, "
86+
"-((maximum(-c, 0.0) + 1.0) ** (2.0 - lam) - 1.0) / (2.0 - lam))"
87+
)
88+
89+
90+
def numexpr_apply(df, expr=YEO_JOHNSON_NX, lam=0.5):
91+
return pd.DataFrame(
92+
{col: numexpr.evaluate(expr, local_dict={"c": df[col].values, "lam": lam}) for col in df},
93+
index=df.index,
94+
)
95+
96+
97+
# Prefixes of one expression, so the number of fused operations is the only
98+
# variable: mixing in different *kinds* of operation would measure the cost of
99+
# the operations rather than the benefit of fusing them.
100+
OPS_SWEEP = (
101+
("1", lambda col: np.sin(col)),
102+
("2", lambda col: np.sin(col) * np.cos(col)),
103+
("3", lambda col: np.sin(col) * np.cos(col) + col**2),
104+
("4", lambda col: np.sin(col) * np.cos(col) + col**2 - np.sqrt(np.abs(col))),
105+
("5", lambda col: np.sin(col) * np.cos(col) + col**2 - np.sqrt(np.abs(col)) + np.exp(-col)),
106+
)
46107

47108

48109
def timeit(fn):
@@ -55,18 +116,89 @@ def timeit(fn):
55116
return best, result
56117

57118

119+
def speedup(df, func):
120+
"""Plain apply vs engine=blosc2.jit, returning (speedup, t_plain, t_engine)."""
121+
t_plain, _ = timeit(lambda: df.apply(func))
122+
t_engine, _ = timeit(lambda: df.apply(func, engine=blosc2.jit))
123+
return t_plain / t_engine, t_plain, t_engine
124+
125+
126+
def save_plot(row_speedups, ops_speedups, out_path):
127+
import matplotlib
128+
129+
matplotlib.use("Agg")
130+
import matplotlib.pyplot as plt
131+
132+
fig, (ax_rows, ax_ops) = plt.subplots(1, 2, figsize=(8, 3.2))
133+
134+
ax_rows.semilogx(ROW_SWEEP, row_speedups, "o-", color=COLOR_TIP, linewidth=2)
135+
ax_rows.set_xlabel("rows (log scale)", color=INK, fontsize=9)
136+
ax_rows.set_ylabel("speedup vs plain apply", color=INK, fontsize=9)
137+
ax_rows.set_title(f"{NCOLS} columns, Yeo-Johnson", color=MUTED, fontsize=9)
138+
139+
labels = [name for name, _ in OPS_SWEEP]
140+
ax_ops.plot(range(len(labels)), ops_speedups, "o-", color=COLOR_TIP, linewidth=2)
141+
ax_ops.set_xticks(range(len(labels)))
142+
ax_ops.set_xticklabels(labels)
143+
ax_ops.set_xlabel("operations fused into one pass", color=INK, fontsize=9)
144+
ax_ops.set_title(f"{NROWS:,} rows x {NCOLS} columns", color=MUTED, fontsize=9)
145+
146+
for ax, values in ((ax_rows, row_speedups), (ax_ops, ops_speedups)):
147+
# Break-even: below this line the engine is a net loss.
148+
ax.axhline(1.0, color=MUTED, linestyle="--", linewidth=1)
149+
ax.set_ylim(0, max(values) * 1.25)
150+
ax.yaxis.set_major_formatter(lambda v, _pos: f"{v:g}x")
151+
ax.yaxis.grid(True, color=GRID, linewidth=0.8)
152+
ax.set_axisbelow(True)
153+
ax.spines["top"].set_visible(False)
154+
ax.spines["right"].set_visible(False)
155+
ax.spines["left"].set_color(GRID)
156+
ax.spines["bottom"].set_color(GRID)
157+
ax.tick_params(labelsize=9, colors=MUTED)
158+
159+
fig.suptitle(
160+
"df.apply(f, engine=blosc2.jit): when it pays off",
161+
fontsize=11,
162+
color=INK,
163+
)
164+
fig.tight_layout(rect=[0, 0, 1, 0.90])
165+
out_path.parent.mkdir(parents=True, exist_ok=True)
166+
fig.savefig(out_path, dpi=150)
167+
plt.close(fig)
168+
169+
58170
def main():
59171
df = make_df()
60172

61-
t_plain, result_plain = timeit(lambda: df.apply(transform))
62-
t_engine, result_engine = timeit(lambda: df.apply(transform, engine=blosc2.jit))
173+
t_plain, result_plain = timeit(lambda: df.apply(yeo_johnson))
174+
t_engine, result_engine = timeit(lambda: df.apply(yeo_johnson, engine=blosc2.jit))
175+
t_numexpr, result_numexpr = timeit(lambda: numexpr_apply(df))
63176

64177
pd.testing.assert_frame_equal(result_engine, result_plain)
65-
66-
print(f"rows={NROWS}, cols={NCOLS}")
67-
print(f"plain df.apply(f): {t_plain:.4f} s")
68-
print(f"df.apply(f, engine=blosc2.jit): {t_engine:.4f} s")
69-
print(f"speedup: {t_plain / t_engine:.1f}x")
178+
pd.testing.assert_frame_equal(result_numexpr, result_plain)
179+
180+
print(f"rows={NROWS}, cols={NCOLS}, transform=Yeo-Johnson")
181+
print(f"plain df.apply(f): {t_plain:.4f} s")
182+
print(f"df.apply(f, engine=blosc2.jit): {t_engine:.4f} s {t_plain / t_engine:.2f}x")
183+
print(f"numexpr per column: {t_numexpr:.4f} s {t_plain / t_numexpr:.2f}x")
184+
185+
print("\nrows sweep (speedup vs plain apply):")
186+
row_speedups = []
187+
for nrows in ROW_SWEEP:
188+
sp, tp, te = speedup(make_df(nrows=nrows), yeo_johnson)
189+
row_speedups.append(sp)
190+
print(f" {nrows:>9,} rows: plain {tp:.4f} s engine {te:.4f} s {sp:.2f}x")
191+
192+
print("\nops sweep (speedup vs plain apply):")
193+
ops_speedups = []
194+
for name, func in OPS_SWEEP:
195+
sp, tp, te = speedup(df, func)
196+
ops_speedups.append(sp)
197+
print(f" {name.replace(chr(10), ' '):>20}: plain {tp:.4f} s engine {te:.4f} s {sp:.2f}x")
198+
199+
out_path = OUT_DIR / "speedup.png"
200+
save_plot(row_speedups, ops_speedups, out_path)
201+
print(f"\nplot saved to {out_path}")
70202

71203

72204
if __name__ == "__main__":

0 commit comments

Comments
 (0)