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
3741from pathlib import Path
3842from time import perf_counter
3943
4953
5054ROW_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+
5268OUT_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.
83119YEO_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 ("\n row-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 ("\n kepler 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 ("\n kepler 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"\n plot 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+
126307def 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 ("\n real 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+
170396def 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 ("\n rows 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"\n plot saved to { out_path } " )
202430
431+ bench_row_wise ()
432+
203433
204434if __name__ == "__main__" :
205435 main ()
0 commit comments