Skip to content

Commit acc6a20

Browse files
Merge pull request #684 from Blosc/jit-full-ds2
@blosc2.jit: compile control flow instead of tracing it, and rework the pandas story
2 parents deaa8db + dcbdd6f commit acc6a20

18 files changed

Lines changed: 1996 additions & 208 deletions

‎CMakeLists.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ endif()
109109

110110
FetchContent_Declare(miniexpr
111111
GIT_REPOSITORY https://github.com/Blosc/miniexpr.git
112-
GIT_TAG aab4b2ff030ffeddba894d0fe45ab7df6e53bd47
112+
GIT_TAG 58d2d0b4a3aee3d1ac84b213712cf982744196c8
113113
# SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr
114114
)
115115
FetchContent_MakeAvailable(miniexpr)

‎bench/bench_pandas_engine.py‎

Lines changed: 247 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,19 @@
2525
# quoted expression string, not that it wins a raw speed race. See
2626
# doc/guides/pandas_engine.md.
2727
#
28-
# Note: axis=1 (row-wise) is NOT a good fit for this engine. It still calls
29-
# the function once per row in a Python loop either way, and for a handful
30-
# of columns the wrapping overhead per call (building a compute-engine proxy
31-
# for a tiny array) is larger than the win, so engine=blosc2.jit is actually
32-
# *slower* than plain apply(axis=1) in that case. Use axis=0 (or restructure
33-
# the computation to operate on whole columns) to get the engine's benefit.
28+
# Row-wise (axis=1) computations are a different story. apply() cannot express
29+
# per-row iteration at all, so the pattern to reach for is a function taking
30+
# the columns as separate array parameters, called directly (no df.apply) --
31+
# with **df, since a DataFrame unpacks into one keyword argument per column.
32+
# See "Row-wise computations" in doc/guides/pandas_engine.md. bench_row_wise()
33+
# below measures that pattern against a plain per-row apply() and vectorized
34+
# NumPy on a genuine per-row-convergence problem (Kepler's equation via
35+
# Newton-Raphson), where a real per-row `break` beats even vectorized NumPy,
36+
# and sweeps both what drives that win (rows, and how unevenly rows converge).
3437
#
3538
# Each measurement is the minimum of NRUNS repetitions to reduce noise.
3639

40+
import math
3741
from pathlib import Path
3842
from time import perf_counter
3943

@@ -49,6 +53,18 @@
4953

5054
ROW_SWEEP = (1_000, 10_000, 100_000, 1_000_000, 5_000_000)
5155

56+
# Plain per-row apply(axis=1) is ~1000x slower than the alternatives below;
57+
# keep this sweep small so the benchmark finishes in a reasonable time.
58+
ROW_WISE_APPLY_NROWS = 2_000
59+
60+
KEPLER_ROW_SWEEP = (10_000, 100_000, 1_000_000, 5_000_000)
61+
62+
# Maximum orbital eccentricity: the knob controlling how *unevenly* rows
63+
# converge. Near-circular orbits (0.1) all converge in the same 3 iterations;
64+
# near-parabolic ones (0.99) leave a slow tail that vectorized NumPy must keep
65+
# sweeping the whole array for, while the DSL kernel's per-row break does not.
66+
KEPLER_ECC_SWEEP = (0.1, 0.5, 0.9, 0.99)
67+
5268
OUT_DIR = Path(__file__).resolve().parent.parent / "doc" / "guides" / "pandas_engine"
5369

5470
# dataviz reference palette, same values as bench/optim_tips/common.py
@@ -78,6 +94,26 @@ def yeo_johnson(col, lam=0.5):
7894
return np.where(col >= 0, pos, neg)
7995

8096

97+
# The same transform written with a real per-element if/else. It compiles to a
98+
# DSL kernel instead of being traced, so only the matching arm runs for each
99+
# element and no clamping is needed. lam is inlined because apply() passes the
100+
# column alone. The explanation lives here rather than in a docstring: a DSL
101+
# kernel body cannot contain a string literal.
102+
def yeo_johnson_branch(col):
103+
if col >= 0:
104+
out = (np.power(col + 1.0, 0.5) - 1.0) / 0.5
105+
else:
106+
out = -(np.power(-col + 1.0, 1.5) - 1.0) / 1.5
107+
return out
108+
109+
110+
def yeo_johnson_scalar(x, lam=0.5):
111+
"""Per-element Python, the shape you would write without any engine."""
112+
if x >= 0:
113+
return ((x + 1.0) ** lam - 1.0) / lam
114+
return -((-x + 1.0) ** (2.0 - lam) - 1.0) / (2.0 - lam)
115+
116+
81117
# The same transform as a single numexpr expression: legal, but this is what
82118
# the readability argument is about.
83119
YEO_JOHNSON_NX = (
@@ -123,6 +159,151 @@ def speedup(df, func):
123159
return t_plain / t_engine, t_plain, t_engine
124160

125161

162+
# Kepler's equation, solved by Newton-Raphson: a genuine per-row-convergence
163+
# problem (row["colname"] combines two columns, and rows converge in a
164+
# different number of iterations), used to benchmark the row-wise
165+
# "columns as direct-call parameters" pattern from doc/guides/pandas_engine.md
166+
# against both a plain per-row apply(axis=1) and vectorized NumPy.
167+
def kepler_row_scalar(row):
168+
m = row["mean_anomaly"]
169+
ecc = row["eccentricity"]
170+
e = m + ecc * math.sin(m)
171+
for _ in range(100):
172+
diff = (e - ecc * math.sin(e) - m) / (1.0 - ecc * math.cos(e))
173+
e = e - diff
174+
if abs(diff) < 1e-12:
175+
break
176+
return e
177+
178+
179+
def kepler_numpy(m, ecc):
180+
e = m + ecc * np.sin(m)
181+
for _ in range(100):
182+
diff = (e - ecc * np.sin(e) - m) / (1.0 - ecc * np.cos(e))
183+
e = e - diff
184+
if np.max(np.abs(diff)) < 1e-12:
185+
break
186+
return e
187+
188+
189+
@blosc2.jit
190+
def kepler_dsl(mean_anomaly, eccentricity):
191+
e = mean_anomaly + eccentricity * sin(mean_anomaly) # noqa: F821 # 'sin' resolved as a bare DSL function name
192+
for _ in range(100):
193+
diff = (e - eccentricity * sin(e) - mean_anomaly) / (1.0 - eccentricity * cos(e)) # noqa: F821
194+
e = e - diff
195+
if abs(diff) < 1e-12:
196+
break
197+
return e
198+
199+
200+
def make_kepler_df(nrows, ecc_max=0.95):
201+
rng = np.random.default_rng(1)
202+
return pd.DataFrame(
203+
{
204+
"mean_anomaly": rng.uniform(0, 2 * np.pi, nrows),
205+
"eccentricity": rng.uniform(0.0, ecc_max, nrows),
206+
}
207+
)
208+
209+
210+
def kepler_max_iters(m, ecc):
211+
"""Iterations the slowest-converging row needs -- what vectorized NumPy
212+
pays for every row, and what the DSL kernel's per-row break avoids."""
213+
e = m + ecc * np.sin(m)
214+
for k in range(100):
215+
diff = (e - ecc * np.sin(e) - m) / (1.0 - ecc * np.cos(e))
216+
e = e - diff
217+
if np.max(np.abs(diff)) < 1e-12:
218+
return k + 1
219+
return 100
220+
221+
222+
def kepler_speedup(df):
223+
"""Vectorized NumPy vs the direct DSL call, returning (speedup, t_numpy, t_dsl)."""
224+
m = df["mean_anomaly"].to_numpy()
225+
ecc = df["eccentricity"].to_numpy()
226+
t_numpy, result_numpy = timeit(lambda: kepler_numpy(m, ecc))
227+
# Columns passed by keyword via **df: kernel parameters are named after the
228+
# DataFrame columns, so no column has to be restated at the call site.
229+
t_dsl, result_dsl = timeit(lambda: np.asarray(kepler_dsl(**df)))
230+
np.testing.assert_allclose(result_dsl, result_numpy, atol=1e-9)
231+
return t_numpy / t_dsl, t_numpy, t_dsl
232+
233+
234+
def bench_row_wise():
235+
# Slice from one frame rather than calling make_kepler_df(n) twice with
236+
# different n: a fresh same-seeded Generator's bulk draws are not
237+
# guaranteed to share a common prefix across different requested sizes.
238+
df_full = make_kepler_df(NROWS)
239+
df_small = df_full.iloc[:ROW_WISE_APPLY_NROWS]
240+
m = df_full["mean_anomaly"].to_numpy()
241+
ecc = df_full["eccentricity"].to_numpy()
242+
243+
t_apply, result_apply = timeit(lambda: df_small.apply(kepler_row_scalar, axis=1))
244+
t_numpy, result_numpy = timeit(lambda: kepler_numpy(m, ecc))
245+
t_dsl, result_dsl = timeit(lambda: np.asarray(kepler_dsl(**df_full)))
246+
247+
# Cross-check correctness: plain apply on the small frame vs numpy on the
248+
# same rows, and the direct DSL call vs numpy on the full frame.
249+
np.testing.assert_allclose(
250+
result_apply.to_numpy(),
251+
kepler_numpy(m[:ROW_WISE_APPLY_NROWS], ecc[:ROW_WISE_APPLY_NROWS]),
252+
atol=1e-9,
253+
)
254+
np.testing.assert_allclose(result_dsl, result_numpy, atol=1e-9)
255+
256+
print("\nrow-wise (axis=1), Kepler's equation via Newton-Raphson:")
257+
print(f" plain apply(axis=1), {ROW_WISE_APPLY_NROWS:>9,} rows: {t_apply:.4f} s")
258+
print(f" vectorized numpy, {NROWS:>9,} rows: {t_numpy:.4f} s")
259+
print(f" direct DSL call, {NROWS:>9,} rows: {t_dsl:.4f} s {t_numpy / t_dsl:.2f}x vs numpy")
260+
per_row_apply = t_apply / ROW_WISE_APPLY_NROWS
261+
per_row_dsl = t_dsl / NROWS
262+
print(
263+
f" per row: apply {per_row_apply * 1e6:.1f} us vs direct DSL call {per_row_dsl * 1e6:.4f} us "
264+
f"(~{per_row_apply / per_row_dsl:,.0f}x)"
265+
)
266+
267+
print("\nkepler rows sweep (speedup of the direct DSL call vs vectorized numpy):")
268+
row_speedups = []
269+
for nrows in KEPLER_ROW_SWEEP:
270+
sp, tn, td = kepler_speedup(make_kepler_df(nrows))
271+
row_speedups.append(sp)
272+
print(f" {nrows:>9,} rows: numpy {tn:.4f} s DSL {td:.4f} s {sp:.2f}x")
273+
274+
print("\nkepler eccentricity sweep (how unevenly rows converge):")
275+
ecc_speedups, ecc_iters = [], []
276+
for ecc_max in KEPLER_ECC_SWEEP:
277+
df = make_kepler_df(NROWS, ecc_max=ecc_max)
278+
iters = kepler_max_iters(df["mean_anomaly"].to_numpy(), df["eccentricity"].to_numpy())
279+
sp, tn, td = kepler_speedup(df)
280+
ecc_speedups.append(sp)
281+
ecc_iters.append(iters)
282+
print(
283+
f" e < {ecc_max:<5} slowest row: {iters:>2} iters "
284+
f"numpy {tn:.4f} s DSL {td:.4f} s {sp:.2f}x"
285+
)
286+
287+
out_path = OUT_DIR / "kepler.png"
288+
save_kepler_plot(row_speedups, ecc_speedups, ecc_iters, out_path)
289+
print(f"\nplot saved to {out_path}")
290+
291+
292+
def style_speedup_axes(ax, values):
293+
"""Shared look for the speedup panels: break-even line, x-suffixed ticks."""
294+
# Break-even: below this line the faster-looking option is a net loss.
295+
ax.axhline(1.0, color=MUTED, linestyle="--", linewidth=1)
296+
ax.set_ylim(0, max(values) * 1.25)
297+
ax.yaxis.set_major_formatter(lambda v, _pos: f"{v:g}x")
298+
ax.yaxis.grid(True, color=GRID, linewidth=0.8)
299+
ax.set_axisbelow(True)
300+
ax.spines["top"].set_visible(False)
301+
ax.spines["right"].set_visible(False)
302+
ax.spines["left"].set_color(GRID)
303+
ax.spines["bottom"].set_color(GRID)
304+
ax.tick_params(labelsize=9, colors=MUTED)
305+
306+
126307
def save_plot(row_speedups, ops_speedups, out_path):
127308
import matplotlib
128309

@@ -144,17 +325,7 @@ def save_plot(row_speedups, ops_speedups, out_path):
144325
ax_ops.set_title(f"{NROWS:,} rows x {NCOLS} columns", color=MUTED, fontsize=9)
145326

146327
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)
328+
style_speedup_axes(ax, values)
158329

159330
fig.suptitle(
160331
"df.apply(f, engine=blosc2.jit): when it pays off",
@@ -167,6 +338,61 @@ def save_plot(row_speedups, ops_speedups, out_path):
167338
plt.close(fig)
168339

169340

341+
def save_kepler_plot(row_speedups, ecc_speedups, ecc_iters, out_path):
342+
import matplotlib
343+
344+
matplotlib.use("Agg")
345+
import matplotlib.pyplot as plt
346+
347+
fig, (ax_rows, ax_ecc) = plt.subplots(1, 2, figsize=(8, 3.2))
348+
349+
ax_rows.semilogx(KEPLER_ROW_SWEEP, row_speedups, "o-", color=COLOR_TIP, linewidth=2)
350+
ax_rows.set_xlabel("rows (log scale)", color=INK, fontsize=9)
351+
ax_rows.set_ylabel("speedup vs vectorized NumPy", color=INK, fontsize=9)
352+
ax_rows.set_title("eccentricity < 0.95", color=MUTED, fontsize=9)
353+
354+
ax_ecc.plot(range(len(KEPLER_ECC_SWEEP)), ecc_speedups, "o-", color=COLOR_TIP, linewidth=2)
355+
ax_ecc.set_xticks(range(len(KEPLER_ECC_SWEEP)))
356+
ax_ecc.set_xticklabels([f"< {e}\n({n} iters)" for e, n in zip(KEPLER_ECC_SWEEP, ecc_iters, strict=True)])
357+
ax_ecc.set_xlabel("eccentricity (iterations the slowest row needs)", color=INK, fontsize=9)
358+
ax_ecc.set_title(f"{NROWS:,} rows", color=MUTED, fontsize=9)
359+
360+
for ax, values in ((ax_rows, row_speedups), (ax_ecc, ecc_speedups)):
361+
style_speedup_axes(ax, values)
362+
363+
fig.suptitle(
364+
"Kepler by Newton-Raphson: direct DSL call vs vectorized NumPy",
365+
fontsize=11,
366+
color=INK,
367+
)
368+
fig.tight_layout(rect=[0, 0, 1, 0.90])
369+
out_path.parent.mkdir(parents=True, exist_ok=True)
370+
fig.savefig(out_path, dpi=150)
371+
plt.close(fig)
372+
373+
374+
SCALAR_ROWS = 50_000
375+
376+
377+
def bench_branch_vs_where(df, t_plain, result_plain):
378+
"""Real per-element if vs the traced np.where form, plus per-element Python.
379+
380+
The scalar version is timed on a smaller frame and extrapolated: at the full
381+
size it takes over a second per run.
382+
"""
383+
t_branch, result_branch = timeit(lambda: df.apply(yeo_johnson_branch, engine=blosc2.jit))
384+
pd.testing.assert_frame_equal(result_branch, result_plain)
385+
386+
small = make_df(nrows=SCALAR_ROWS)
387+
t_small, _ = timeit(lambda: small.apply(lambda col: col.map(yeo_johnson_scalar)))
388+
t_scalar = t_small * (NROWS / SCALAR_ROWS)
389+
390+
print("\nreal if vs np.where (both under engine=blosc2.jit):")
391+
print(f" per-element Python, real if: {t_scalar:.4f} s (extrapolated) {t_plain / t_scalar:.2f}x")
392+
print(f" engine, real if (DSL kernel): {t_branch:.4f} s {t_plain / t_branch:.2f}x")
393+
print(" (np.where form is the t_engine figure above)")
394+
395+
170396
def main():
171397
df = make_df()
172398

@@ -182,6 +408,8 @@ def main():
182408
print(f"df.apply(f, engine=blosc2.jit): {t_engine:.4f} s {t_plain / t_engine:.2f}x")
183409
print(f"numexpr per column: {t_numexpr:.4f} s {t_plain / t_numexpr:.2f}x")
184410

411+
bench_branch_vs_where(df, t_plain, result_plain)
412+
185413
print("\nrows sweep (speedup vs plain apply):")
186414
row_speedups = []
187415
for nrows in ROW_SWEEP:
@@ -200,6 +428,8 @@ def main():
200428
save_plot(row_speedups, ops_speedups, out_path)
201429
print(f"\nplot saved to {out_path}")
202430

431+
bench_row_wise()
432+
203433

204434
if __name__ == "__main__":
205435
main()

0 commit comments

Comments
 (0)