From b7706a98d42ad54dfd6348ee554048e1fe415107 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 23 Jul 2026 14:26:58 +0200 Subject: [PATCH 01/14] Native NumPy operands in miniexpr; @blosc2.jit dispatches control flow to DSL miniexpr's prefilter can now gather blocks directly from raw NumPy buffers (no asarray conversion), scoped to DSL kernels so plain string-expression evaluation keeps its exact prior numeric behavior. @blosc2.jit auto-detects control flow (if/for/while) and dispatches DSL-valid functions to a whole-kernel miniexpr compile instead of tracing, which would otherwise silently record only the branch taken by the one tracing call. Adds a `strict=` flag, `out=` support on the DSL route, default-argument kernels, and more actionable DSL fallback error messages. Co-Authored-By: Claude Sonnet 5 --- bench/ndarray/jit-dsl-mandelbrot.py | 103 +++++++++++ doc/guides/optimization_tips.md | 21 +++ doc/reference/dsl_syntax.md | 9 + src/blosc2/blosc2_ext.pyx | 106 ++++++++++- src/blosc2/dsl_kernel.py | 15 +- src/blosc2/lazyexpr.py | 78 ++++++-- src/blosc2/proxy.py | 146 ++++++++++++++- tests/ndarray/test_dsl_kernels.py | 91 ++++++++++ tests/ndarray/test_jit_dsl_dispatch.py | 237 +++++++++++++++++++++++++ 9 files changed, 772 insertions(+), 34 deletions(-) create mode 100644 bench/ndarray/jit-dsl-mandelbrot.py create mode 100644 tests/ndarray/test_jit_dsl_dispatch.py diff --git a/bench/ndarray/jit-dsl-mandelbrot.py b/bench/ndarray/jit-dsl-mandelbrot.py new file mode 100644 index 000000000..6e9dfd5a4 --- /dev/null +++ b/bench/ndarray/jit-dsl-mandelbrot.py @@ -0,0 +1,103 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +# Compares a NumPy-vectorized Mandelbrot escape-time kernel against the same +# kernel run through @blosc2.jit's DSL (control-flow) dispatch route, directly +# on NumPy operands. Tracing would silently drop the per-pixel loop/break, so +# jit compiles the whole function with miniexpr instead; see +# doc/guides/optimization_tips.md ("Let @blosc2.jit compile control flow +# instead of tracing it"). +# +# Return paths are equalized (both calls end in a plain NumPy array): any +# non-None jit() kwarg flips the return from `retval[()]` to `.compute()`, +# which would otherwise skew the comparison. + +from __future__ import annotations + +import argparse +import statistics +import time + +import numpy as np + +import blosc2 + + +@blosc2.jit +def mandelbrot_jit(cr, ci, max_iter): + zr = 0.0 + zi = 0.0 + n = 0 + for _i in range(max_iter): + if zr * zr + zi * zi > 4.0: + break + new_zr = zr * zr - zi * zi + cr + zi = 2 * zr * zi + ci + zr = new_zr + n = n + 1 + return n + + +def mandelbrot_numpy(cr, ci, max_iter): + zr = np.zeros_like(cr) + zi = np.zeros_like(ci) + n = np.zeros(cr.shape, dtype=np.int64) + active = np.ones(cr.shape, dtype=bool) + for _i in range(max_iter): + mag = zr * zr + zi * zi + active &= mag <= 4.0 + new_zr = zr * zr - zi * zi + cr + new_zi = 2 * zr * zi + ci + zr = np.where(active, new_zr, zr) + zi = np.where(active, new_zi, zi) + n = np.where(active, n + 1, n) + return n + + +def _grid(height: int, width: int) -> tuple[np.ndarray, np.ndarray]: + cr = np.linspace(-2.0, 1.0, width, dtype=np.float64)[None, :] * np.ones((height, 1)) + ci = np.linspace(-1.5, 1.5, height, dtype=np.float64)[:, None] * np.ones((1, width)) + return cr, ci + + +def _bench(fn, reps: int, warmup: int) -> float: + for _ in range(warmup): + fn() + times = [] + for _ in range(reps): + t0 = time.perf_counter() + fn() + times.append(time.perf_counter() - t0) + return statistics.median(times) + + +def main(): + parser = argparse.ArgumentParser(description="NumPy vs @blosc2.jit (DSL route) Mandelbrot benchmark.") + parser.add_argument("--height", type=int, default=800) + parser.add_argument("--width", type=int, default=1200) + parser.add_argument("--max-iter", type=int, default=100) + parser.add_argument("--reps", type=int, default=5) + parser.add_argument("--warmup", type=int, default=1) + args = parser.parse_args() + + cr, ci = _grid(args.height, args.width) + print(f"grid: {args.height}x{args.width}, max_iter={args.max_iter}") + + ref = mandelbrot_numpy(cr, ci, args.max_iter) + got = mandelbrot_jit(cr, ci, args.max_iter) + assert np.array_equal(ref, got), "jit DSL result does not match the NumPy reference" + + numpy_med = _bench(lambda: mandelbrot_numpy(cr, ci, args.max_iter), args.reps, args.warmup) + jit_med = _bench(lambda: mandelbrot_jit(cr, ci, args.max_iter), args.reps, args.warmup) + + print(f"numpy vectorized: {numpy_med:.6f} s") + print(f"blosc2.jit (DSL route): {jit_med:.6f} s") + print(f"speedup: {numpy_med / jit_med:.2f}x") + + +if __name__ == "__main__": + main() diff --git a/doc/guides/optimization_tips.md b/doc/guides/optimization_tips.md index e01e2b41d..b567b9505 100644 --- a/doc/guides/optimization_tips.md +++ b/doc/guides/optimization_tips.md @@ -58,6 +58,27 @@ The output passes light uniformity checks against NumPy's PCG64 (the benchmark s *Benchmark for this tip: [`tip_11_dsl_random.py`](https://github.com/Blosc/python-blosc2/blob/main/bench/optim_tips/tip_11_dsl_random.py)* +## Let `@blosc2.jit` compile control flow instead of tracing it + +{func}`@blosc2.jit ` normally works by *tracing*: it calls your function once with proxy operands to record a `LazyExpr` string, so an `if`/`for`/`while` in the body only ever sees one (traced) path — the rest is silently lost. When the body contains control flow **and** it fits the [DSL grammar](../reference/dsl_syntax.md), `jit` detects this at decoration time and instead compiles the whole function with the same miniexpr engine that powers `@blosc2.dsl_kernel`, so every branch and loop runs as written, once per chunk, on NumPy or NDArray operands directly (no conversion copy). + +```python +# Without this detection, jit would call the function once, record only the +# branch that one call happened to take, and reuse it for every pixel — not +# a real per-pixel escape-time loop. +@blosc2.jit +def mandelbrot(cr, ci, max_iter): + zr, zi, n = 0.0, 0.0, 0 + for _ in range(max_iter): + if zr * zr + zi * zi > 4.0: + break + zr, zi = zr * zr - zi * zi + cr, 2 * zr * zi + ci + n += 1 + return n # jit detects the control flow and compiles this kernel whole +``` + +Functions **without** control flow always trace, even when they happen to be DSL-valid: tracing plus vectorized `ne_evaluate`/miniexpr is faster than whole-kernel miniexpr for pure elementwise expressions, so `jit` only takes the DSL route when tracing would silently lose branches/loops. Use `jit(strict=True)` to force the DSL route regardless (raises at decoration time if the function can't be compiled), or `jit(strict=False)` to force tracing even with control flow (only correct when branches depend on plain Python values, not on the arrays themselves). + ## Align your reads with the double partition blosc2 arrays are partitioned twice: the array is split into **chunks** (the unit of storage and compression), and each chunk is subdivided into **blocks** (the unit of decompression, sized to fit CPU caches). A read that lands exactly on a partition boundary decompresses only the chunk or block it needs, while the same-sized read shifted off-grid straddles (and decompresses) extra ones. diff --git a/doc/reference/dsl_syntax.md b/doc/reference/dsl_syntax.md index 3a5acafb7..e1946ba80 100644 --- a/doc/reference/dsl_syntax.md +++ b/doc/reference/dsl_syntax.md @@ -18,6 +18,15 @@ def kernel(x, y): Use Python-style indentation and always return a value on the paths you execute. +`@blosc2.jit` auto-detects this DSL: a decorated function whose body contains an +`if`/`for`/`while` and that compiles under this grammar is dispatched here +automatically, so its branches and loops actually run, once per chunk, instead +of `jit`'s normal approach of calling the function only once to record a single +expression — which would otherwise capture just whichever branch that one call +happened to take, silently dropping the rest. `@blosc2.dsl_kernel` remains the +explicit form — it always requires the DSL to compile, equivalent to +`jit(strict=True)`. + ## Program shape - Exactly one top-level `def ...:` function is expected. diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 1ddce5a82..ed325a565 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -801,6 +801,8 @@ ctypedef struct me_input_cache_s: ctypedef struct me_udata: b2nd_array_t** inputs + uint8_t** np_data # per-input raw base pointer; NULL entry = b2nd input + int32_t* np_typesizes # per-input itemsize; valid where np_data[i] != NULL me_input_cache_s* input_chunk_caches int ninputs me_eval_params* eval_params @@ -2409,6 +2411,10 @@ cdef class SChunk: free(me_data.input_chunk_caches) if me_data.inputs != NULL: free(me_data.inputs) + if me_data.np_data != NULL: + free(me_data.np_data) + if me_data.np_typesizes != NULL: + free(me_data.np_typesizes) if me_data.miniexpr_handle != NULL: # XXX do we really need the conditional? me_free(me_data.miniexpr_handle) if me_data.eval_params != NULL: @@ -2515,6 +2521,15 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, cdef int64_t start_ndim[B2ND_MAX_DIM] cdef int64_t stop_ndim[B2ND_MAX_DIM] cdef int64_t buffershape[B2ND_MAX_DIM] + # Raw-NumPy-input gather (odometer copy of a block out of a C-order buffer) + cdef int64_t counter[B2ND_MAX_DIM] + cdef int64_t shape_strides[B2ND_MAX_DIM] + cdef int64_t blockshape_strides[B2ND_MAX_DIM] + cdef int32_t np_ts + cdef int np_ndim + cdef c_bool all_pad + cdef int64_t ext, row_items, row_bytes, src_flat, dst_flat + cdef int dd cdef b2nd_array_t* ndarr cdef int rc @@ -2566,6 +2581,68 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, return 0 for i in range(udata.ninputs): + if udata.np_data != NULL and udata.np_data[i] != NULL: + # Raw NumPy input: gather block (nchunk, nblock) from a C-order buffer. + # All geometry comes from the output array, valid because the Python + # gates guarantee every operand shares the output's shape and grid. + np_ts = udata.np_typesizes[i] + blocknitems = udata.array.blocknitems + block_nbytes = blocknitems * np_ts + if expected_blocknitems == -1: + expected_blocknitems = blocknitems + elif blocknitems != expected_blocknitems: + raise ValueError("miniexpr: inconsistent block element counts across inputs") + input_buffers[i] = malloc(block_nbytes) + if input_buffers[i] == NULL: + raise MemoryError("miniexpr: cannot allocate input block buffer") + memset(input_buffers[i], 0, block_nbytes) # zero padding, matches b2nd semantics + + np_ndim = udata.array.ndim + blosc2_unidim_to_multidim(np_ndim, udata.chunks_in_array, nchunk, chunk_ndim) + blosc2_unidim_to_multidim(np_ndim, udata.blocks_in_chunk, nblock, block_ndim) + + all_pad = False + for dd in range(np_ndim): + start_ndim[dd] = chunk_ndim[dd] * udata.array.chunkshape[dd] + block_ndim[dd] * udata.array.blockshape[dd] + ext = udata.array.shape[dd] - start_ndim[dd] + stop_ndim[dd] = udata.array.blockshape[dd] if ext >= udata.array.blockshape[dd] else ext + if stop_ndim[dd] <= 0: + all_pad = True # fully-padded block: buffer stays zeroed + + if not all_pad: + # C-order element strides for the full array shape and for the block shape. + shape_strides[np_ndim - 1] = 1 + blockshape_strides[np_ndim - 1] = 1 + for dd in range(np_ndim - 2, -1, -1): + shape_strides[dd] = shape_strides[dd + 1] * udata.array.shape[dd + 1] + blockshape_strides[dd] = blockshape_strides[dd + 1] * udata.array.blockshape[dd + 1] + + row_items = stop_ndim[np_ndim - 1] + row_bytes = row_items * np_ts + for dd in range(np_ndim): + counter[dd] = 0 + # Odometer over the outer np_ndim-1 dims; the innermost dim is + # copied in bulk per row (1-D collapses to a single memcpy). + while True: + src_flat = 0 + dst_flat = 0 + for dd in range(np_ndim): + src_flat += (start_ndim[dd] + counter[dd]) * shape_strides[dd] + dst_flat += counter[dd] * blockshape_strides[dd] + memcpy( input_buffers[i] + dst_flat * np_ts, + udata.np_data[i] + src_flat * np_ts, + row_bytes) + dd = np_ndim - 2 + while dd >= 0: + counter[dd] += 1 + if counter[dd] < stop_ndim[dd]: + break + counter[dd] = 0 + dd -= 1 + else: + break + continue + ndarr = udata.inputs[i] if ndarr.sc.storage.urlpath == NULL: src = ndarr.sc.data[nchunk] @@ -2651,11 +2728,10 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, if rc < 0: raise ValueError("miniexpr: error decompressing the chunk") # For reduction operations, we need to track which block we're processing - # The linear_block_index should be based on the INPUT array structure, not the output array - # Get the first input array's chunk and block structure - cdef b2nd_array_t* first_input = udata.inputs[0] + # The linear_block_index should be based on the same grid the output shares + # with every input (raw NumPy inputs have no b2nd_array_t to read ndim from). cdef int nblocks_per_chunk = 1 - for i in range(first_input.ndim): + for i in range(udata.array.ndim): nblocks_per_chunk *= udata.blocks_in_chunk[i] # Calculate the global linear block index: nchunk * blocks_per_chunk + nblock # This works because blocks never span chunks (chunks are padded to block boundaries) @@ -3913,6 +3989,8 @@ cdef class NDArray: cdef me_udata *udata = calloc(1, sizeof(me_udata)) cdef me_eval_params* eval_params cdef b2nd_array_t** inputs_ + cdef uint8_t** np_data + cdef int32_t* np_typesizes cdef me_input_cache_s* input_chunk_caches cdef void* aux_reduc_ptr = NULL cdef int i @@ -3925,14 +4003,32 @@ cdef class NDArray: if udata == NULL: raise MemoryError("Cannot allocate miniexpr user data") inputs_ = NULL + np_data = NULL + np_typesizes = NULL if ninputs > 0: inputs_ = malloc(ninputs * sizeof(b2nd_array_t*)) if inputs_ == NULL: free(udata) raise MemoryError("Cannot allocate miniexpr input table") + np_data = calloc(ninputs, sizeof(uint8_t*)) + np_typesizes = calloc(ninputs, sizeof(int32_t)) + if np_data == NULL or np_typesizes == NULL: + free(inputs_) + free(np_data) + free(np_typesizes) + free(udata) + raise MemoryError("Cannot allocate miniexpr raw-input tables") for i, operand in enumerate(operands): - inputs_[i] = operand.c_array + if isinstance(operand, np.ndarray): + # Caller (fast_eval) guarantees C-contiguous, native-endian, non-scalar. + inputs_[i] = NULL + np_data[i] = np.PyArray_DATA( operand) + np_typesizes[i] = ( operand).itemsize + else: + inputs_[i] = operand.c_array udata.inputs = inputs_ + udata.np_data = np_data + udata.np_typesizes = np_typesizes udata.ninputs = ninputs input_chunk_caches = NULL if ninputs > 0: diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 85dcb0de0..495987cab 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -338,8 +338,6 @@ def _args(self, func_node: ast.FunctionDef): args = func_node.args if args.vararg or args.kwarg or args.kwonlyargs: self._err(args, "DSL kernel does not support *args/**kwargs/kwonly args") - if args.defaults or args.kw_defaults: - self._err(args, "DSL kernel does not support default arguments") def _check_input_assign(self, target: ast.Name): # G2: miniexpr forbids reassigning an input parameter (inputs alias operand buffers). @@ -531,9 +529,10 @@ def __init__(self, func): dsl_source = None input_names = None self.dsl_error = e - except Exception: + except Exception as e: dsl_source = None input_names = None + self.dsl_error = e self.dsl_source = dsl_source self.input_names = input_names @@ -584,8 +583,6 @@ def _input_names_from_signature(func_node: ast.FunctionDef) -> list[str]: args = func_node.args if args.vararg or args.kwarg or args.kwonlyargs: raise ValueError("DSL kernel does not support *args/**kwargs/kwonly args") - if args.defaults or args.kw_defaults: - raise ValueError("DSL kernel does not support default arguments") return [a.arg for a in (args.posonlyargs + args.args)] def __call__(self, inputs_tuple, output, offset=None): @@ -614,7 +611,13 @@ def __call__(self, inputs_tuple, output, offset=None): def dsl_kernel(func): - """Decorator to wrap a function in a DSLKernel.""" + """Decorator to wrap a function in a DSLKernel. + + Default argument values in *func*'s signature are accepted as ordinary named + inputs, but they are honored (filled in when omitted) only through the + ``@blosc2.jit`` call path. Calling a :class:`DSLKernel` directly (e.g. via + :func:`lazyudf`) requires every input to be passed positionally. + """ return DSLKernel(func) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index cd9cd29e4..afb8b2b07 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -1735,22 +1735,36 @@ def fast_eval( # noqa: C901 expr_string_miniexpr = _apply_jit_backend_pragma( expr_string_miniexpr, operands_miniexpr, jit_backend ) - all_ndarray_miniexpr = all( - isinstance(value, blosc2.NDArray) and value.shape != () for value in operands_miniexpr.values() - ) - # Require aligned NDArray operands with identical chunk/block grid. - same_shape = all(hasattr(op, "shape") and op.shape == shape for op in operands_miniexpr.values()) - same_chunks = all(hasattr(op, "chunks") and op.chunks == chunks for op in operands_miniexpr.values()) - same_blocks = all(hasattr(op, "blocks") and op.blocks == blocks for op in operands_miniexpr.values()) - if not (same_shape and same_chunks and same_blocks): + + def _miniexpr_eligible_operand(op): + if isinstance(op, blosc2.NDArray): + return op.shape != () and op.shape == shape and op.chunks == chunks and op.blocks == blocks + if isinstance(op, np.ndarray): + # Raw NumPy operands are only miniexpr-eligible for DSL kernels (the + # jit control-flow dispatch route). Plain string expressions and traced + # `jit` calls keep the old NDArray-only gate, so their numeric behavior + # (e.g. transcendentals matching numexpr bit-for-bit) is unchanged. + return ( + is_dsl + and op.ndim > 0 + and op.shape == shape + and op.dtype.isnative + and op.dtype.kind in "biufc" + ) + return False + + all_eligible_miniexpr = all(_miniexpr_eligible_operand(op) for op in operands_miniexpr.values()) + if not all_eligible_miniexpr: use_miniexpr = False if is_dsl and dsl_disable_reason is None: - dsl_disable_reason = "all DSL operands must share shape/chunks/blocks." - if not (all_ndarray_miniexpr and out is None): + dsl_disable_reason = ( + "all DSL operands must be NDArray or NumPy inputs sharing shape/chunks/blocks." + ) + if not (all_eligible_miniexpr and out is None): use_miniexpr = False if is_dsl and dsl_disable_reason is None: dsl_disable_reason = ( - "DSL kernels require NDArray inputs and do not support the `out` argument." + "DSL kernels require NDArray or NumPy inputs and do not support the `out` argument." ) has_complex = any( isinstance(op, blosc2.NDArray) and blosc2.isdtype(op.dtype, "complex floating") @@ -1779,7 +1793,11 @@ def fast_eval( # noqa: C901 print(f"[blosc2] engine={engine} {jit_info} expr={expr_short}", flush=True) if use_miniexpr: - cparams = kwargs.pop("cparams", blosc2.CParams()) + cparams = kwargs.pop("cparams", None) + if cparams is None: + # getitem output is throwaway scratch (returned as a NumPy array and + # discarded), so compressing it buys nothing but a round trip. + cparams = blosc2.CParams(clevel=0) if getitem else blosc2.CParams() # All values will be overwritten, so we can use an uninitialized array res_eval = blosc2.uninit(shape, dtype, chunks=chunks, blocks=blocks, cparams=cparams, **kwargs) prefilter_set = False @@ -1787,6 +1805,11 @@ def fast_eval( # noqa: C901 # Fuse where(cond, x, y) into the expression for miniexpr _pref_expr = expr_string_miniexpr _pref_ops = operands_miniexpr + if any(isinstance(v, np.ndarray) for v in _pref_ops.values()): + _pref_ops = { + k: (np.ascontiguousarray(v) if isinstance(v, np.ndarray) else v) + for k, v in _pref_ops.items() + } if where is not None and len(where) == 2: _pref_expr = f"where({_pref_expr}, _where_x, _where_y)" # _cb_anchor keeps the contiguous bitmap alive for the whole @@ -1883,7 +1906,11 @@ def fast_eval( # noqa: C901 if callable(expression): if _is_dsl_kernel_expression(expression): _raise_dsl_miniexpr_required( - "internal fallback attempted to execute the DSL kernel directly in Python." + "DSL kernels require the miniexpr fast path, and it was unavailable for this " + "evaluation. Common causes: operands with mismatched chunks/blocks or " + "non-contiguous/non-native-byte-order NumPy arrays, an explicit `out=` argument, " + "or a `where(cond, x, y)` with cardinality-changing semantics (len(where) == 1) — " + "all unsupported for DSL kernels." ) if _in_place: expression(tuple(chunk_operands.values()), out, offset=offset) @@ -2219,7 +2246,9 @@ def slices_eval( # noqa: C901 if callable(expression): if _is_dsl_kernel_expression(expression): _raise_dsl_miniexpr_required( - "internal sliced fallback attempted to execute the DSL kernel directly in Python." + "DSL kernels only support evaluating the full array (compute() or [()]) through " + "the miniexpr fast path; slicing a DSL computation (e.g. `lexpr[1:5]`) is not " + "supported." ) if _in_place: # presumably the user knows what they're doing # edit out in-place @@ -2373,7 +2402,8 @@ def slices_eval_getitem( if callable(expression): if _is_dsl_kernel_expression(expression): _raise_dsl_miniexpr_required( - "internal getitem fallback attempted to execute the DSL kernel directly in Python." + "DSL kernels only support evaluating the full array (compute() or [()]) through " + "the miniexpr fast path; sliced getitem (e.g. `lexpr[1:5]`) is not supported." ) offset = tuple(0 if s is None else s.start for s in _slice_bcast) # offset for the udf if _in_place: @@ -2775,7 +2805,8 @@ def reduce_slices( # noqa: C901 if callable(expression): if _is_dsl_kernel_expression(expression): _raise_dsl_miniexpr_required( - "internal reduction fallback attempted to execute the DSL kernel directly in Python." + "DSL kernels do not support reductions (e.g. sum()/mean() over an axis); write " + "the reduction outside the kernel, over its computed result." ) # TODO: Implement the reductions for UDFs (and test them) result = np.empty(cslice_shape, dtype=out.dtype) @@ -3018,7 +3049,7 @@ def _eval_zero_input_dsl_if_needed( return True, full_res -def chunked_eval( +def chunked_eval( # noqa: C901 expression: str | Callable[[tuple, np.ndarray, tuple[int]], None], operands: dict, item=(), **kwargs ): """ @@ -3120,6 +3151,19 @@ def chunked_eval( return slices_eval(expression, operands, getitem=getitem, _slice=item, shape=shape, **kwargs) fast_path = full_slice and fast_path + if not fast_path and full_slice and _is_dsl_kernel_expression(expression) and operands: + # All-NumPy DSL operands: validate_inputs only sees NDinputs, so it never + # sets fast_path for this case; reroute it here instead. Scalar operands + # (e.g. a Python int/float parameter) don't participate in the shape/grid + # check, mirroring validate_inputs' own raw_inputs filtering. + raw_ops = [v for v in operands.values() if not _isscalar(v)] + if ( + raw_ops + and all(isinstance(v, np.ndarray) and v.ndim > 0 for v in raw_ops) + and len({v.shape for v in raw_ops}) == 1 + ): + fast_path = True + if fast_path: # necessarily item is () if getitem: # When using getitem, taking the fast path is always possible diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 7f346d36d..8af506ff7 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +import ast import asyncio from abc import ABC, abstractmethod from collections.abc import Sequence @@ -18,6 +19,7 @@ import numpy as np import blosc2 +from blosc2.dsl_kernel import DSLKernel # Default Proxy.afetch concurrency cap for remote sources (e.g. C2Array), # where fetches are dominated by round-trip latency, not local CPU/IO. @@ -751,13 +753,98 @@ def as_simpleproxy(*arrs: Sequence[blosc2.Array]) -> tuple[SimpleProxy | blosc2. return out[0] if len(out) == 1 else out -def jit(func=None, *, out=None, disable=False, **kwargs): +def _has_control_flow(source: str | None) -> bool: + """Whether *source* (a DSL-extracted function source, or None) contains a + branch or loop that tracing cannot observe.""" + if source is None: + return False + tree = ast.parse(source) + return any(isinstance(node, ast.If | ast.For | ast.While) for node in ast.walk(tree)) + + +def _jit_dsl_wrapper(kernel: DSLKernel, out, decorator_kwargs: dict): + """Build the call wrapper for the DSL (control-flow) dispatch route of `jit`. + + Unlike the tracing `wrapper` (which calls `func` once to record a single + expression, losing any branch not taken on that one call), this calls + `kernel` once per invocation through `blosc2.lazyudf`, so every branch and + loop in the kernel body is compiled and actually runs, once per chunk. + """ + + def dsl_wrapper(*args, **func_kwargs): + sig = kernel._sig + if sig is None: + raise TypeError(f"@blosc2.jit: cannot introspect the signature of {kernel.__name__!r}") + bound = sig.bind(*args, **func_kwargs) + bound.apply_defaults() + values = tuple(bound.arguments[name] for name in kernel.input_names) + + array_shapes = { + v.shape + for v in values + if isinstance(v, np.ndarray | blosc2.NDArray) and getattr(v, "ndim", 0) > 0 + } + if not array_shapes: + shape = decorator_kwargs.get("shape") + if shape is None: + raise TypeError( + "@blosc2.jit DSL kernels with only scalar inputs require `shape=` " + "(passed to the jit decorator) to determine the result shape." + ) + elif len(array_shapes) > 1: + raise TypeError( + "blosc2.jit DSL kernels do not support broadcasting; all array arguments " + f"must share one shape, got {sorted(array_shapes)}" + ) + else: + (shape,) = array_shapes + + # Build the LazyUDF bare (no storage kwargs): those are applied once, at + # the return step below, exactly like the tracing `wrapper` does. Passing + # them here too would apply e.g. `urlpath=` twice and raise. + lexpr = blosc2.lazyudf(kernel, values, dtype=None, shape=shape) + + if out is not None: + if isinstance(out, blosc2.NDArray): + raise NotImplementedError( + "blosc2.jit does not support an NDArray `out` on the DSL (control-flow) " + "dispatch route; use lexpr.compute(urlpath=..., mode='w') to persist a " + "result chunk-by-chunk instead." + ) + if not isinstance(out, np.ndarray): + raise TypeError(f"blosc2.jit `out` must be a NumPy array or NDArray, got {type(out)!r}") + if out.shape != shape: + raise TypeError(f"`out` shape {out.shape} does not match operand shape {shape}") + res = lexpr.compute(cparams=blosc2.CParams(clevel=0)) + if out.dtype != res.dtype: + raise TypeError( + f"`out` dtype {out.dtype} does not match the inferred result dtype {res.dtype}" + ) + if out.flags.c_contiguous: + res.get_slice_numpy(out, (tuple(0 for _ in res.shape), tuple(res.shape))) + else: + np.copyto(out, res[()], casting="no") + return out + + if decorator_kwargs and any(v is not None for v in decorator_kwargs.values()): + return lexpr.compute(**decorator_kwargs) + return lexpr[()] + + return dsl_wrapper + + +def jit(func=None, *, out=None, disable=False, strict=None, **kwargs): # noqa: C901 """ Prepare a function so that it can be used with the Blosc2 compute engine. The inputs of the function can be any combination of NumPy/NDArray arrays - and scalars. The function will be called with the NumPy arrays replaced by - :ref:`SimpleProxy` objects, whereas NDArray objects will be used as is. + and scalars. By default, the function is *traced*: it is called once with + the NumPy arrays replaced by :ref:`SimpleProxy` objects (NDArray objects are + used as is) to record a single expression, which is then what actually gets + evaluated. Because tracing only calls the function once, an ``if``/``for``/ + ``while`` in the body only ever takes the one path that single call + happened to follow — see `strict` below for when `jit` instead compiles the + function whole, so every branch and loop genuinely runs. The returned value will be a NDArray if appropriate kwargs are provided (e.g. `cparams=`). Else, the return value will be a NumPy array @@ -769,10 +856,30 @@ def jit(func=None, *, out=None, disable=False, **kwargs): func: callable The function to be prepared for the Blosc2 compute engine. out: np.ndarray, NDArray, optional - The output array where the result will be stored. + The output array where the result will be stored. On the DSL + (control-flow) dispatch route, a NumPy `out` is filled in place + (directly when C-contiguous, else via a copy); an NDArray `out` is not + supported there — use ``compute(urlpath=..., mode="w")`` instead. disable: bool, optional If True, the decorator is disabled and the original function is returned unchanged. Default is False. + strict: bool, optional + Control which evaluation route is used: + + - ``None`` (default): if *func*'s body contains an ``if``/``for``/``while`` + and it compiles as a DSL kernel, dispatch to the DSL route (miniexpr + runs the whole function, so branches/loops behave as written); a + control-flow function that fails DSL extraction still falls back to + tracing, but a subsequent tracing failure is annotated with the DSL + extraction error. Functions without control flow always trace, even + if they happen to be DSL-valid (tracing is faster for pure elementwise + expressions). + - ``True``: always use the DSL route, raising at decoration time if + *func* cannot be compiled as a DSL kernel. Equivalent to + ``blosc2.dsl_kernel``. + - ``False``: always use the tracing route, even if *func* has control + flow (this only works when branches/loops depend on plain Python + values, not on traced arrays). **kwargs: dict, optional Additional keyword arguments supported by the :func:`empty` constructor. @@ -788,6 +895,8 @@ def jit(func=None, *, out=None, disable=False, **kwargs): (e.g. when using a reduction as the last function). In this case, you can still use the `out` parameter of the reduction function for some custom control over the output. + * DSL-route kernels do not support broadcasting: every array argument must + share the same shape. Examples -------- @@ -803,10 +912,30 @@ def jit(func=None, *, out=None, disable=False, **kwargs): [5 5 5 5] """ - def decorator(func): + def decorator(func): # noqa: C901 if disable: return func + kernel = DSLKernel(func) + has_cf = _has_control_flow(kernel.dsl_source) + dsl_ok = kernel.dsl_source is not None and kernel.dsl_error is None + if strict is True and not dsl_ok: + raise kernel.dsl_error or TypeError( + f"@blosc2.jit(strict=True): could not extract a DSL kernel from {func.__name__!r}" + ) + use_dsl = strict is True or (strict is None and has_cf and dsl_ok) + + if use_dsl: + return _jit_dsl_wrapper(kernel, out, kwargs) + + _trace_hint = None + if strict is None and has_cf and not dsl_ok: + _trace_hint = ( + f"Note: {func.__name__!r} contains control flow (if/for/while) but could not be " + f"compiled as a DSL kernel: {kernel.dsl_error or 'source unavailable'}. See " + "doc/reference/dsl_syntax.md for the DSL syntax reference." + ) + def wrapper(*args, **func_kwargs): # Get some kwargs in decorator for SimpleProxy constructor proxy_kwargs = {"chunks": kwargs.get("chunks"), "blocks": kwargs.get("blocks")} @@ -825,7 +954,12 @@ def wrapper(*args, **func_kwargs): func_kwargs[key] = SimpleProxy(value, **proxy_kwargs) # Call function with the new arguments - retval = func(*new_args, **func_kwargs) + try: + retval = func(*new_args, **func_kwargs) + except Exception as e: + if _trace_hint is not None: + raise type(e)(f"{e}\n{_trace_hint}") from e + raise # Treat return value # If it is a numpy array, return it as is diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 0439015cf..790641639 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -1157,3 +1157,94 @@ def other(a, b): st = blosc2.validate_dsl_jit(other, [np.float64, np.float64], np.float64) assert st["compiled"] assert not st["jit"] + + +def _dsl_reference(kernel, operands, dtype=None): + """Evaluate *kernel* over NDArray copies of *operands* (same engine, same buffers).""" + nd_operands = tuple(blosc2.asarray(op) if isinstance(op, np.ndarray) else op for op in operands) + return blosc2.lazyudf(kernel, nd_operands, dtype=dtype)[()] + + +@blosc2.dsl_kernel +def _numpy_operand_kernel(x, y): + return x * 2.0 + y + + +@pytest.mark.parametrize( + "shape", + [ + (10_007,), # 1-D: partial last chunk and block + (101, 67), # 2-D: odd shape + (13, 17, 19), # 3-D: odd shape + ], +) +def test_dsl_kernel_numpy_operands_match_ndarray_reference(shape): + rng = np.random.default_rng(0) + a = rng.random(shape).astype(np.float64) + b = rng.random(shape).astype(np.float64) + res = blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None)[()] + np.testing.assert_array_equal(res, _dsl_reference(_numpy_operand_kernel, (a, b))) + + +def test_dsl_kernel_numpy_operands_mixed_dtype_promotes_output(): + rng = np.random.default_rng(1) + a = (rng.random(10_007) * 10).astype(np.float32) + b = (rng.random(10_007) * 10).astype(np.int64) + res = blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None)[()] + ref = _dsl_reference(_numpy_operand_kernel, (a, b)) + assert res.dtype == ref.dtype + np.testing.assert_array_equal(res, ref) + + +def test_dsl_kernel_mixed_ndarray_and_numpy_operand(): + shape = (20, 10) + a = np.arange(np.prod(shape), dtype=np.float64).reshape(shape) + b = blosc2.asarray(np.arange(np.prod(shape), dtype=np.float64).reshape(shape) * 2) + res = blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None)[()] + ref = blosc2.lazyudf(_numpy_operand_kernel, (blosc2.asarray(a), b), dtype=None)[()] + np.testing.assert_array_equal(res, ref) + + +def test_dsl_kernel_numpy_operands_f_ordered_and_strided(): + shape = (20, 10) + b = np.arange(np.prod(shape), dtype=np.float64).reshape(shape) + ref = _dsl_reference(_numpy_operand_kernel, (b, b)) + + a_f = np.asfortranarray(b) + res_f = blosc2.lazyudf(_numpy_operand_kernel, (a_f, b), dtype=None)[()] + np.testing.assert_array_equal(res_f, ref) + + a_strided = np.arange(2 * np.prod(shape), dtype=np.float64).reshape(40, 10)[::2] + ref_strided = _dsl_reference(_numpy_operand_kernel, (np.ascontiguousarray(a_strided), b)) + res_strided = blosc2.lazyudf(_numpy_operand_kernel, (a_strided, b), dtype=None)[()] + np.testing.assert_array_equal(res_strided, ref_strided) + + +def test_dsl_kernel_numpy_operand_non_native_endian_requires_miniexpr(): + a = np.arange(100, dtype=">f8").reshape(10, 10) + b = np.arange(100, dtype=np.float64).reshape(10, 10) + with pytest.raises(RuntimeError, match="NDArray or NumPy inputs"): + blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None)[()] + + +def test_dsl_kernel_zero_input_dummy_operand_injection_still_works(): + @blosc2.dsl_kernel + def ramp(start, step): + return start + step * _i0 # noqa: F821 # DSL index symbol resolved by miniexpr + + res = blosc2.lazyudf(ramp, (1.0, 2.0), dtype=np.float64, shape=(100,))[()] + expected = 1.0 + 2.0 * np.arange(100, dtype=np.float64) + np.testing.assert_allclose(res, expected) + + +def test_dsl_kernel_numpy_out_matches_compute_and_honors_explicit_cparams(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + lexpr = blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None) + + res_getitem = lexpr[()] + res_compute = lexpr.compute()[:] + np.testing.assert_array_equal(res_getitem, res_compute) + + res_explicit = lexpr.compute(cparams=blosc2.CParams(clevel=5)) + assert res_explicit.schunk.cparams.clevel == 5 diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py new file mode 100644 index 000000000..143411eaa --- /dev/null +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -0,0 +1,237 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import numpy as np +import pytest + +import blosc2 + + +def _mandel_numpy(cr, ci, max_iter): + zr = np.zeros_like(cr) + zi = np.zeros_like(ci) + n = np.zeros(cr.shape, dtype=np.int64) + active = np.ones(cr.shape, dtype=bool) + for _ in range(max_iter): + mag = zr * zr + zi * zi + active = active & ~(active & (mag > 4.0)) + new_zr = zr * zr - zi * zi + cr + new_zi = 2 * zr * zi + ci + zr = np.where(active, new_zr, zr) + zi = np.where(active, new_zi, zi) + n = np.where(active, n + 1, n) + return n + + +def _mandel_grid(): + h, w = 12, 16 + cr = np.linspace(-2, 1, w).astype(np.float64)[None, :] * np.ones((h, 1)) + ci = np.linspace(-1, 1, h).astype(np.float64)[:, None] * np.ones((1, w)) + return cr, ci + + +def test_jit_control_flow_dispatches_to_dsl_and_matches_numpy(monkeypatch, capsys): + @blosc2.jit + def mandel(cr, ci, max_iter): + zr = 0.0 + zi = 0.0 + n = 0 + for _i in range(max_iter): + if zr * zr + zi * zi > 4.0: + break + new_zr = zr * zr - zi * zi + cr + zi = 2 * zr * zi + ci + zr = new_zr + n = n + 1 + return n + + cr, ci = _mandel_grid() + monkeypatch.setenv("BLOSC_ME_JIT_TRACE", "1") + res = mandel(cr, ci, 30) + captured = capsys.readouterr() + assert "engine=miniexpr" in captured.out + assert "def mandel" in captured.out + np.testing.assert_array_equal(res, _mandel_numpy(cr, ci, 30)) + + +def test_jit_control_flow_with_default_argument(): + @blosc2.jit + def mandel(cr, ci, max_iter=30): + zr = 0.0 + zi = 0.0 + n = 0 + for _i in range(max_iter): + if zr * zr + zi * zi > 4.0: + break + new_zr = zr * zr - zi * zi + cr + zi = 2 * zr * zi + ci + zr = new_zr + n = n + 1 + return n + + cr, ci = _mandel_grid() + res = mandel(cr, ci) + np.testing.assert_array_equal(res, _mandel_numpy(cr, ci, 30)) + + +def test_jit_elementwise_function_still_traces(monkeypatch): + calls = [] + real_lazyudf = blosc2.lazyudf + + def spy_lazyudf(*args, **kwargs): + calls.append((args, kwargs)) + return real_lazyudf(*args, **kwargs) + + monkeypatch.setattr(blosc2, "lazyudf", spy_lazyudf) + + @blosc2.jit + def elemwise(a, b): + return a * 2.0 + b + + a = np.arange(100, dtype=np.float64) + b = np.arange(100, dtype=np.float64) * 0.5 + res = elemwise(a, b) + np.testing.assert_allclose(res, a * 2.0 + b) + assert calls == [] # no control flow -> never routed through the DSL/lazyudf path + + +def test_jit_strict_true_on_elementwise_dsl_valid_function_uses_dsl(monkeypatch): + calls = [] + import blosc2.proxy as proxy_mod + + real_wrapper = proxy_mod._jit_dsl_wrapper + + def spy(*args, **kwargs): + calls.append(True) + return real_wrapper(*args, **kwargs) + + monkeypatch.setattr(proxy_mod, "_jit_dsl_wrapper", spy) + + @blosc2.jit(strict=True) + def elemwise(a, b): + return a * 2.0 + b + + a = np.arange(100, dtype=np.float64) + b = np.arange(100, dtype=np.float64) * 0.5 + res = elemwise(a, b) + np.testing.assert_allclose(res, a * 2.0 + b) + assert calls # dispatched through the DSL wrapper + + +def test_jit_strict_true_on_non_dsl_function_raises_at_decoration_time(): + with pytest.raises(Exception, match="axis"): + + @blosc2.jit(strict=True) + def bad(a): + return np.sum(a, axis=1) + + +def test_jit_strict_false_on_control_flow_traces(): + @blosc2.jit(strict=False) + def cf_func(a, b): + if True: + return a + b + return a - b + + a = np.arange(100, dtype=np.float64) + b = np.arange(100, dtype=np.float64) * 0.5 + res = cf_func(a, b) + np.testing.assert_allclose(res, a + b) + + +def test_jit_control_flow_on_python_scalar_flag_still_traces(): + @blosc2.jit + def scalar_flag(a, b, flag): + if flag: + return a + b + return a - b + + a = np.arange(100, dtype=np.float64) + b = np.arange(100, dtype=np.float64) * 0.5 + np.testing.assert_allclose(scalar_flag(a, b, True), a + b) + np.testing.assert_allclose(scalar_flag(a, b, False), a - b) + + +def test_jit_dsl_route_rejects_broadcasting(): + @blosc2.jit + def kernel(a, b, n): + acc = 0.0 + for _i in range(n): + acc = acc + a + b + return acc + + with pytest.raises(TypeError, match="broadcasting"): + kernel(np.zeros((10,)), np.zeros((20,)), 2) + + +def _kernel_src(a, b, n): + acc = 0.0 + for _i in range(n): + acc = acc + a + b + return acc + + +def test_jit_dsl_route_out_numpy_c_contiguous_filled_in_place(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + out = np.empty(1000, dtype=np.float64) + jit_f = blosc2.jit(out=out)(_kernel_src) + res = jit_f(a, b, 3) + assert res is out + np.testing.assert_allclose(out, (a + b) * 3) + + +def test_jit_dsl_route_out_numpy_non_contiguous_uses_copyto_fallback(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + out = np.empty(2000, dtype=np.float64)[::2] + assert not out.flags.c_contiguous + jit_f = blosc2.jit(out=out)(_kernel_src) + res = jit_f(a, b, 3) + assert res is out + np.testing.assert_allclose(out, (a + b) * 3) + + +def test_jit_dsl_route_out_mismatched_shape_or_dtype_raises_typeerror(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + + with pytest.raises(TypeError, match="shape"): + blosc2.jit(out=np.empty(500, dtype=np.float64))(_kernel_src)(a, b, 3) + + with pytest.raises(TypeError, match="dtype"): + blosc2.jit(out=np.empty(1000, dtype=np.float32))(_kernel_src)(a, b, 3) + + +def test_jit_dsl_route_ndarray_out_raises_not_implemented_mentioning_urlpath(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + nd_out = blosc2.zeros((1000,), dtype=np.float64) + with pytest.raises(NotImplementedError, match="urlpath"): + blosc2.jit(out=nd_out)(_kernel_src)(a, b, 3) + + +def test_jit_dsl_route_compute_urlpath_persists_result(tmp_path): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + urlpath = str(tmp_path / "persisted.b2nd") + jit_f = blosc2.jit(urlpath=urlpath, mode="w")(_kernel_src) + res = jit_f(a, b, 3) + assert isinstance(res, blosc2.NDArray) + reopened = blosc2.open(urlpath) + np.testing.assert_allclose(reopened[:], (a + b) * 3) + + +def test_jit_dsl_route_ndarray_operands_match_numpy_operands(): + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + na = blosc2.asarray(a) + nb = blosc2.asarray(b) + jit_f = blosc2.jit()(_kernel_src) + res_numpy = jit_f(a, b, 3) + res_ndarray = jit_f(na, nb, 3) + np.testing.assert_array_equal(res_numpy, res_ndarray) From 1b9995cbda474a476a1c1a792f869eb50d6e5a2a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 23 Jul 2026 15:04:27 +0200 Subject: [PATCH 02/14] Rewrite np.foo(...) DSL calls to bare names miniexpr recognizes The DSL grammar only accepts bare-name function calls (sin(x)), never attribute calls (np.sin(x)) -- but that's how virtually all real NumPy/pandas code is written, so it was the main practical wall keeping @blosc2.jit's control-flow dispatch from reaching everyday UDFs. DSLKernel now rewrites `alias.foo(...)` to `foo(...)` for any name bound to the real numpy module, plus a small verified alias table (power/maximum/minimum/absolute) for the few names miniexpr spells differently. Update the pandas engine guide to describe the new behavior and its two remaining failure modes. Co-Authored-By: Claude Sonnet 5 --- doc/guides/pandas_engine.md | 34 +++++++++++--- src/blosc2/dsl_kernel.py | 73 +++++++++++++++++++++++++++++++ tests/ndarray/test_dsl_kernels.py | 66 ++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index d80dd5fd5..a8129206f 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -135,11 +135,35 @@ explicitly if it matters: z = (col - col.mean()) / col.std(ddof=0) ``` -**No Python `if` on values.** The function is traced rather than executed -statement by statement, so branching on array contents raises -`ValueError: The truth value of an array ... is ambiguous`. Use `np.where`, -nesting it where you would have used `elif`. (This is the same restriction -numexpr has.) Branching on a scalar *parameter* is fine. +**Real per-element `if`/`for`/`while` now works, for a numeric subset of +NumPy.** `engine=blosc2.jit` auto-detects control flow: if your function +branches or loops over column values *and* the function fits Blosc2's +[DSL grammar](../reference/dsl_syntax.md), it is compiled and run as written — +branches and loops behave like real Python — instead of being traced. Both +`np.sin(x)`-style calls and bare `sin(x)`-style calls are accepted (the former +is rewritten to the latter automatically; a handful of NumPy functions the DSL +knows under a different name, like `np.maximum`/`np.minimum`, are translated +too — `power`, `maximum`, `minimum` and `absolute` today). Two things to know +before relying on this: + +- If the function doesn't fit the DSL grammar at all (e.g. it uses statements + the grammar doesn't support), it silently falls back to the original + behavior: the function is traced, and branching on array contents raises + `ValueError: The truth value of an array ... is ambiguous`. Use `np.where` + as before, nesting it where you would have used `elif`. Branching on a + scalar *parameter* has always been fine either way. +- If the function looks DSL-shaped but calls something outside the DSL's + supported functions (not every NumPy function has a DSL equivalent), + compiling it fails at call time with a `RuntimeError` naming the problem — + a different error than the `ValueError` above, and one that is not silently + swallowed. Check the [DSL syntax reference](../reference/dsl_syntax.md) for + what is actually supported before depending on this path for a given + function. + +This dispatch decision is not configurable through `engine=`: pandas' engine +protocol requires the plain `blosc2.jit` object, so `engine=` always gets the +auto-detect (`strict=None`) behavior described above — there is currently no +way to pass `strict=True`/`strict=False` through this entry point. **`np.where` evaluates both arms.** Unlike a real `if`, both branches are computed over the whole column and only then selected between, so each one runs diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 495987cab..df2e7bf22 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -16,6 +16,8 @@ from io import StringIO from typing import ClassVar +import numpy + _PRINT_DSL_KERNEL = os.environ.get("PRINT_DSL_KERNEL", "").strip().lower() _PRINT_DSL_KERNEL = _PRINT_DSL_KERNEL not in ("", "0", "false", "no", "off") _DSL_USAGE_DOC_URL = "https://github.com/Blosc/python-blosc2/blob/main/doc/reference/dsl_syntax.md" @@ -25,6 +27,44 @@ class DSLSyntaxError(ValueError): """Raised when a @dsl_kernel function uses unsupported DSL syntax.""" +# NumPy function names that the DSL grammar recognizes only under a different +# (but semantically identical, same-arity) name. Verified individually against +# the NumPy function they alias -- not a general "closest match" mapping, so +# names with subtle semantic differences (e.g. `np.mod`/`np.remainder`'s sign +# convention vs C's `fmod`) are deliberately left out. +_NUMPY_TO_DSL_FUNC_ALIASES = { + "power": "pow", + "maximum": "fmax", + "minimum": "fmin", + "absolute": "abs", +} + + +class _NumpyAttrCallRewriter(ast.NodeTransformer): + """Rewrite `alias.foo(...)` calls to the bare `foo(...)` form the DSL grammar + requires, for every *alias* bound to the real NumPy module. Also applies + `_NUMPY_TO_DSL_FUNC_ALIASES` for the handful of functions the DSL knows + under a different name. + """ + + def __init__(self, aliases: set[str]): + self._aliases = aliases + self.rewrote_any = False + + def visit_Call(self, node: ast.Call) -> ast.AST: + self.generic_visit(node) + func = node.func + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id in self._aliases + ): + dsl_name = _NUMPY_TO_DSL_FUNC_ALIASES.get(func.attr, func.attr) + node.func = ast.copy_location(ast.Name(id=dsl_name, ctx=ast.Load()), func) + self.rewrote_any = True + return node + + def _normalize_miniexpr_scalar(value): # NumPy scalar-like values expose .item(); plain Python scalars do not. # Do not call .item() on non-scalar arrays; for Blosc2 arrays this can be expensive. @@ -559,6 +599,11 @@ def _extract_dsl(self, func, validate: bool = True): if dsl_func is None: raise ValueError("No function definition found in sliced DSL source") input_names = self._input_names_from_signature(dsl_func) + + dsl_source, dsl_tree, dsl_func = self._rewrite_numpy_attr_calls( + func, dsl_source, dsl_tree, dsl_func, input_names + ) + if validate: DSLValidator(dsl_source, input_names=input_names).validate(dsl_func) if _PRINT_DSL_KERNEL: @@ -567,6 +612,34 @@ def _extract_dsl(self, func, validate: bool = True): print(dsl_source) return dsl_source, input_names + @staticmethod + def _rewrite_numpy_attr_calls(func, dsl_source, dsl_tree, dsl_func, input_names): + """Rewrite `np.foo(...)` calls to bare `foo(...)`, for every name in *func*'s + defining scope that is bound to the real NumPy module (typically `np`, but + any alias, including a bare `numpy` import, is honored). The DSL grammar + only accepts bare function-name calls. No-op, returning the inputs + unchanged, when there is nothing to rewrite (including when the alias is + shadowed by one of the kernel's own parameter names). + """ + aliases = { + name + for name, value in getattr(func, "__globals__", {}).items() + if value is numpy and name not in input_names + } + if not aliases: + return dsl_source, dsl_tree, dsl_func + + rewriter = _NumpyAttrCallRewriter(aliases) + rewritten = rewriter.visit(ast.parse(dsl_source)) + if not rewriter.rewrote_any: + return dsl_source, dsl_tree, dsl_func + + ast.fix_missing_locations(rewritten) + new_source = ast.unparse(rewritten) + new_tree = ast.parse(new_source) + new_func = next((node for node in new_tree.body if isinstance(node, ast.FunctionDef)), None) + return new_source, new_tree, new_func + @staticmethod def _slice_function_source(source: str, func_node: ast.FunctionDef) -> str: lines = source.splitlines() diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 790641639..687110b9e 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -1248,3 +1248,69 @@ def test_dsl_kernel_numpy_out_matches_compute_and_honors_explicit_cparams(): res_explicit = lexpr.compute(cparams=blosc2.CParams(clevel=5)) assert res_explicit.schunk.cparams.clevel == 5 + + +def test_dsl_kernel_numpy_attribute_calls_are_rewritten_to_bare_names(): + @blosc2.dsl_kernel + def k(x, y): + if x >= 0: + return np.sin(x) + y + else: + return -np.sin(-x) + y + + assert k.dsl_error is None + assert "np.sin" not in k.dsl_source + assert "sin(" in k.dsl_source + + x = np.linspace(-2, 2, 1000) + y = np.ones_like(x) + res = blosc2.lazyudf(k, (x, y), dtype=None)[()] + expected = np.where(x >= 0, np.sin(x) + y, -np.sin(-x) + y) + np.testing.assert_allclose(res, expected) + + +@pytest.mark.parametrize( + ("numpy_call", "expected_dsl_name"), + [ + ("np.power(x, 2.0)", "pow"), + ("np.maximum(x, 0.5)", "fmax"), + ("np.minimum(x, -0.5)", "fmin"), + ("np.absolute(x)", "abs"), + ], +) +def test_dsl_kernel_numpy_func_aliases_map_to_dsl_names(numpy_call, expected_dsl_name): + src = f"def k(x):\n if x >= 0:\n return {numpy_call}\n else:\n return -x\n" + k = kernel_from_source(src) + + assert k.dsl_error is None + assert f"{expected_dsl_name}(" in k.dsl_source + + x = np.linspace(-2, 2, 1000) + res = blosc2.lazyudf(k, (x,), dtype=None)[()] + expected = np.where(x >= 0, eval(numpy_call, {"np": np, "x": x}), -x) + np.testing.assert_allclose(res, expected) + + +def test_dsl_kernel_numpy_alias_not_rewritten_when_shadowed_by_parameter(): + # A parameter literally named "np" shadows the module -- the rewrite must + # not mistake a per-call NDArray/scalar input for the NumPy module. + src = "def k(np, y):\n return np * y\n" + k = kernel_from_source(src) + + assert k.dsl_error is None + a = np.linspace(1, 2, 100) + b = np.linspace(2, 3, 100) + res = blosc2.lazyudf(k, (a, b), dtype=None)[()] + np.testing.assert_allclose(res, a * b) + + +def test_dsl_kernel_numpy_call_without_alias_left_untouched(): + # No import of numpy bound in the kernel's defining scope -- nothing to + # rewrite, and the plain bare-name form still works unaffected. + @blosc2.dsl_kernel + def k(x): + return sin(x) # noqa: F821 # 'sin' resolved as a bare DSL function name + + a = np.linspace(-2, 2, 1000) + res = blosc2.lazyudf(k, (a,), dtype=None)[()] + np.testing.assert_allclose(res, np.sin(a)) From 054de8ed0b637d7f049531519389d9a4d9a6acda Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 23 Jul 2026 18:08:46 +0200 Subject: [PATCH 03/14] Decouple jit()'s NDArray-vs-NumPy return type from execution-tuning kwargs jit/jit_backend/fp_accuracy tune how an expression is evaluated, not what container the result comes back in -- but they shared the same **kwargs bucket as storage kwargs (cparams, chunks, urlpath, ...), so specifying e.g. jit_backend="cc" silently flipped the return type from a plain NumPy array to an NDArray. That heuristic was sound when every kwarg here was a storage parameter (its original 2025 design); it stopped holding once jit/jit_backend were folded into the same bucket without revisiting it. Split the two kinds of kwargs: only storage kwargs now trigger the NDArray/ compute() path, on both the tracing and DSL dispatch routes, while execution-tuning kwargs are honored on either return path. Co-Authored-By: Claude Sonnet 5 --- src/blosc2/proxy.py | 50 ++++++++++++++++++-------- tests/ndarray/test_jit.py | 28 +++++++++++++++ tests/ndarray/test_jit_dsl_dispatch.py | 21 +++++++++++ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 8af506ff7..d2cf5aa3f 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -25,6 +25,13 @@ # where fetches are dominated by round-trip latency, not local CPU/IO. REMOTE_MAX_CONCURRENCY = 8 +# `jit` kwargs that tune *how* an expression is evaluated, not what container the +# result is stored in. Unlike storage kwargs (`cparams`, `chunks`, `urlpath`, ...), +# these must not by themselves flip the return type from a plain NumPy array to +# an NDArray -- wanting a faster JIT backend has nothing to do with wanting a +# compressed/persisted container back. +_JIT_EXECUTION_TUNING_KWARGS = frozenset({"jit", "jit_backend", "fp_accuracy"}) + class ProxyNDSource(ABC): """ @@ -799,10 +806,16 @@ def dsl_wrapper(*args, **func_kwargs): else: (shape,) = array_shapes - # Build the LazyUDF bare (no storage kwargs): those are applied once, at - # the return step below, exactly like the tracing `wrapper` does. Passing - # them here too would apply e.g. `urlpath=` twice and raise. - lexpr = blosc2.lazyudf(kernel, values, dtype=None, shape=shape) + # Execution-tuning kwargs (jit/jit_backend/fp_accuracy) are baked into the + # LazyUDF at construction, so they take effect on *both* the getitem + # (NumPy) and compute (NDArray) return paths below. Storage kwargs + # (cparams, chunks, urlpath, ...) are applied once, only at the return + # step -- passing them here too would e.g. apply `urlpath=` twice and raise. + exec_kwargs = { + k: v for k, v in decorator_kwargs.items() if k in _JIT_EXECUTION_TUNING_KWARGS and v is not None + } + storage_kwargs = {k: v for k, v in decorator_kwargs.items() if k not in _JIT_EXECUTION_TUNING_KWARGS} + lexpr = blosc2.lazyudf(kernel, values, dtype=None, shape=shape, **exec_kwargs) if out is not None: if isinstance(out, blosc2.NDArray): @@ -826,7 +839,7 @@ def dsl_wrapper(*args, **func_kwargs): np.copyto(out, res[()], casting="no") return out - if decorator_kwargs and any(v is not None for v in decorator_kwargs.values()): + if storage_kwargs and any(v is not None for v in storage_kwargs.values()): return lexpr.compute(**decorator_kwargs) return lexpr[()] @@ -846,10 +859,13 @@ def jit(func=None, *, out=None, disable=False, strict=None, **kwargs): # noqa: happened to follow — see `strict` below for when `jit` instead compiles the function whole, so every branch and loop genuinely runs. - The returned value will be a NDArray if appropriate kwargs are provided - (e.g. `cparams=`). Else, the return value will be a NumPy array - (if the function returns a NumPy array). If `out` is provided, - the result will be computed and stored in the `out` array + The returned value will be a NDArray if a *storage* kwarg is provided (e.g. + `cparams=`, `chunks=`, `urlpath=` — anything that only makes sense for a + compressed/persisted container). Else, the return value will be a NumPy + array (if the function returns a NumPy array). Execution-tuning kwargs + (`jit=`, `jit_backend=`, `fp_accuracy=`) do not by themselves trigger this — + they take effect either way, without changing the return type. If `out` is + provided, the result will be computed and stored in the `out` array. Parameters ---------- @@ -936,6 +952,11 @@ def decorator(func): # noqa: C901 "doc/reference/dsl_syntax.md for the DSL syntax reference." ) + exec_kwargs = { + k: v for k, v in kwargs.items() if k in _JIT_EXECUTION_TUNING_KWARGS and v is not None + } + storage_kwargs = {k: v for k, v in kwargs.items() if k not in _JIT_EXECUTION_TUNING_KWARGS} + def wrapper(*args, **func_kwargs): # Get some kwargs in decorator for SimpleProxy constructor proxy_kwargs = {"chunks": kwargs.get("chunks"), "blocks": kwargs.get("blocks")} @@ -964,8 +985,8 @@ def wrapper(*args, **func_kwargs): # Treat return value # If it is a numpy array, return it as is if isinstance(retval, np.ndarray): - if kwargs and any(kwargs[key] is not None for key in kwargs): - # But if kwargs are provided, return a NDArray instead + if storage_kwargs and any(v is not None for v in storage_kwargs.values()): + # But if storage kwargs are provided, return a NDArray instead return blosc2.asarray(retval, **kwargs) return retval @@ -977,10 +998,11 @@ def wrapper(*args, **func_kwargs): # If the return value is a LazyExpr, compute it if out is not None: return retval.compute(out=out, **kwargs) - if kwargs and any(kwargs[key] is not None for key in kwargs): + if storage_kwargs and any(v is not None for v in storage_kwargs.values()): return retval.compute(**kwargs) - # If no kwargs are provided, return a numpy array - return retval[()] + # No storage kwargs: return a NumPy array (like retval[()]), but still + # honor any execution-tuning kwargs (jit/jit_backend/fp_accuracy). + return retval.compute(_getitem=True, **exec_kwargs) return wrapper diff --git a/tests/ndarray/test_jit.py b/tests/ndarray/test_jit.py index 0dbcdf820..ba09af30b 100644 --- a/tests/ndarray/test_jit.py +++ b/tests/ndarray/test_jit.py @@ -177,3 +177,31 @@ def reduc_std_jit_cparams(a, b, c): assert d_jit.schunk.cparams.clevel == 1 assert d_jit.schunk.cparams.codec == blosc2.Codec.LZ4 assert d_jit.schunk.cparams.filters == [blosc2.Filter.BITSHUFFLE] + [blosc2.Filter.NOFILTER] * 5 + + +def test_jit_execution_tuning_kwarg_alone_keeps_numpy_return(): + # jit/jit_backend/fp_accuracy tune *how* an expression runs, not what + # container the result comes back in -- they must not by themselves flip + # the return type from NumPy to NDArray (unlike storage kwargs). + @blosc2.jit(jit=False) + def f(a, b): + return a * 2.0 + b + + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + res = f(a, b) + assert isinstance(res, np.ndarray) + np.testing.assert_allclose(res, a * 2.0 + b) + + +def test_jit_execution_tuning_kwarg_with_storage_kwarg_still_returns_ndarray(): + @blosc2.jit(jit=False, cparams=blosc2.CParams(clevel=2)) + def f(a, b): + return a * 2.0 + b + + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + res = f(a, b) + assert isinstance(res, blosc2.NDArray) + assert res.schunk.cparams.clevel == 2 + np.testing.assert_allclose(res[:], a * 2.0 + b) diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py index 143411eaa..bb4661885 100644 --- a/tests/ndarray/test_jit_dsl_dispatch.py +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -235,3 +235,24 @@ def test_jit_dsl_route_ndarray_operands_match_numpy_operands(): res_numpy = jit_f(a, b, 3) res_ndarray = jit_f(na, nb, 3) np.testing.assert_array_equal(res_numpy, res_ndarray) + + +def test_jit_dsl_route_execution_tuning_kwarg_alone_keeps_numpy_return(): + # Same rule as the tracing route: jit/jit_backend/fp_accuracy tune execution, + # not the return container, so they must not force an NDArray on their own. + jit_f = blosc2.jit(jit=False)(_kernel_src) + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + res = jit_f(a, b, 3) + assert isinstance(res, np.ndarray) + np.testing.assert_allclose(res, (a + b) * 3) + + +def test_jit_dsl_route_execution_tuning_kwarg_with_storage_kwarg_still_returns_ndarray(): + jit_f = blosc2.jit(jit=False, cparams=blosc2.CParams(clevel=2))(_kernel_src) + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + res = jit_f(a, b, 3) + assert isinstance(res, blosc2.NDArray) + assert res.schunk.cparams.clevel == 2 + np.testing.assert_allclose(res[:], (a + b) * 3) From 0c73c21f30f52618a71ca434a8f293b799aca35c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 24 Jul 2026 20:08:57 +0200 Subject: [PATCH 04/14] Accept Series as DSL operands; fix engine=blosc2.jit axis=1 crash @blosc2.jit's DSL (control-flow) dispatch route rejected pandas Series and other array-protocol operands with a misleading "requires shape=" error, even though the tracing route already accepted them; both routes now treat anything exposing __array__ the same way (zero-copy for NumPy-backed data). df.apply(func, engine=blosc2.jit, axis=1) crashed with IndexError on the textbook row["colname"] idiom pandas 3 uses to motivate engine=, since each call received a positional ndarray row. It now dispatches such functions to one call over whole per-column arrays (_PandasRowProxy), extracted from the original DataFrame so per-column dtypes survive, and raises a clear error -- instead of hanging -- when the idiom is combined with a for/while loop, since tracing would otherwise unroll the loop and blow up the traced expression size with every iteration. doc/guides/pandas_engine.md is rewritten to cover both the column-wise engine= path and the much faster row-wise pattern of calling a @blosc2.jit function directly with DataFrame columns as arguments, which bench/bench_pandas_engine.py now measures (Kepler's equation via Newton-Raphson: 6x over vectorized NumPy, ~160x per row over plain apply(axis=1)). Also documents two rough edges found while writing that example: DSL kernel bodies can't contain a docstring, and a reduction (max/min/sum) used inside per-element control flow silently produces wrong results past element 0 -- a miniexpr limitation, not something this layer can validate today. Co-Authored-By: Claude Sonnet 5 --- bench/bench_pandas_engine.py | 106 +++++++++++++- doc/guides/pandas_engine.md | 183 ++++++++++++++++++++++--- doc/guides/pandas_engine/speedup.png | Bin 51870 -> 51491 bytes doc/reference/dsl_syntax.md | 18 +++ src/blosc2/proxy.py | 144 ++++++++++++++++++- tests/ndarray/test_jit_dsl_dispatch.py | 9 ++ tests/test_pandas_udf_engine.py | 78 +++++++++++ 7 files changed, 510 insertions(+), 28 deletions(-) diff --git a/bench/bench_pandas_engine.py b/bench/bench_pandas_engine.py index 898be4dce..eb335ae93 100644 --- a/bench/bench_pandas_engine.py +++ b/bench/bench_pandas_engine.py @@ -25,15 +25,19 @@ # quoted expression string, not that it wins a raw speed race. See # doc/guides/pandas_engine.md. # -# Note: axis=1 (row-wise) is NOT a good fit for this engine. It still calls -# the function once per row in a Python loop either way, and for a handful -# of columns the wrapping overhead per call (building a compute-engine proxy -# for a tiny array) is larger than the win, so engine=blosc2.jit is actually -# *slower* than plain apply(axis=1) in that case. Use axis=0 (or restructure -# the computation to operate on whole columns) to get the engine's benefit. +# Row-wise (axis=1) computations that combine several columns per row are a +# different story: engine=blosc2.jit + axis=1 still calls the function once +# per row in a Python loop, so it is not the right tool there either. Instead, +# write the function to take the columns as separate array parameters and +# call it directly (no df.apply at all) -- see "Row-wise computations" in +# doc/guides/pandas_engine.md. bench_row_wise() below measures that pattern +# against a plain per-row apply() and vectorized NumPy on a genuine +# per-row-convergence problem (Kepler's equation via Newton-Raphson), where a +# real per-row `break` beats even vectorized NumPy. # # Each measurement is the minimum of NRUNS repetitions to reduce noise. +import math from pathlib import Path from time import perf_counter @@ -49,6 +53,10 @@ ROW_SWEEP = (1_000, 10_000, 100_000, 1_000_000, 5_000_000) +# Plain per-row apply(axis=1) is ~1000x slower than the alternatives below; +# keep this sweep small so the benchmark finishes in a reasonable time. +ROW_WISE_APPLY_NROWS = 2_000 + OUT_DIR = Path(__file__).resolve().parent.parent / "doc" / "guides" / "pandas_engine" # dataviz reference palette, same values as bench/optim_tips/common.py @@ -123,6 +131,90 @@ def speedup(df, func): return t_plain / t_engine, t_plain, t_engine +# Kepler's equation, solved by Newton-Raphson: a genuine per-row-convergence +# problem (row["colname"] combines two columns, and rows converge in a +# different number of iterations), used to benchmark the row-wise +# "columns as direct-call parameters" pattern from doc/guides/pandas_engine.md +# against both a plain per-row apply(axis=1) and vectorized NumPy. +def kepler_row_scalar(row): + m = row["mean_anomaly"] + ecc = row["eccentricity"] + e = m + ecc * math.sin(m) + for _ in range(100): + diff = (e - ecc * math.sin(e) - m) / (1.0 - ecc * math.cos(e)) + e = e - diff + if abs(diff) < 1e-12: + break + return e + + +def kepler_numpy(m, ecc): + e = m + ecc * np.sin(m) + for _ in range(100): + diff = (e - ecc * np.sin(e) - m) / (1.0 - ecc * np.cos(e)) + e = e - diff + if np.max(np.abs(diff)) < 1e-12: + break + return e + + +@blosc2.jit +def kepler_dsl(mean_anomaly, eccentricity): + e = mean_anomaly + eccentricity * sin(mean_anomaly) # noqa: F821 # 'sin' resolved as a bare DSL function name + for _ in range(100): + diff = (e - eccentricity * sin(e) - mean_anomaly) / (1.0 - eccentricity * cos(e)) # noqa: F821 + e = e - diff + if abs(diff) < 1e-12: + break + return e + + +def make_kepler_df(nrows): + rng = np.random.default_rng(1) + return pd.DataFrame( + { + "mean_anomaly": rng.uniform(0, 2 * np.pi, nrows), + "eccentricity": rng.uniform(0.0, 0.95, nrows), + } + ) + + +def bench_row_wise(): + # Slice from one frame rather than calling make_kepler_df(n) twice with + # different n: a fresh same-seeded Generator's bulk draws are not + # guaranteed to share a common prefix across different requested sizes. + df_full = make_kepler_df(NROWS) + df_small = df_full.iloc[:ROW_WISE_APPLY_NROWS] + m = df_full["mean_anomaly"].to_numpy() + ecc = df_full["eccentricity"].to_numpy() + + t_apply, result_apply = timeit(lambda: df_small.apply(kepler_row_scalar, axis=1)) + t_numpy, result_numpy = timeit(lambda: kepler_numpy(m, ecc)) + t_dsl, result_dsl = timeit( + lambda: np.asarray(kepler_dsl(df_full["mean_anomaly"], df_full["eccentricity"])) + ) + + # Cross-check correctness: plain apply on the small frame vs numpy on the + # same rows, and the direct DSL call vs numpy on the full frame. + np.testing.assert_allclose( + result_apply.to_numpy(), + kepler_numpy(m[:ROW_WISE_APPLY_NROWS], ecc[:ROW_WISE_APPLY_NROWS]), + atol=1e-9, + ) + np.testing.assert_allclose(result_dsl, result_numpy, atol=1e-9) + + print("\nrow-wise (axis=1), Kepler's equation via Newton-Raphson:") + print(f" plain apply(axis=1), {ROW_WISE_APPLY_NROWS:>9,} rows: {t_apply:.4f} s") + print(f" vectorized numpy, {NROWS:>9,} rows: {t_numpy:.4f} s") + print(f" direct DSL call, {NROWS:>9,} rows: {t_dsl:.4f} s {t_numpy / t_dsl:.2f}x vs numpy") + per_row_apply = t_apply / ROW_WISE_APPLY_NROWS + per_row_dsl = t_dsl / NROWS + print( + f" per row: apply {per_row_apply * 1e6:.1f} us vs direct DSL call {per_row_dsl * 1e6:.4f} us " + f"(~{per_row_apply / per_row_dsl:,.0f}x)" + ) + + def save_plot(row_speedups, ops_speedups, out_path): import matplotlib @@ -200,6 +292,8 @@ def main(): save_plot(row_speedups, ops_speedups, out_path) print(f"\nplot saved to {out_path}") + bench_row_wise() + if __name__ == "__main__": main() diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index a8129206f..3a363430d 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -1,4 +1,4 @@ -# Using Blosc2 as a pandas Engine +# Using Blosc2 with pandas pandas' `DataFrame.apply` and `Series.map` accept an `engine=` argument, and `blosc2.jit` is one such engine. Instead of running your function once per @@ -7,7 +7,14 @@ Blosc2 evaluates the entire function body in a single multi-threaded pass over the data. The result is typically **2-3x faster than a plain `apply`**, with the -function itself left exactly as you wrote it. +function itself left exactly as you wrote it. That is the right tool for +transforms applied to each column independently (`axis=0`, the default). + +For computations that combine several *columns* per row — the case pandas 3 +highlights `engine=` for — a plain `@blosc2.jit` function called directly with +the columns as arguments is both simpler and faster, with wins well past +2-3x. [Jump to that section](#row-wise-computations-pass-the-columns-skip-apply) +if that is what you are here for. ## An example @@ -39,8 +46,8 @@ result = df.apply(yeo_johnson, engine=blosc2.jit) `result` is a DataFrame of the same shape and column names as `df`, with the transform applied to every column — the same thing plain `df.apply(yeo_johnson)` -returns, only computed differently. On the machine below it takes 0.056 s -instead of 0.119 s, a **2.1x** speedup. +returns, only computed differently. On the machine below it takes 0.057 s +instead of 0.126 s, a **2.2x** speedup. ## Why it is faster @@ -62,12 +69,12 @@ Two things have to be true, and the plot below measures each one on its own. **Enough rows.** Setting up the compute engine costs a fixed amount per call. At a few hundred thousand rows that setup is still larger than anything it -saves, and the engine is a net loss — at 100,000 rows it runs at 0.70x. Break-even +saves, and the engine is a net loss — at 100,000 rows it runs at 0.64x. Break-even falls between 100,000 and 1,000,000 rows. **Enough arithmetic.** The more operations there are to fuse into one pass, the more temporaries are avoided and the bigger the win — from 2.1x for a single -operation up to 3.8x for five. +operation up to 3.6x for five. Beyond a few million rows the speedup flattens and then eases off (1.9x at 5M above): the arrays no longer fit in cache and the whole computation becomes @@ -99,9 +106,9 @@ compare on the example above: | approach | time | vs plain apply | | --- | --- | --- | -| plain `df.apply(f)` | 0.1194 s | 1.00x | -| `df.apply(f, engine=blosc2.jit)` | 0.0561 s | 2.13x | -| `numexpr.evaluate(...)` per column | 0.0380 s | 3.14x | +| plain `df.apply(f)` | 0.1258 s | 1.00x | +| `df.apply(f, engine=blosc2.jit)` | 0.0569 s | 2.21x | +| `numexpr.evaluate(...)` per column | 0.0393 s | 3.20x | On an in-memory DataFrame, numexpr is somewhat faster. The reason to prefer `engine=blosc2.jit` is not raw speed but that **you write a Python function @@ -121,7 +128,9 @@ and is checked by your editor and linters. That is the trade being offered. Note also that Blosc2's characteristic strength — computing directly over compressed, potentially larger-than-memory arrays — does not come into play on this path, because pandas materialises each column as a plain NumPy array -before the engine ever sees it. +before the engine ever sees it. It does come into play in the row-wise +pattern below: a `@blosc2.jit` function called directly accepts a +`blosc2.NDArray` operand exactly as readily as a DataFrame column. ## Gotchas @@ -178,16 +187,151 @@ The same caveat applies to numexpr's `where()`. **`np.sign` is not supported** on the traced expressions and raises a `TypeError`. Express it with `np.where` instead. -## Row-wise (`axis=1`) +**A reduction used inside per-element control flow does not mean what it +looks like it means, in a DSL kernel.** `sum`, `max`, `min` and friends are +*block* reductions: they collapse the whole chunk being evaluated down to one +value, not one value per row. Writing the array-style idiom + +```python +if max(abs(diff)) < 1e-12: + break +``` + +inside a `@blosc2.jit` DSL kernel (see the DSL syntax reference and the +row-wise section below) does **not** raise — it compiles and runs, and +silently produces wrong results for every row past the first: only element 0 +of the block receives the reduction's value, so the condition evaluates +against effectively-zero data everywhere else. This is a known rough edge in +the underlying [miniexpr](https://github.com/Blosc/miniexpr) compiler, not +something `blosc2.jit` can validate away today. Write the per-element form +instead — drop the reduction and compare the array directly: + +```python +if abs(diff) < 1e-12: + break +``` + +## Row-wise computations: pass the columns, skip `apply` -`axis=0`, the default, calls the function once per column and is where the -benefit lies. `axis=1` calls it once per row — the same Python-level loop plain -pandas would run, except each call now also pays the cost of wrapping a tiny -array for the compute engine. For a handful of columns that overhead outweighs -any gain, making `engine=blosc2.jit` with `axis=1` typically *slower* than a -plain `apply(axis=1)`. +The previous sections cover `axis=0` — one function call per *column*, which +is where `engine=blosc2.jit` earns its 2-3x. `axis=1` — one call per *row* — +is what pandas 3 actually highlights `engine=` for, via examples like: + +```python +def add_people(row): + return row["max_people"] + row["max_children"] + + +visits = pd.DataFrame({"max_people": [4, 2, 8], "max_children": [1, 0, 3]}) +visits.apply(add_people, engine=blosc2.jit, axis=1) +``` + +`engine=blosc2.jit` handles this specific shape reasonably well: a function +that only ever combines columns by name (`row["colname"]`, nothing fancier) +is detected and dispatched to one call over whole per-column arrays instead +of pandas' historical per-row Python loop, so it traces to a single fused +expression the same way `axis=0` does. + +But for anything with real per-row iteration — a genuine per-row-convergence +computation, not just combining a few columns — `apply` is the wrong tool +regardless of `engine=`, and `engine=blosc2.jit` raises a clear `TypeError` +rather than attempting it (tracing would otherwise unroll the loop eagerly at +call time, and the traced expression size explodes with each iteration; see +[Gotchas](#gotchas) above for a related, subtler version of the same +`for`/`while`-with-a-reduction interaction). Skip `df.apply(...)` entirely and +call a `@blosc2.jit` function directly, passing the columns as separate array +arguments: + +```python +import numpy as np +import pandas as pd + +import blosc2 + +rng = np.random.default_rng(0) +orbits = pd.DataFrame( + { + "mean_anomaly": rng.uniform(0, 2 * np.pi, 1_000_000), + "eccentricity": rng.uniform(0.0, 0.95, 1_000_000), + } +) + + +# Eccentric anomaly via Newton-Raphson on Kepler's equation. Note: DSL kernel +# bodies do not support a docstring (or any other string-literal statement). +@blosc2.jit +def kepler(mean_anomaly, eccentricity): + e = mean_anomaly + eccentricity * sin(mean_anomaly) + for _ in range(100): + diff = (e - eccentricity * sin(e) - mean_anomaly) / ( + 1.0 - eccentricity * cos(e) + ) + e = e - diff + if abs(diff) < 1e-12: + break + return e + + +result = kepler(orbits["mean_anomaly"], orbits["eccentricity"]) +``` -Use `axis=0`, or restructure the computation so it works column-wise. +That's it — no `apply`, no `engine=` keyword. `orbits["colname"]` (a pandas +Series) is accepted directly as a kernel operand, exactly like a NumPy array. + +On 1,000,000 rows this runs **6.2x faster than fully vectorized NumPy**: + +| approach | time (1M rows) | +| --- | --- | +| vectorized NumPy (whole-array loop) | 0.164 s | +| `kepler(orbits["mean_anomaly"], orbits["eccentricity"])` | 0.026 s | + +A plain Python `df.apply(..., axis=1)` is far enough outside this range that +timing it at 1M rows isn't practical: measured on 2,000 rows it runs at +about 4.4 microseconds/row, versus 0.026 microseconds/row for the direct +call — **around 167x faster per row**, before even accounting for the +per-row work Python duplicates on every call (`sin`/`cos` reimported, +Newton step re-interpreted, ...) that the compiled kernel pays for once. + +### Why it beats even vectorized NumPy + +Vectorized NumPy still has to loop until the *worst* row converges — every +row keeps recomputing `sin`/`cos`/the Newton step for however many iterations +the slowest-converging row needs, because the loop is at the whole-array +level. The DSL kernel's `for`/`if`/`break` compile to a real, independent +per-element loop: each row exits as soon as *it* converges, and the compiled +loop runs as one fused, multi-threaded pass with no NumPy-sized temporaries +in between. See the [DSL syntax reference](../reference/dsl_syntax.md) for +what a kernel body may contain. + +### What this costs + +- `df["colname"]` and `df["colname"].to_numpy()` are a **zero-copy view** for + ordinary numeric dtypes (confirmed via `np.shares_memory`, including + mixed-dtype frames — extracting one column never triggers the whole-frame + upcast that `df.to_numpy()` or `df.values` does, which is what + `engine=blosc2.jit` uses internally for `axis=0`). Each column keeps its + own dtype. +- Nullable (`Float64`/`Int64`), Arrow-backed, and tz-aware columns are not + zero-copy — extracting them allocates a converted array once, which is + noise next to iterative work like the kernel above. + +### This isn't really a pandas feature + +The kernel above takes arrays; a DataFrame column happens to *be* one (via +`__array__`). The same call works unmodified with a polars Series, an xarray +`DataArray`, an h5py dataset slice, or a `blosc2.NDArray` — where compressed +or larger-than-memory operands become relevant, unlike anywhere else on this +page. Treat `engine=blosc2.jit` as the pandas-specific on-ramp for column-wise +work, and a plain `@blosc2.jit` call as the general tool for everything else. + +## When to use which + +| situation | use | +| --- | --- | +| same function applied to every column independently | `df.apply(f, engine=blosc2.jit)` (`axis=0`) | +| a function combining a few named columns, no per-row loop | `df.apply(f, engine=blosc2.jit, axis=1)` — works, but consider the direct call below anyway | +| per-row convergence / iteration (Newton-Raphson class) | call the `@blosc2.jit` function directly with the columns, no `apply` | +| trivial arithmetic, or fewer than ~100,000 rows | plain pandas — neither engine wins here | ## Limitations @@ -216,4 +360,5 @@ an Apple M4 with pandas 3.0.3 and 8 threads. Run it yourself with: python bench/bench_pandas_engine.py ``` -It prints the comparison table, both sweeps, and regenerates the plot above. +It prints the `engine=` comparison table, both sweeps, the row-wise Kepler +table above, and regenerates the plot. diff --git a/doc/guides/pandas_engine/speedup.png b/doc/guides/pandas_engine/speedup.png index b26d37a3e8eda63a649fef80fe73d455559e944f..74faa5fee6ccbef3c4a476c55b5588dfc83fcad1 100644 GIT binary patch literal 51491 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뗌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 diff --git a/doc/reference/dsl_syntax.md b/doc/reference/dsl_syntax.md index e1946ba80..82097546d 100644 --- a/doc/reference/dsl_syntax.md +++ b/doc/reference/dsl_syntax.md @@ -178,6 +178,21 @@ Rules: - `while` condition is a regular DSL expression. - Runtime iteration cap is enforced by `ME_DSL_WHILE_MAX_ITERS`. +### Reductions inside control flow + +`sum`, `max`, `min` and other block reductions collapse the whole chunk being +evaluated to one value, not one value per element. Using one as the +condition of `if`/`while`, or assigning one to a local that per-element code +later reads (e.g. `y = max(x)` followed by `if x > 0: y = y + 1`), does +**not** raise a compile-time or runtime error -- it compiles and runs, and +produces results that are only correct for element 0 of the block; every +other element sees a stale/zero value where the reduction result should be. +This is a rough edge in the underlying +[miniexpr](https://github.com/Blosc/miniexpr) compiler, not something this +Python layer validates today. Write the per-element form instead (drop the +reduction, e.g. `if abs(diff) < tol` rather than `if max(abs(diff)) < tol`) +whenever the intent is a per-element, not whole-block, decision. + ## `print(...)` `print` is supported as a DSL statement. @@ -280,3 +295,6 @@ These Python features are not part of this DSL: - Ternary expression: `a if cond else b` - `for ... else` and `while ... else` - Keyword-argument calls and other call forms outside the supported subset +- Docstrings (or any other bare string-literal statement) inside the kernel + body -- this is a compile-time parse error at the miniexpr level, not a + silently-ignored statement. diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index d2cf5aa3f..35b2387d5 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -7,6 +7,8 @@ import ast import asyncio +import inspect +import textwrap from abc import ABC, abstractmethod from collections.abc import Sequence @@ -760,6 +762,93 @@ def as_simpleproxy(*arrs: Sequence[blosc2.Array]) -> tuple[SimpleProxy | blosc2. return out[0] if len(out) == 1 else out +class _PandasRowProxy(blosc2.Operand): + """Row proxy for `PandasUdfEngine.apply`'s axis=1 route. + + Stands in for "the current row" the way the textbook `axis=1` idiom + expects (`row["colname"]`), but is backed by whole *columns*: `row["a"] + + row["b"]` traces to one fused expression over the whole column set in + a single call, instead of looping over rows in Python. Columns are + extracted lazily (and cached) from the original DataFrame, not from a + whole-frame NumPy array, so per-column dtypes are preserved. + """ + + def __init__(self, df): + self._df = df + self._cache = {} + + def __getitem__(self, key): + if not isinstance(key, str): + raise TypeError( + f"row[{key!r}]: axis=1 row proxies only support column access by " + "name (a string). Positional or iterable row access is not " + "supported; for row-wise computations, call your @blosc2.jit " + "function directly with the DataFrame columns as separate " + "arguments instead, e.g. func(df['a'], df['b'])." + ) + if key in self._cache: + return self._cache[key] + n_matches = int((self._df.columns == key).sum()) + if n_matches == 0: + raise KeyError(f"row[{key!r}]: no such column in the DataFrame") + if n_matches > 1: + raise KeyError( + f"row[{key!r}]: column label is duplicated ({n_matches} matches); " + "axis=1 row proxies require unique column labels" + ) + col = self._df[key].to_numpy() + if col.dtype.kind not in "biufc": + raise ValueError( + f"row[{key!r}]: column has dtype {col.dtype!r}, which is not numeric. " + "The Blosc2 engine only supports vectorized numeric computations." + ) + proxy = SimpleProxy(col) + self._cache[key] = proxy + return proxy + + def __getattr__(self, name): + raise AttributeError( + f"row.{name}: axis=1 row proxies only support column access via " + f"row[{name!r}]; attribute access, iteration and per-row methods " + "(e.g. row.isna()) are not supported. For per-row computations that " + "need more than combining columns (e.g. per-row branching), call " + "your @blosc2.jit function directly with the columns as separate " + "array arguments instead of through df.apply(..., axis=1)." + ) + + +def _analyze_row_func(func) -> tuple[bool, bool]: + """Inspect *func* for the two signals `PandasUdfEngine.apply`'s axis=1 + route needs to pick a dispatch strategy: whether it subscripts its first + parameter with a string literal anywhere in its body (the `row["colname"]` + idiom), and whether its body contains a `for`/`while` loop. + + Both default to False (the historical per-row loop) if the source can't + be inspected, e.g. a dynamically built function. + """ + try: + sig = inspect.signature(func) + params = list(sig.parameters.values()) + if not params: + return False, False + row_name = params[0].name + source = textwrap.dedent(inspect.getsource(func)) + tree = ast.parse(source) + except (OSError, TypeError, SyntaxError, ValueError): + return False, False + nodes = list(ast.walk(tree)) + uses_subscript = any( + isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Name) + and node.value.id == row_name + and isinstance(node.slice, ast.Constant) + and isinstance(node.slice.value, str) + for node in nodes + ) + has_loop = any(isinstance(node, ast.For | ast.While) for node in nodes) + return uses_subscript, has_loop + + def _has_control_flow(source: str | None) -> bool: """Whether *source* (a DSL-extracted function source, or None) contains a branch or loop that tracing cannot observe.""" @@ -785,6 +874,17 @@ def dsl_wrapper(*args, **func_kwargs): bound = sig.bind(*args, **func_kwargs) bound.apply_defaults() values = tuple(bound.arguments[name] for name in kernel.input_names) + # Accept array-protocol operands (pandas Series, polars Series, ...) the + # same way the tracing route already does; zero-copy when the source is + # numpy-backed. + values = tuple( + np.asarray(v) + if not isinstance(v, np.ndarray | blosc2.NDArray) + and hasattr(v, "__array__") + and getattr(v, "ndim", 0) > 0 + else v + for v in values + ) array_shapes = { v.shape @@ -1055,6 +1155,8 @@ def apply(cls, data, func, args, kwargs, decorator, axis): """ orig = data values = cls._ensure_numpy_data(data) + func_name = getattr(func, "__name__", "the function") + uses_subscript, has_loop = _analyze_row_func(func) if hasattr(orig, "columns") else (False, False) func = decorator(func) if values.ndim == 1 or axis is None: # pandas Series.apply or pipe @@ -1064,9 +1166,45 @@ def apply(cls, data, func, args, kwargs, decorator, axis): result = [func(values[:, col_idx], *args, **kwargs) for col_idx in range(values.shape[1])] result = np.vstack(result).transpose() elif axis in (1, "columns"): - # pandas apply(axis=1) row-wise - result = [func(values[row_idx, :], *args, **kwargs) for row_idx in range(values.shape[0])] - result = np.vstack(result) + if uses_subscript and has_loop: + # row["colname"] combined with for/while: tracing would unroll + # the loop eagerly at call time, growing the traced expression + # with every iteration (a real per-row iteration count, like a + # Newton-Raphson loop, blows this up well past practical). No + # existing dispatch route can run this well; point at the one + # that can instead of hanging or crashing confusingly. + raise TypeError( + f"@blosc2.jit engine=... axis=1: {func_name!r} " + 'combines row["colname"] access with a for/while loop, which cannot be ' + "traced efficiently per-row. Call your @blosc2.jit function directly with " + "the DataFrame columns as separate array arguments instead, e.g. " + "kernel(df['a'], df['b']) -- see doc/guides/pandas_engine.md." + ) + if uses_subscript: + # The `row["colname"]` idiom: replace the per-row Python loop + # with one call over whole per-column arrays (row-proxy, see + # `_PandasRowProxy`), extracted from the original DataFrame so + # per-column dtypes survive. + row_proxy = _PandasRowProxy(orig) + result = func(row_proxy, *args, **kwargs) + if not ( + isinstance(result, np.ndarray) + and result.ndim == 1 + and result.shape[0] == values.shape[0] + ): + raise TypeError( + '@blosc2.jit engine=... axis=1: functions using row["colname"] must ' + f"return one scalar per row (shape ({values.shape[0]},)); got " + f"{result!r}. Returning multiple values per row is not supported here." + ) + else: + # pandas apply(axis=1) row-wise: the historical per-row loop. + # Fine for functions treating the row as a plain array (e.g. + # `row + 1`); functions using row["colname"] are dispatched + # above instead, since this loop hands each call a positional + # ndarray row that does not support string subscripting. + result = [func(values[row_idx, :], *args, **kwargs) for row_idx in range(values.shape[0])] + result = np.vstack(result) else: raise NotImplementedError(f"Unknown axis '{axis}'. Use one of 0, 1 or None.") diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py index bb4661885..d7c7dc075 100644 --- a/tests/ndarray/test_jit_dsl_dispatch.py +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -256,3 +256,12 @@ def test_jit_dsl_route_execution_tuning_kwarg_with_storage_kwarg_still_returns_n assert isinstance(res, blosc2.NDArray) assert res.schunk.cparams.clevel == 2 np.testing.assert_allclose(res[:], (a + b) * 3) + + +def test_jit_dsl_route_accepts_array_protocol_operands(): + pd = pytest.importorskip("pandas") + a = np.arange(1000, dtype=np.float64) + b = np.arange(1000, dtype=np.float64) * 0.5 + df = pd.DataFrame({"a": a, "b": b}) + jit_f = blosc2.jit()(_kernel_src) + np.testing.assert_array_equal(jit_f(df["a"], df["b"], 3), jit_f(a, b, 3)) diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 7441345f9..3e6f9d7c8 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -180,3 +180,81 @@ def test_apply_object_dtype_raises_clear_error(self): df = pd.DataFrame({"a": ["x", "y"]}) with pytest.raises(ValueError, match="numeric dtype"): df.apply(lambda x: x + 1, engine=blosc2.jit) + + def test_apply_axis1_row_subscript_idiom_matches_default_engine(self): + def add_people(row): + return row["max_people"] + row["max_children"] + + df = pd.DataFrame({"max_people": [4, 2, 8], "max_children": [1, 0, 3]}) + expected = df.apply(add_people, axis=1) + result = df.apply(add_people, engine=blosc2.jit, axis=1) + pd.testing.assert_series_equal(result, expected) + + def test_apply_axis1_row_subscript_args_kwargs_forwarded(self): + def combine(row, num1, num2=0): + return row["a"] + row["b"] + num1 + num2 + + df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}) + expected = df.apply(combine, axis=1, args=(10,), num2=100) + result = df.apply(combine, engine=blosc2.jit, axis=1, args=(10,), num2=100) + pd.testing.assert_series_equal(result, expected) + + def test_apply_axis1_row_subscript_preserves_column_dtype(self): + # a mixed-dtype frame would be upcast by DataFrame.values; the row + # proxy must extract columns from the original frame instead. + def add(row): + return row["i"] + row["f"] + + df = pd.DataFrame({"i": np.array([1, 2, 3], dtype=np.int64), "f": [0.5, 0.5, 0.5]}) + result = df.apply(add, engine=blosc2.jit, axis=1) + np.testing.assert_allclose(result.to_numpy(), [1.5, 2.5, 3.5]) + + def test_apply_axis1_row_subscript_with_loop_raises_clear_error(self): + def kepler_row(row): + m, ecc = row["m"], row["ecc"] + e = m + ecc * np.sin(m) + for _ in range(50): + diff = (e - ecc * np.sin(e) - m) / (1.0 - ecc * np.cos(e)) + e = e - diff + return e + + df = pd.DataFrame({"m": [0.1, 0.5], "ecc": [0.1, 0.2]}) + with pytest.raises(TypeError, match="for/while loop"): + df.apply(kepler_row, engine=blosc2.jit, axis=1) + + def test_apply_axis1_row_subscript_duplicate_column_raises(self): + def add(row): + return row["a"] + 1 + + df = pd.DataFrame(np.ones((2, 2)), columns=["a", "a"]) + with pytest.raises(KeyError, match="duplicated"): + df.apply(add, engine=blosc2.jit, axis=1) + + def test_apply_axis1_row_subscript_attribute_access_raises(self): + def bad(row): + return row["a"] + row.b + + df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}) + with pytest.raises(AttributeError, match="row\\['b'\\]"): + df.apply(bad, engine=blosc2.jit, axis=1) + + def test_apply_axis1_row_subscript_non_numeric_column_raises(self): + # Whole-frame numeric-dtype validation (`_ensure_numpy_data`) already + # gates this ahead of row-proxy dispatch; `_PandasRowProxy` carries + # its own per-column check too, for callers that construct it + # directly. + def bad(row): + return row["a"] + len(row["b"]) + + df = pd.DataFrame({"a": [1.0, 2.0], "b": ["x", "y"]}) + with pytest.raises(ValueError, match="numeric dtype"): + df.apply(bad, engine=blosc2.jit, axis=1) + + def test_apply_axis1_positional_idiom_still_uses_per_row_loop(self): + # No `row["..."]` subscript: falls back to the historical per-row + # loop, unaffected by the row-proxy dispatch added for the subscript + # idiom above. + df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]}) + expected = df.apply(lambda row: row * 2, axis=1) + result = df.apply(lambda row: row * 2, engine=blosc2.jit, axis=1) + pd.testing.assert_frame_equal(result, expected) From 53d913f2703e8090f72c9ceb6baa05a18da3af39 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 12:59:50 +0200 Subject: [PATCH 05/14] Support np.sign, and pick up the miniexpr DSL builtin fixes np.sign() was missing from ufunc_map_1param, so __array_ufunc__ returned NotImplemented and NumPy raised TypeError. The "sign" op already existed everywhere else (blosc2.sign, the expression compiler), only the dispatch entry was absent. Bump miniexpr to 63f2914, which stops sign() and a set of other builtins from silently falling back to the interpreter inside DSL kernels, and drop the gotcha documenting the old behavior. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- doc/guides/pandas_engine.md | 3 --- src/blosc2/ndarray.py | 1 + tests/ndarray/test_elementwise_funcs.py | 8 ++++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b0294d06..037232310 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG aab4b2ff030ffeddba894d0fe45ab7df6e53bd47 + GIT_TAG 63f291401a032393c30084e8644f14f415d51431 # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index 3a363430d..00080ddfa 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -184,9 +184,6 @@ correct, because `np.where` discards them, but the warning is noise and the work is wasted. That is why each arm is clamped to its own domain with `np.maximum`. The same caveat applies to numexpr's `where()`. -**`np.sign` is not supported** on the traced expressions and raises a -`TypeError`. Express it with `np.where` instead. - **A reduction used inside per-element control flow does not mean what it looks like it means, in a DSL kernel.** `sum`, `max`, `min` and friends are *block* reductions: they collapse the whole chunk being evaluated down to one diff --git a/src/blosc2/ndarray.py b/src/blosc2/ndarray.py index 4d5306688..114037f4b 100644 --- a/src/blosc2/ndarray.py +++ b/src/blosc2/ndarray.py @@ -116,6 +116,7 @@ np.floor: "floor", np.ceil: "ceil", np.trunc: "trunc", + np.sign: "sign", np.signbit: "signbit", np.round: "round", } diff --git a/tests/ndarray/test_elementwise_funcs.py b/tests/ndarray/test_elementwise_funcs.py index c82705f2c..84908eb64 100644 --- a/tests/ndarray/test_elementwise_funcs.py +++ b/tests/ndarray/test_elementwise_funcs.py @@ -355,3 +355,11 @@ def test_binary_funcs_torch_proxy(np_func, blosc_func, dtype, shape, chunkshape) @pytest.mark.parametrize(("shape", "chunkshape"), SHAPES_CHUNKS_HEAVY) def test_binary_funcs_heavy(np_func, blosc_func, dtype, shape, chunkshape): _test_binary_func_impl(np_func, blosc_func, dtype, shape, chunkshape) + + +@pytest.mark.parametrize("dtype", [blosc2.int64, blosc2.float64, blosc2.complex128]) +def test_sign_ufunc_dispatch(dtype): + # np.sign() on an operand must build a lazy expression, not raise TypeError + a = np.array([-2, 0, 3], dtype=dtype) + b = blosc2.asarray(a) + np.testing.assert_allclose(np.sign(b)[()], np.sign(a)) From 4799a2c5b78b80694adb5a839b4eef5876508b6f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 13:13:55 +0200 Subject: [PATCH 06/14] Dispatch np.square, np.negative, np.positive and np.reciprocal Same gap as np.sign: blosc2. and the op name both existed, but the ufunc_map_1param entry did not, so __array_ufunc__ returned NotImplemented and NumPy raised TypeError. Co-Authored-By: Claude Opus 5 --- src/blosc2/ndarray.py | 4 ++++ tests/ndarray/test_elementwise_funcs.py | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/blosc2/ndarray.py b/src/blosc2/ndarray.py index 114037f4b..ad661353f 100644 --- a/src/blosc2/ndarray.py +++ b/src/blosc2/ndarray.py @@ -118,6 +118,10 @@ np.trunc: "trunc", np.sign: "sign", np.signbit: "signbit", + np.square: "square", + np.negative: "negative", + np.positive: "positive", + np.reciprocal: "reciprocal", np.round: "round", } diff --git a/tests/ndarray/test_elementwise_funcs.py b/tests/ndarray/test_elementwise_funcs.py index 84908eb64..91bb6655f 100644 --- a/tests/ndarray/test_elementwise_funcs.py +++ b/tests/ndarray/test_elementwise_funcs.py @@ -357,9 +357,11 @@ def test_binary_funcs_heavy(np_func, blosc_func, dtype, shape, chunkshape): _test_binary_func_impl(np_func, blosc_func, dtype, shape, chunkshape) +@pytest.mark.parametrize("np_func", [np.sign, np.square, np.negative, np.positive, np.reciprocal]) @pytest.mark.parametrize("dtype", [blosc2.int64, blosc2.float64, blosc2.complex128]) -def test_sign_ufunc_dispatch(dtype): - # np.sign() on an operand must build a lazy expression, not raise TypeError - a = np.array([-2, 0, 3], dtype=dtype) +def test_ufunc_dispatch(np_func, dtype): + # These must build a lazy expression, not raise TypeError from __array_ufunc__. + # No zero in the input: reciprocal(0) warns, and that is not what is under test. + a = np.array([-2, 1, 3], dtype=dtype) b = blosc2.asarray(a) - np.testing.assert_allclose(np.sign(b)[()], np.sign(a)) + np.testing.assert_allclose(np_func(b)[()], np_func(a)) From 464a205f78317171b7c9ff307024a552172c5f42 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 13:29:26 +0200 Subject: [PATCH 07/14] Bump miniexpr to 8be04ee: fix complex division with a scalar operand Complex division where one operand was a scalar constant fell through to miniexpr's generic binary fallback, which narrows both operands through double and so discarded the imaginary part. 1.0/z on a complex NDArray returned 1/real(z) + 0j, and z/2.0 returned real(z)/2 + 0j. Ordinary values, no warning. 1.0/NDArray, blosc2.reciprocal, np.reciprocal and NDArray/2.0 now all agree with NumPy, down to the sign of zero and inf+nanj at the zero divide. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 037232310..9a0f4c152 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 63f291401a032393c30084e8644f14f415d51431 + GIT_TAG 8be04ee489d2859fec21885a01cf8db004e2b11a # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) From 1841fe749b382fa5fbf7f2ebd21912bf9f5a5a81 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 14:37:34 +0200 Subject: [PATCH 08/14] Do not jit an already-jitted function in the pandas engine PandasUdfEngine.apply/map called decorator(func) unconditionally, so passing a @blosc2.jit function with engine=blosc2.jit produced jit(jit(f)). The outer tracing wrapper replaces array arguments with SimpleProxy operands, so the inner DSL kernel saw no array at all and failed asking for `shape=`. Tracing tolerated it (SimpleProxy is what tracing wants), so only branch kernels broke. Mark both wrappers the decorator can return -- the DSL route returns early, before the tracing one -- and skip re-decorating a marked function. _analyze_row_func now inspects the undecorated function too: on a decorated argument it was reading the wrapper's source, so the axis=1 row["col"] detection would have misfired. Co-Authored-By: Claude Opus 5 --- src/blosc2/proxy.py | 33 ++++++++++++++++++++++++++++---- tests/test_pandas_udf_engine.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 35b2387d5..417cc28b4 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -817,6 +817,24 @@ def __getattr__(self, name): ) +def _undecorated(func): + """The original function behind a @blosc2.jit wrapper, or *func* itself. + + Source inspection has to see what the user wrote, not the wrapper. + """ + return getattr(func, "_blosc2_jit_wrapped", func) + + +def _decorate_once(func, decorator): + """Apply *decorator* unless *func* is already a @blosc2.jit wrapper. + + Decorating twice used to break the DSL route: the outer (tracing) wrapper + replaces array arguments with SimpleProxy operands, so the inner DSL kernel + saw no array at all and failed asking for `shape=`. + """ + return func if hasattr(func, "_blosc2_jit_wrapped") else decorator(func) + + def _analyze_row_func(func) -> tuple[bool, bool]: """Inspect *func* for the two signals `PandasUdfEngine.apply`'s axis=1 route needs to pick a dispatch strategy: whether it subscripts its first @@ -1042,7 +1060,9 @@ def decorator(func): # noqa: C901 use_dsl = strict is True or (strict is None and has_cf and dsl_ok) if use_dsl: - return _jit_dsl_wrapper(kernel, out, kwargs) + dsl_wrapper = _jit_dsl_wrapper(kernel, out, kwargs) + dsl_wrapper._blosc2_jit_wrapped = func + return dsl_wrapper _trace_hint = None if strict is None and has_cf and not dsl_ok: @@ -1104,6 +1124,9 @@ def wrapper(*args, **func_kwargs): # honor any execution-tuning kwargs (jit/jit_backend/fp_accuracy). return retval.compute(_getitem=True, **exec_kwargs) + # Lets callers (notably the pandas engine below) tell an already-jitted + # function from a plain one, so it is not decorated a second time. + wrapper._blosc2_jit_wrapped = func return wrapper if func is None: @@ -1142,7 +1165,7 @@ def map(cls, data, func, args, kwargs, decorator, skip_na): if skip_na: raise NotImplementedError("The Blosc2 engine does not support na_action='ignore' in map.") values = cls._ensure_numpy_data(data) - func = decorator(func) + func = _decorate_once(func, decorator) return func(values, *args, **kwargs) @classmethod @@ -1156,8 +1179,10 @@ def apply(cls, data, func, args, kwargs, decorator, axis): orig = data values = cls._ensure_numpy_data(data) func_name = getattr(func, "__name__", "the function") - uses_subscript, has_loop = _analyze_row_func(func) if hasattr(orig, "columns") else (False, False) - func = decorator(func) + uses_subscript, has_loop = ( + _analyze_row_func(_undecorated(func)) if hasattr(orig, "columns") else (False, False) + ) + func = _decorate_once(func, decorator) if values.ndim == 1 or axis is None: # pandas Series.apply or pipe result = func(values, *args, **kwargs) diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 3e6f9d7c8..115ef54b6 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -258,3 +258,37 @@ def test_apply_axis1_positional_idiom_still_uses_per_row_loop(self): expected = df.apply(lambda row: row * 2, axis=1) result = df.apply(lambda row: row * 2, engine=blosc2.jit, axis=1) pd.testing.assert_frame_equal(result, expected) + + def test_apply_already_jitted_function_is_not_decorated_twice(self): + # Decorating and passing engine= both request the same thing. Applying + # the decorator a second time used to wrap the array in a SimpleProxy + # before the inner DSL kernel saw it, which then failed asking for + # `shape=`. Traced functions tolerated it, so only branches broke. + def branch(col): + if col >= 0: + out = col + 1.0 + else: + out = col - 1.0 + return out + + df = pd.DataFrame({"a": [-2.0, 1.0, 3.0], "b": [4.0, -5.0, 6.0]}) + expected = df.apply(lambda col: np.where(col >= 0, col + 1.0, col - 1.0)) + + for func in (branch, blosc2.jit(branch)): + result = df.apply(func, engine=blosc2.jit) + pd.testing.assert_frame_equal(result, expected) + + def test_map_already_jitted_function_is_not_decorated_twice(self): + def branch(col): + if col >= 0: + out = col * 2.0 + else: + out = col + return out + + s = pd.Series([-2.0, 1.0, 3.0]) + expected = np.where(s.to_numpy() >= 0, s.to_numpy() * 2.0, s.to_numpy()) + + for func in (branch, blosc2.jit(branch)): + result = s.map(func, engine=blosc2.jit) + np.testing.assert_allclose(np.asarray(result), expected) From daa0be358f1b0e22e00124c9eebaf3a9a98ed074 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 14:37:42 +0200 Subject: [PATCH 09/14] Bump miniexpr to 58d2d0b; drop the now-redundant power alias miniexpr accepts `power` as an alias of `pow`, so the np.power entry in _NUMPY_TO_DSL_FUNC_ALIASES is no longer needed: that map is for names the DSL does not know, and it knows this one. np.power still works, only the `np.` prefix is stripped now. The bump also carries the complex-division fix already in use here and a Windows portability fix (_stat takes struct _stat, not struct stat), which matters because this repo builds miniexpr via FetchContent. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- src/blosc2/dsl_kernel.py | 1 - tests/ndarray/test_dsl_kernels.py | 4 +++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a0f4c152..1895b275a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 8be04ee489d2859fec21885a01cf8db004e2b11a + GIT_TAG 58d2d0b4a3aee3d1ac84b213712cf982744196c8 # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index df2e7bf22..82c11b373 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -33,7 +33,6 @@ class DSLSyntaxError(ValueError): # names with subtle semantic differences (e.g. `np.mod`/`np.remainder`'s sign # convention vs C's `fmod`) are deliberately left out. _NUMPY_TO_DSL_FUNC_ALIASES = { - "power": "pow", "maximum": "fmax", "minimum": "fmin", "absolute": "abs", diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 687110b9e..729547892 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -1272,7 +1272,9 @@ def k(x, y): @pytest.mark.parametrize( ("numpy_call", "expected_dsl_name"), [ - ("np.power(x, 2.0)", "pow"), + # np.power keeps its name: the DSL accepts `power` as an alias of `pow`, + # so only the `np.` prefix is stripped. + ("np.power(x, 2.0)", "power"), ("np.maximum(x, 0.5)", "fmax"), ("np.minimum(x, -0.5)", "fmin"), ("np.absolute(x)", "abs"), From 515c48ae1dc54e669820d08248fb51b7f11708d8 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 14:37:51 +0200 Subject: [PATCH 10/14] docs: explain tracing plainly, and be honest about if vs np.where "The function is traced" told a new reader nothing. Say what happens instead: the engine calls the function once with stand-in objects that record operations rather than computing them, and the ValueError follows from a Python `if` being handed the whole column. The claim that np.where is used for speed did not survive measurement. A real `if` run by Python is indeed the slow option (0.08x), but compiled to a DSL kernel it lands within ~6% of the traced form -- the saved arm roughly cancels the vectorized math tracing gets to use. The honest reason to keep np.where in the example is portability: it works whatever the function contains, while the branch only compiles if the whole function fits the DSL grammar. bench_branch_vs_where() emits all four figures so the table stays reproducible from bench/bench_pandas_engine.py, as the page promises. Co-Authored-By: Claude Opus 5 --- bench/bench_pandas_engine.py | 44 +++++++++++++++++++++++ doc/guides/pandas_engine.md | 68 +++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/bench/bench_pandas_engine.py b/bench/bench_pandas_engine.py index eb335ae93..baa384edb 100644 --- a/bench/bench_pandas_engine.py +++ b/bench/bench_pandas_engine.py @@ -86,6 +86,26 @@ def yeo_johnson(col, lam=0.5): return np.where(col >= 0, pos, neg) +# The same transform written with a real per-element if/else. It compiles to a +# DSL kernel instead of being traced, so only the matching arm runs for each +# element and no clamping is needed. lam is inlined because apply() passes the +# column alone. The explanation lives here rather than in a docstring: a DSL +# kernel body cannot contain a string literal. +def yeo_johnson_branch(col): + if col >= 0: + out = (np.power(col + 1.0, 0.5) - 1.0) / 0.5 + else: + out = -(np.power(-col + 1.0, 1.5) - 1.0) / 1.5 + return out + + +def yeo_johnson_scalar(x, lam=0.5): + """Per-element Python, the shape you would write without any engine.""" + if x >= 0: + return ((x + 1.0) ** lam - 1.0) / lam + return -((-x + 1.0) ** (2.0 - lam) - 1.0) / (2.0 - lam) + + # The same transform as a single numexpr expression: legal, but this is what # the readability argument is about. YEO_JOHNSON_NX = ( @@ -259,6 +279,28 @@ def save_plot(row_speedups, ops_speedups, out_path): plt.close(fig) +SCALAR_ROWS = 50_000 + + +def bench_branch_vs_where(df, t_plain, result_plain): + """Real per-element if vs the traced np.where form, plus per-element Python. + + The scalar version is timed on a smaller frame and extrapolated: at the full + size it takes over a second per run. + """ + t_branch, result_branch = timeit(lambda: df.apply(yeo_johnson_branch, engine=blosc2.jit)) + pd.testing.assert_frame_equal(result_branch, result_plain) + + small = make_df(nrows=SCALAR_ROWS) + t_small, _ = timeit(lambda: small.apply(lambda col: col.map(yeo_johnson_scalar))) + t_scalar = t_small * (NROWS / SCALAR_ROWS) + + print("\nreal if vs np.where (both under engine=blosc2.jit):") + print(f" per-element Python, real if: {t_scalar:.4f} s (extrapolated) {t_plain / t_scalar:.2f}x") + print(f" engine, real if (DSL kernel): {t_branch:.4f} s {t_plain / t_branch:.2f}x") + print(" (np.where form is the t_engine figure above)") + + def main(): df = make_df() @@ -274,6 +316,8 @@ def main(): print(f"df.apply(f, engine=blosc2.jit): {t_engine:.4f} s {t_plain / t_engine:.2f}x") print(f"numexpr per column: {t_numexpr:.4f} s {t_plain / t_numexpr:.2f}x") + bench_branch_vs_where(df, t_plain, result_plain) + print("\nrows sweep (speedup vs plain apply):") row_speedups = [] for nrows in ROW_SWEEP: diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index 00080ddfa..d282a475d 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -144,7 +144,22 @@ explicitly if it matters: z = (col - col.mean()) / col.std(ddof=0) ``` -**Real per-element `if`/`for`/`while` now works, for a numeric subset of +**Your function is inspected once, not run over the values.** This is worth +understanding, because most of the surprises below follow from it. By default +the engine calls your function a single time, passing stand-in objects in place +of the columns. Those stand-ins compute nothing; they just record the operations +you ask for. What comes back is a description of the whole calculation, which +Blosc2 then evaluates over the real data in one fused pass. (The usual name for +this is *tracing*.) Your Python code therefore runs once, at setup — never per +row, which is exactly where the speed comes from. + +The catch is that a plain Python `if` has nothing to look at during that single +call: it is handed the entire column, not one value, so it raises +`ValueError: The truth value of an array with more than one element is +ambiguous`. Branching on a scalar *parameter* is fine, and always was — only +branching on column values is affected. + +**Real per-element `if`/`for`/`while` works too, for a numeric subset of NumPy.** `engine=blosc2.jit` auto-detects control flow: if your function branches or loops over column values *and* the function fits Blosc2's [DSL grammar](../reference/dsl_syntax.md), it is compiled and run as written — @@ -152,15 +167,14 @@ branches and loops behave like real Python — instead of being traced. Both `np.sin(x)`-style calls and bare `sin(x)`-style calls are accepted (the former is rewritten to the latter automatically; a handful of NumPy functions the DSL knows under a different name, like `np.maximum`/`np.minimum`, are translated -too — `power`, `maximum`, `minimum` and `absolute` today). Two things to know +too — `maximum`, `minimum` and `absolute` today). Two things to know before relying on this: - If the function doesn't fit the DSL grammar at all (e.g. it uses statements the grammar doesn't support), it silently falls back to the original behavior: the function is traced, and branching on array contents raises `ValueError: The truth value of an array ... is ambiguous`. Use `np.where` - as before, nesting it where you would have used `elif`. Branching on a - scalar *parameter* has always been fine either way. + as before, nesting it where you would have used `elif`. - If the function looks DSL-shaped but calls something outside the DSL's supported functions (not every NumPy function has a DSL equivalent), compiling it fails at call time with a `RuntimeError` naming the problem — @@ -174,6 +188,52 @@ protocol requires the plain `blosc2.jit` object, so `engine=` always gets the auto-detect (`strict=None`) behavior described above — there is currently no way to pass `strict=True`/`strict=False` through this entry point. +**So why does the example above use `np.where` rather than an `if`?** Mostly +portability, not speed. Written with a real branch, `yeo_johnson` needs no +clamping at all, because only the matching arm runs for each element: + +```python +@blosc2.jit +def yeo_johnson_branch(col): + if col >= 0: + out = (np.power(col + 1.0, 0.5) - 1.0) / 0.5 + else: + out = -(np.power(-col + 1.0, 1.5) - 1.0) / 1.5 + return out + + +result = df.apply(yeo_johnson_branch, engine=blosc2.jit) +``` + +The `@blosc2.jit` decorator is optional here — `engine=blosc2.jit` compiles the +function either way — but it is harmless, and keeping it means the same +function also works when called directly. + +That is the clearer statement of the transform, and on the same 1,000,000 × 8 +frame it costs about 6%: + +| approach | time | vs plain apply | +| --- | --- | --- | +| per-element Python, real `if` | 1.66 s (measured at 50,000 rows, scaled) | 0.08x | +| plain `df.apply(f)`, `np.where` | 0.1300 s | 1.00x | +| `engine=blosc2.jit`, real `if` (DSL kernel) | 0.0642 s | 2.02x | +| `engine=blosc2.jit`, `np.where` (traced) | 0.0606 s | 2.15x | + +Two things to read off that table. A real `if` executed **by Python**, one value +at a time, is the slow option by a wide margin — 13x slower than a vectorized +`np.where` and about 26x slower than either engine path. That is the version to +avoid, and it is what people usually mean when they say branching is slow. But a +real `if` **compiled to a DSL kernel** is a different thing entirely: it lands +within a few percent of the traced form, because the branch's saved work +(only one arm runs) roughly cancels against the vectorized math the traced form +gets to use. + +The traced `np.where` version stays in the example because it works whatever +your function contains, while the branch version only compiles if the whole +function fits the DSL grammar — note it already had to inline `lam`, since +`apply` passes the column alone. If your function does fit, prefer the branch: +it is easier to read and needs no domain clamping. + **`np.where` evaluates both arms.** Unlike a real `if`, both branches are computed over the whole column and only then selected between, so each one runs on values it was never meant to see. In `yeo_johnson` above, `np.power(col + 1.0, From 9f93914062c8aae4bd5cd462c610f6523a7da824 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 17:40:17 +0200 Subject: [PATCH 11/14] Accept a configured blosc2.jit(...) as a pandas engine pandas gates on hasattr(engine, "__pandas_udf__") and then uses the engine object itself as the decorator, so the attribute was the only thing keeping blosc2.jit(strict=True) from working as an engine. Attaching it to the decorator jit() returns makes `df.apply(f, engine=blosc2.jit(strict=True))` work, which is the only way to reach strict= through that entry point, and gives strict=False the same way. No new public name: the existing, documented strict= parameter is what the caller reaches for. Also make the strict=True failure a DSLSyntaxError rather than a TypeError when the source cannot be read at all, so "not a DSL kernel" is uniformly a ValueError subclass, and drop the "@blosc2.jit(strict=True):" prefix from the message, which named the wrong entry point when the failure arrives via engine=. Document what strict=True actually guarantees: that the source *parses* as DSL, checked at decoration time. A DSL-shaped function calling something miniexpr does not implement still passes and fails later at call time with a RuntimeError. The old text claimed equivalence to blosc2.dsl_kernel, which is an unrelated helper building a DSLKernel object. The Examples are now valid doctests (they used >>> for continuation lines, so blosc2.proxy.jit was one of six failing doctests in this module) with verified output -- compute_expression returns array([3, 5, 5, 5]), not [5 5 5 5]. Co-Authored-By: Claude Opus 5 --- src/blosc2/proxy.py | 63 ++++++++++++++++++++++++++++----- tests/test_pandas_udf_engine.py | 29 +++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 417cc28b4..949dcf649 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -21,7 +21,7 @@ import numpy as np import blosc2 -from blosc2.dsl_kernel import DSLKernel +from blosc2.dsl_kernel import DSLKernel, DSLSyntaxError # Default Proxy.afetch concurrency cap for remote sources (e.g. C2Array), # where fetches are dominated by round-trip latency, not local CPU/IO. @@ -1008,9 +1008,19 @@ def jit(func=None, *, out=None, disable=False, strict=None, **kwargs): # noqa: extraction error. Functions without control flow always trace, even if they happen to be DSL-valid (tracing is faster for pure elementwise expressions). - - ``True``: always use the DSL route, raising at decoration time if - *func* cannot be compiled as a DSL kernel. Equivalent to - ``blosc2.dsl_kernel``. + - ``True``: always use the DSL route, raising + :class:`~blosc2.dsl_kernel.DSLSyntaxError` at decoration time if + *func*'s source cannot be **parsed** as a DSL kernel. Note the + guarantee is exactly that -- parsing -- and not that the kernel will + compile: a function that is DSL-shaped but calls something miniexpr + does not implement passes here and fails later, at call time, with a + ``RuntimeError``. See the DSL syntax reference for what the grammar + accepts. (Unrelated to :func:`blosc2.dsl_kernel`, which builds a + :class:`DSLKernel` object rather than an evaluating wrapper.) + + This also works as a pandas engine, which is the only way to reach + ``strict`` through that entry point: + ``df.apply(f, engine=blosc2.jit(strict=True))``. - ``False``: always use the tracing route, even if *func* has control flow (this only works when branches/loops depend on plain Python values, not on traced arrays). @@ -1037,13 +1047,37 @@ def jit(func=None, *, out=None, disable=False, strict=None, **kwargs): # noqa: >>> import numpy as np >>> import blosc2 >>> @blosc2.jit - >>> def compute_expression(a, b, c): - >>> return np.sum(((a ** 3 + np.sin(a * 2)) > 2 * c) & (b > 0), axis=1) + ... def compute_expression(a, b, c): + ... return np.sum(((a ** 3 + np.sin(a * 2)) > 2 * c) & (b > 0), axis=1) >>> a = np.arange(20, dtype=np.float32).reshape(4, 5) >>> b = np.arange(20).reshape(4, 5) >>> c = np.arange(5) >>> compute_expression(a, b, c) - [5 5 5 5] + array([3, 5, 5, 5]) + + With ``strict=True`` the function is compiled as a DSL kernel, so a real + per-element ``if`` runs as written -- only the matching arm is evaluated: + + >>> @blosc2.jit(strict=True) + ... def clamp(x): + ... if x < 0.0: + ... out = 0.0 + ... else: + ... out = x + ... return out + >>> clamp(np.array([-1.5, 2.0, -0.5])) + array([0., 2., 0.]) + + The guarantee is that the source *parses* as DSL, checked at decoration + time. A body the grammar does not accept is rejected right away, rather + than silently falling back to tracing: + + >>> @blosc2.jit(strict=True) # doctest: +IGNORE_EXCEPTION_DETAIL + ... def not_dsl(x): + ... return np.where(x >= 0, x.mean(), x) + Traceback (most recent call last): + ... + blosc2.dsl_kernel.DSLSyntaxError: Unsupported call target in DSL ... """ def decorator(func): # noqa: C901 @@ -1054,8 +1088,13 @@ def decorator(func): # noqa: C901 has_cf = _has_control_flow(kernel.dsl_source) dsl_ok = kernel.dsl_source is not None and kernel.dsl_error is None if strict is True and not dsl_ok: - raise kernel.dsl_error or TypeError( - f"@blosc2.jit(strict=True): could not extract a DSL kernel from {func.__name__!r}" + # One condition, one exception type: DSLSyntaxError (a ValueError) + # whether the source failed to parse as DSL or could not be read at + # all (a lambda, a C function). The message avoids naming the + # decorator spelling, since `strict=True` also arrives through + # `df.apply(..., engine=blosc2.jit(strict=True))`. + raise kernel.dsl_error or DSLSyntaxError( + f"strict=True: could not extract a DSL kernel from {func.__name__!r}" ) use_dsl = strict is True or (strict is None and has_cf and dsl_ok) @@ -1129,6 +1168,12 @@ def wrapper(*args, **func_kwargs): wrapper._blosc2_jit_wrapped = func return wrapper + # Carry the engine on the decorator too, so a configured call such as + # `blosc2.jit(strict=True)` is accepted by `df.apply(..., engine=...)`: + # pandas gates on hasattr(engine, "__pandas_udf__") and then uses the engine + # object itself as the decorator. + decorator.__pandas_udf__ = PandasUdfEngine + if func is None: return decorator else: diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 115ef54b6..d45db177c 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -292,3 +292,32 @@ def branch(col): for func in (branch, blosc2.jit(branch)): result = s.map(func, engine=blosc2.jit) np.testing.assert_allclose(np.asarray(result), expected) + + def test_apply_engine_accepts_configured_jit(self): + # pandas gates on hasattr(engine, "__pandas_udf__") and then uses the + # engine object as the decorator, so a configured blosc2.jit(...) call + # is a valid engine -- the only way to reach strict= through apply(). + from blosc2.dsl_kernel import DSLSyntaxError + + def branch(col): + if col >= 0: + out = col + 1.0 + else: + out = col - 1.0 + return out + + def not_dsl(col): + return np.where(col >= 0, col.mean() + 1.0, col - 1.0) + + df = pd.DataFrame({"a": [-2.0, 1.0, 3.0], "b": [4.0, -5.0, 6.0]}) + expected = df.apply(lambda col: np.where(col >= 0, col + 1.0, col - 1.0)) + + result = df.apply(branch, engine=blosc2.jit(strict=True)) + pd.testing.assert_frame_equal(result, expected) + + # strict=True refuses to silently fall back to tracing + with pytest.raises(DSLSyntaxError): + df.apply(not_dsl, engine=blosc2.jit(strict=True)) + + # strict=False forces the tracing route instead + df.apply(not_dsl, engine=blosc2.jit(strict=False)) From 13cf9721a3a8ad0acfeeeba602b74f72171e764d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 18:39:23 +0200 Subject: [PATCH 12/14] docs: rewrite the pandas guide around one question, add the Kepler plot The guide had grown to ~460 lines of interleaved benchmarks and caveats. It now opens with the only decision a reader has to make -- one column at a time, or several columns per row? -- and gives a section to each answer. Material that argued rather than informed (the numexpr trade-off, the if-vs-np.where table, column extraction costs) moved to a short Details section at the end; the four gotchas that produce wrong answers stay in full. Promote `kernel(**df)` as the row-wise call: a DataFrame unpacks into one keyword argument per column, so naming a kernel's parameters after the columns removes the repetition that made the direct-call pattern look like a downgrade from apply(). Works today on both jit routes -- Series became valid operands in 9f939140 -- so this is documentation, not new API. Benchmark gains two Kepler sweeps and a second plot: speedup vs rows, and vs eccentricity, the latter being the actual test of the "per-row break" explanation. Rows converging evenly (e < 0.1, all 3 iterations) win only 2.7x; unevenly (e < 0.99, worst row 10 iterations vs 4 on average) win 7.4x. Co-Authored-By: Claude Opus 5 --- bench/bench_pandas_engine.py | 142 ++++++-- doc/guides/pandas_engine.md | 500 +++++++++------------------ doc/guides/pandas_engine/kepler.png | Bin 0 -> 58242 bytes doc/guides/pandas_engine/speedup.png | Bin 51491 -> 51404 bytes src/blosc2/proxy.py | 5 +- tests/test_pandas_udf_engine.py | 21 ++ 6 files changed, 301 insertions(+), 367 deletions(-) create mode 100644 doc/guides/pandas_engine/kepler.png diff --git a/bench/bench_pandas_engine.py b/bench/bench_pandas_engine.py index baa384edb..cb03fdfab 100644 --- a/bench/bench_pandas_engine.py +++ b/bench/bench_pandas_engine.py @@ -25,15 +25,15 @@ # quoted expression string, not that it wins a raw speed race. See # doc/guides/pandas_engine.md. # -# Row-wise (axis=1) computations that combine several columns per row are a -# different story: engine=blosc2.jit + axis=1 still calls the function once -# per row in a Python loop, so it is not the right tool there either. Instead, -# write the function to take the columns as separate array parameters and -# call it directly (no df.apply at all) -- see "Row-wise computations" in -# doc/guides/pandas_engine.md. bench_row_wise() below measures that pattern -# against a plain per-row apply() and vectorized NumPy on a genuine -# per-row-convergence problem (Kepler's equation via Newton-Raphson), where a -# real per-row `break` beats even vectorized NumPy. +# Row-wise (axis=1) computations are a different story. apply() cannot express +# per-row iteration at all, so the pattern to reach for is a function taking +# the columns as separate array parameters, called directly (no df.apply) -- +# with **df, since a DataFrame unpacks into one keyword argument per column. +# See "Row-wise computations" in doc/guides/pandas_engine.md. bench_row_wise() +# below measures that pattern against a plain per-row apply() and vectorized +# NumPy on a genuine per-row-convergence problem (Kepler's equation via +# Newton-Raphson), where a real per-row `break` beats even vectorized NumPy, +# and sweeps both what drives that win (rows, and how unevenly rows converge). # # Each measurement is the minimum of NRUNS repetitions to reduce noise. @@ -57,6 +57,14 @@ # keep this sweep small so the benchmark finishes in a reasonable time. ROW_WISE_APPLY_NROWS = 2_000 +KEPLER_ROW_SWEEP = (10_000, 100_000, 1_000_000, 5_000_000) + +# Maximum orbital eccentricity: the knob controlling how *unevenly* rows +# converge. Near-circular orbits (0.1) all converge in the same 3 iterations; +# near-parabolic ones (0.99) leave a slow tail that vectorized NumPy must keep +# sweeping the whole array for, while the DSL kernel's per-row break does not. +KEPLER_ECC_SWEEP = (0.1, 0.5, 0.9, 0.99) + OUT_DIR = Path(__file__).resolve().parent.parent / "doc" / "guides" / "pandas_engine" # dataviz reference palette, same values as bench/optim_tips/common.py @@ -189,16 +197,40 @@ def kepler_dsl(mean_anomaly, eccentricity): return e -def make_kepler_df(nrows): +def make_kepler_df(nrows, ecc_max=0.95): rng = np.random.default_rng(1) return pd.DataFrame( { "mean_anomaly": rng.uniform(0, 2 * np.pi, nrows), - "eccentricity": rng.uniform(0.0, 0.95, nrows), + "eccentricity": rng.uniform(0.0, ecc_max, nrows), } ) +def kepler_max_iters(m, ecc): + """Iterations the slowest-converging row needs -- what vectorized NumPy + pays for every row, and what the DSL kernel's per-row break avoids.""" + e = m + ecc * np.sin(m) + for k in range(100): + diff = (e - ecc * np.sin(e) - m) / (1.0 - ecc * np.cos(e)) + e = e - diff + if np.max(np.abs(diff)) < 1e-12: + return k + 1 + return 100 + + +def kepler_speedup(df): + """Vectorized NumPy vs the direct DSL call, returning (speedup, t_numpy, t_dsl).""" + m = df["mean_anomaly"].to_numpy() + ecc = df["eccentricity"].to_numpy() + t_numpy, result_numpy = timeit(lambda: kepler_numpy(m, ecc)) + # Columns passed by keyword via **df: kernel parameters are named after the + # DataFrame columns, so no column has to be restated at the call site. + t_dsl, result_dsl = timeit(lambda: np.asarray(kepler_dsl(**df))) + np.testing.assert_allclose(result_dsl, result_numpy, atol=1e-9) + return t_numpy / t_dsl, t_numpy, t_dsl + + def bench_row_wise(): # Slice from one frame rather than calling make_kepler_df(n) twice with # different n: a fresh same-seeded Generator's bulk draws are not @@ -210,9 +242,7 @@ def bench_row_wise(): t_apply, result_apply = timeit(lambda: df_small.apply(kepler_row_scalar, axis=1)) t_numpy, result_numpy = timeit(lambda: kepler_numpy(m, ecc)) - t_dsl, result_dsl = timeit( - lambda: np.asarray(kepler_dsl(df_full["mean_anomaly"], df_full["eccentricity"])) - ) + t_dsl, result_dsl = timeit(lambda: np.asarray(kepler_dsl(**df_full))) # Cross-check correctness: plain apply on the small frame vs numpy on the # same rows, and the direct DSL call vs numpy on the full frame. @@ -234,6 +264,45 @@ def bench_row_wise(): f"(~{per_row_apply / per_row_dsl:,.0f}x)" ) + print("\nkepler rows sweep (speedup of the direct DSL call vs vectorized numpy):") + row_speedups = [] + for nrows in KEPLER_ROW_SWEEP: + sp, tn, td = kepler_speedup(make_kepler_df(nrows)) + row_speedups.append(sp) + print(f" {nrows:>9,} rows: numpy {tn:.4f} s DSL {td:.4f} s {sp:.2f}x") + + print("\nkepler eccentricity sweep (how unevenly rows converge):") + ecc_speedups, ecc_iters = [], [] + for ecc_max in KEPLER_ECC_SWEEP: + df = make_kepler_df(NROWS, ecc_max=ecc_max) + iters = kepler_max_iters(df["mean_anomaly"].to_numpy(), df["eccentricity"].to_numpy()) + sp, tn, td = kepler_speedup(df) + ecc_speedups.append(sp) + ecc_iters.append(iters) + print( + f" e < {ecc_max:<5} slowest row: {iters:>2} iters " + f"numpy {tn:.4f} s DSL {td:.4f} s {sp:.2f}x" + ) + + out_path = OUT_DIR / "kepler.png" + save_kepler_plot(row_speedups, ecc_speedups, ecc_iters, out_path) + print(f"\nplot saved to {out_path}") + + +def style_speedup_axes(ax, values): + """Shared look for the speedup panels: break-even line, x-suffixed ticks.""" + # Break-even: below this line the faster-looking option is a net loss. + ax.axhline(1.0, color=MUTED, linestyle="--", linewidth=1) + ax.set_ylim(0, max(values) * 1.25) + ax.yaxis.set_major_formatter(lambda v, _pos: f"{v:g}x") + ax.yaxis.grid(True, color=GRID, linewidth=0.8) + ax.set_axisbelow(True) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color(GRID) + ax.spines["bottom"].set_color(GRID) + ax.tick_params(labelsize=9, colors=MUTED) + def save_plot(row_speedups, ops_speedups, out_path): import matplotlib @@ -256,17 +325,7 @@ def save_plot(row_speedups, ops_speedups, out_path): ax_ops.set_title(f"{NROWS:,} rows x {NCOLS} columns", color=MUTED, fontsize=9) for ax, values in ((ax_rows, row_speedups), (ax_ops, ops_speedups)): - # Break-even: below this line the engine is a net loss. - ax.axhline(1.0, color=MUTED, linestyle="--", linewidth=1) - ax.set_ylim(0, max(values) * 1.25) - ax.yaxis.set_major_formatter(lambda v, _pos: f"{v:g}x") - ax.yaxis.grid(True, color=GRID, linewidth=0.8) - ax.set_axisbelow(True) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color(GRID) - ax.spines["bottom"].set_color(GRID) - ax.tick_params(labelsize=9, colors=MUTED) + style_speedup_axes(ax, values) fig.suptitle( "df.apply(f, engine=blosc2.jit): when it pays off", @@ -279,6 +338,39 @@ def save_plot(row_speedups, ops_speedups, out_path): plt.close(fig) +def save_kepler_plot(row_speedups, ecc_speedups, ecc_iters, out_path): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + fig, (ax_rows, ax_ecc) = plt.subplots(1, 2, figsize=(8, 3.2)) + + ax_rows.semilogx(KEPLER_ROW_SWEEP, row_speedups, "o-", color=COLOR_TIP, linewidth=2) + ax_rows.set_xlabel("rows (log scale)", color=INK, fontsize=9) + ax_rows.set_ylabel("speedup vs vectorized NumPy", color=INK, fontsize=9) + ax_rows.set_title("eccentricity < 0.95", color=MUTED, fontsize=9) + + ax_ecc.plot(range(len(KEPLER_ECC_SWEEP)), ecc_speedups, "o-", color=COLOR_TIP, linewidth=2) + ax_ecc.set_xticks(range(len(KEPLER_ECC_SWEEP))) + ax_ecc.set_xticklabels([f"< {e}\n({n} iters)" for e, n in zip(KEPLER_ECC_SWEEP, ecc_iters, strict=True)]) + ax_ecc.set_xlabel("eccentricity (iterations the slowest row needs)", color=INK, fontsize=9) + ax_ecc.set_title(f"{NROWS:,} rows", color=MUTED, fontsize=9) + + for ax, values in ((ax_rows, row_speedups), (ax_ecc, ecc_speedups)): + style_speedup_axes(ax, values) + + fig.suptitle( + "Kepler by Newton-Raphson: direct DSL call vs vectorized NumPy", + fontsize=11, + color=INK, + ) + fig.tight_layout(rect=[0, 0, 1, 0.90]) + out_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_path, dpi=150) + plt.close(fig) + + SCALAR_ROWS = 50_000 diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index d282a475d..ca98d6aff 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -1,27 +1,27 @@ # Using Blosc2 with pandas -pandas' `DataFrame.apply` and `Series.map` accept an `engine=` argument, and -`blosc2.jit` is one such engine. Instead of running your function once per -element in a Python loop, pandas hands it the **whole column at once**, and -Blosc2 evaluates the entire function body in a single multi-threaded pass -over the data. +There are two ways to make a pandas computation faster with Blosc2, and which +one you want depends on a single question: **does your function work on one +column at a time, or does it combine several columns per row?** -The result is typically **2-3x faster than a plain `apply`**, with the -function itself left exactly as you wrote it. That is the right tool for -transforms applied to each column independently (`axis=0`, the default). +| your function | use | typical win | +| --- | --- | --- | +| transforms one column (`axis=0`) | `df.apply(f, engine=blosc2.jit)` | 2-3x | +| combines columns, one result per row | `f(**df)` — no `apply` at all | 5-7x | + +Both need a decent amount of data and a decent amount of arithmetic to be +worth it; the sections below say how much. -For computations that combine several *columns* per row — the case pandas 3 -highlights `engine=` for — a plain `@blosc2.jit` function called directly with -the columns as arguments is both simpler and faster, with wins well past -2-3x. [Jump to that section](#row-wise-computations-pass-the-columns-skip-apply) -if that is what you are here for. +## One column at a time: `engine=blosc2.jit` -## An example +`DataFrame.apply` and `Series.map` accept an `engine=` argument, and +`blosc2.jit` is one such engine. pandas hands your function the **whole column +at once**, and Blosc2 evaluates the entire function body in a single +multi-threaded pass. -Yeo-Johnson is the power transform behind scikit-learn's `PowerTransformer`, -used to make skewed features more normally distributed. Its parameter is -fitted per column, so applying it to every column of a feature matrix is -precisely what you want: +Yeo-Johnson — the power transform behind scikit-learn's `PowerTransformer` — +is a good fit: its parameter is fitted per column, so you apply it to every +column of a feature matrix. ```python import numpy as np @@ -44,267 +44,46 @@ def yeo_johnson(col, lam=0.5): result = df.apply(yeo_johnson, engine=blosc2.jit) ``` -`result` is a DataFrame of the same shape and column names as `df`, with the -transform applied to every column — the same thing plain `df.apply(yeo_johnson)` -returns, only computed differently. On the machine below it takes 0.057 s -instead of 0.126 s, a **2.2x** speedup. - -## Why it is faster - -Evaluated by NumPy, that function body is a sequence of separate steps: raise -to a power, subtract, divide, do it again for the negative branch, then select. -Each step walks the full column and allocates a new full-size temporary array -to hold its result. - -Blosc2 does not execute the steps one at a time. It captures the whole -expression first, then makes a single pass over the data, computing every step -on one small piece while that piece is still in cache, and spreading the pieces -across cores. The intermediate arrays are never created. +You get back the same DataFrame plain `df.apply(yeo_johnson)` would return, +computed differently: 0.058 s instead of 0.121 s, a **2.1x** speedup. -## When it pays off +The win comes from not materialising intermediates. NumPy runs that body one +operation at a time, allocating a full-size temporary array at each step. +Blosc2 captures the whole expression first, then makes one pass over the data, +computing every step on a small piece while it is still in cache and spreading +the pieces across cores. -Two things have to be true, and the plot below measures each one on its own. +### When it pays off ![Speedup vs rows and vs number of fused operations](pandas_engine/speedup.png) -**Enough rows.** Setting up the compute engine costs a fixed amount per call. -At a few hundred thousand rows that setup is still larger than anything it -saves, and the engine is a net loss — at 100,000 rows it runs at 0.64x. Break-even -falls between 100,000 and 1,000,000 rows. - -**Enough arithmetic.** The more operations there are to fuse into one pass, the -more temporaries are avoided and the bigger the win — from 2.1x for a single -operation up to 3.6x for five. - -Beyond a few million rows the speedup flattens and then eases off (1.9x at 5M -above): the arrays no longer fit in cache and the whole computation becomes -limited by memory bandwidth, which fusion can reduce but not eliminate. - -## When not to use it - -**Trivial expressions.** Arithmetic intensity matters more than the raw number -of operations. A single cheap operation over a large array is limited by memory -bandwidth, not by computation, so there is nothing for the engine to win back: - -```python -result = df.apply(lambda col: col + 1, engine=blosc2.jit) # 0.53x — slower! -``` - -That runs at roughly **half** the speed of a plain `apply`. Reach for the engine -when the function does real work per element, such as transcendental functions -or several chained operations. - -**Small frames.** See the plot above: below a few hundred thousand rows, use a -plain `apply`. - -## Compared to `pd.eval` and numexpr - -pandas' own [Enhancing performance](https://pandas.pydata.org/docs/user_guide/enhancingperf.html) -guide describes `pd.eval(..., engine="numexpr")`, which fuses expressions using -the same underlying idea. It is worth being straightforward about how the two -compare on the example above: - -| approach | time | vs plain apply | -| --- | --- | --- | -| plain `df.apply(f)` | 0.1258 s | 1.00x | -| `df.apply(f, engine=blosc2.jit)` | 0.0569 s | 2.21x | -| `numexpr.evaluate(...)` per column | 0.0393 s | 3.20x | - -On an in-memory DataFrame, numexpr is somewhat faster. The reason to prefer -`engine=blosc2.jit` is not raw speed but that **you write a Python function -rather than a quoted string**. The numexpr equivalent of `yeo_johnson` has to -become a single expression: - -```python -"where(c >= 0, " -"((maximum(c, 0.0) + 1.0) ** lam - 1.0) / lam, " -"-((maximum(-c, 0.0) + 1.0) ** (2.0 - lam) - 1.0) / (2.0 - lam))" -``` - -The Blosc2 version keeps its intermediate variables and its name, can call -helper functions, can be unit-tested and reused elsewhere under `@blosc2.jit`, -and is checked by your editor and linters. That is the trade being offered. - -Note also that Blosc2's characteristic strength — computing directly over -compressed, potentially larger-than-memory arrays — does not come into play on -this path, because pandas materialises each column as a plain NumPy array -before the engine ever sees it. It does come into play in the row-wise -pattern below: a `@blosc2.jit` function called directly accepts a -`blosc2.NDArray` operand exactly as readily as a DataFrame column. - -## Gotchas - -**`std()` silently changes meaning.** A plain `apply` passes your function a -pandas Series, whose `.std()` defaults to `ddof=1`. The engine passes a NumPy -array, whose `.std()` defaults to `ddof=0`. The same source code therefore -computes slightly different numbers depending on the engine. Pass `ddof` -explicitly if it matters: - -```python -z = (col - col.mean()) / col.std(ddof=0) -``` - -**Your function is inspected once, not run over the values.** This is worth -understanding, because most of the surprises below follow from it. By default -the engine calls your function a single time, passing stand-in objects in place -of the columns. Those stand-ins compute nothing; they just record the operations -you ask for. What comes back is a description of the whole calculation, which -Blosc2 then evaluates over the real data in one fused pass. (The usual name for -this is *tracing*.) Your Python code therefore runs once, at setup — never per -row, which is exactly where the speed comes from. - -The catch is that a plain Python `if` has nothing to look at during that single -call: it is handed the entire column, not one value, so it raises -`ValueError: The truth value of an array with more than one element is -ambiguous`. Branching on a scalar *parameter* is fine, and always was — only -branching on column values is affected. - -**Real per-element `if`/`for`/`while` works too, for a numeric subset of -NumPy.** `engine=blosc2.jit` auto-detects control flow: if your function -branches or loops over column values *and* the function fits Blosc2's -[DSL grammar](../reference/dsl_syntax.md), it is compiled and run as written — -branches and loops behave like real Python — instead of being traced. Both -`np.sin(x)`-style calls and bare `sin(x)`-style calls are accepted (the former -is rewritten to the latter automatically; a handful of NumPy functions the DSL -knows under a different name, like `np.maximum`/`np.minimum`, are translated -too — `maximum`, `minimum` and `absolute` today). Two things to know -before relying on this: - -- If the function doesn't fit the DSL grammar at all (e.g. it uses statements - the grammar doesn't support), it silently falls back to the original - behavior: the function is traced, and branching on array contents raises - `ValueError: The truth value of an array ... is ambiguous`. Use `np.where` - as before, nesting it where you would have used `elif`. -- If the function looks DSL-shaped but calls something outside the DSL's - supported functions (not every NumPy function has a DSL equivalent), - compiling it fails at call time with a `RuntimeError` naming the problem — - a different error than the `ValueError` above, and one that is not silently - swallowed. Check the [DSL syntax reference](../reference/dsl_syntax.md) for - what is actually supported before depending on this path for a given - function. - -This dispatch decision is not configurable through `engine=`: pandas' engine -protocol requires the plain `blosc2.jit` object, so `engine=` always gets the -auto-detect (`strict=None`) behavior described above — there is currently no -way to pass `strict=True`/`strict=False` through this entry point. - -**So why does the example above use `np.where` rather than an `if`?** Mostly -portability, not speed. Written with a real branch, `yeo_johnson` needs no -clamping at all, because only the matching arm runs for each element: - -```python -@blosc2.jit -def yeo_johnson_branch(col): - if col >= 0: - out = (np.power(col + 1.0, 0.5) - 1.0) / 0.5 - else: - out = -(np.power(-col + 1.0, 1.5) - 1.0) / 1.5 - return out - +**Enough rows** (left): setup costs a fixed amount per call, so break-even +falls between 100,000 and 1,000,000 rows. Below that, use a plain `apply`. +Beyond a few million the win eases off — the data no longer fits in cache and +memory bandwidth becomes the limit. -result = df.apply(yeo_johnson_branch, engine=blosc2.jit) -``` - -The `@blosc2.jit` decorator is optional here — `engine=blosc2.jit` compiles the -function either way — but it is harmless, and keeping it means the same -function also works when called directly. - -That is the clearer statement of the transform, and on the same 1,000,000 × 8 -frame it costs about 6%: - -| approach | time | vs plain apply | -| --- | --- | --- | -| per-element Python, real `if` | 1.66 s (measured at 50,000 rows, scaled) | 0.08x | -| plain `df.apply(f)`, `np.where` | 0.1300 s | 1.00x | -| `engine=blosc2.jit`, real `if` (DSL kernel) | 0.0642 s | 2.02x | -| `engine=blosc2.jit`, `np.where` (traced) | 0.0606 s | 2.15x | - -Two things to read off that table. A real `if` executed **by Python**, one value -at a time, is the slow option by a wide margin — 13x slower than a vectorized -`np.where` and about 26x slower than either engine path. That is the version to -avoid, and it is what people usually mean when they say branching is slow. But a -real `if` **compiled to a DSL kernel** is a different thing entirely: it lands -within a few percent of the traced form, because the branch's saved work -(only one arm runs) roughly cancels against the vectorized math the traced form -gets to use. - -The traced `np.where` version stays in the example because it works whatever -your function contains, while the branch version only compiles if the whole -function fits the DSL grammar — note it already had to inline `lam`, since -`apply` passes the column alone. If your function does fit, prefer the branch: -it is easier to read and needs no domain clamping. - -**`np.where` evaluates both arms.** Unlike a real `if`, both branches are -computed over the whole column and only then selected between, so each one runs -on values it was never meant to see. In `yeo_johnson` above, `np.power(col + 1.0, -0.5)` would hit a negative base wherever `col < -1` — around 159,000 elements in -a million-row standard normal — producing NaNs and a -`RuntimeWarning: invalid value encountered in power`. The final answer is still -correct, because `np.where` discards them, but the warning is noise and the work -is wasted. That is why each arm is clamped to its own domain with `np.maximum`. -The same caveat applies to numexpr's `where()`. - -**A reduction used inside per-element control flow does not mean what it -looks like it means, in a DSL kernel.** `sum`, `max`, `min` and friends are -*block* reductions: they collapse the whole chunk being evaluated down to one -value, not one value per row. Writing the array-style idiom - -```python -if max(abs(diff)) < 1e-12: - break -``` +**Enough arithmetic** (right): the more operations there are to fuse, the more +temporaries are skipped — 1.9x for a single operation, 3.5x for five. One +cheap operation over a big array wins nothing at all +(`df.apply(lambda col: col + 1, engine=blosc2.jit)` runs at *half* the speed +of a plain `apply`). Reach for the engine when the function does real work per +element. -inside a `@blosc2.jit` DSL kernel (see the DSL syntax reference and the -row-wise section below) does **not** raise — it compiles and runs, and -silently produces wrong results for every row past the first: only element 0 -of the block receives the reduction's value, so the condition evaluates -against effectively-zero data everywhere else. This is a known rough edge in -the underlying [miniexpr](https://github.com/Blosc/miniexpr) compiler, not -something `blosc2.jit` can validate away today. Write the per-element form -instead — drop the reduction and compare the array directly: +`pd.eval(..., engine="numexpr")` fuses expressions the same way and is +somewhat faster here; the reason to prefer `engine=blosc2.jit` is that you +write a Python function instead of a quoted expression string. See +[Details](#details). -```python -if abs(diff) < 1e-12: - break -``` +## Several columns per row: skip `apply`, pass the columns -## Row-wise computations: pass the columns, skip `apply` +This is the case pandas 3 highlights `engine=` for, and it is where `apply` is +the wrong shape: its contract is one call per row. A `@blosc2.jit` function +takes one parameter per column and is called directly — and since a DataFrame +unpacks into one keyword argument per column, that call is just `f(**df)`. -The previous sections cover `axis=0` — one function call per *column*, which -is where `engine=blosc2.jit` earns its 2-3x. `axis=1` — one call per *row* — -is what pandas 3 actually highlights `engine=` for, via examples like: +Kepler's equation, solved per row by Newton-Raphson: ```python -def add_people(row): - return row["max_people"] + row["max_children"] - - -visits = pd.DataFrame({"max_people": [4, 2, 8], "max_children": [1, 0, 3]}) -visits.apply(add_people, engine=blosc2.jit, axis=1) -``` - -`engine=blosc2.jit` handles this specific shape reasonably well: a function -that only ever combines columns by name (`row["colname"]`, nothing fancier) -is detected and dispatched to one call over whole per-column arrays instead -of pandas' historical per-row Python loop, so it traces to a single fused -expression the same way `axis=0` does. - -But for anything with real per-row iteration — a genuine per-row-convergence -computation, not just combining a few columns — `apply` is the wrong tool -regardless of `engine=`, and `engine=blosc2.jit` raises a clear `TypeError` -rather than attempting it (tracing would otherwise unroll the loop eagerly at -call time, and the traced expression size explodes with each iteration; see -[Gotchas](#gotchas) above for a related, subtler version of the same -`for`/`while`-with-a-reduction interaction). Skip `df.apply(...)` entirely and -call a `@blosc2.jit` function directly, passing the columns as separate array -arguments: - -```python -import numpy as np -import pandas as pd - -import blosc2 - rng = np.random.default_rng(0) orbits = pd.DataFrame( { @@ -314,8 +93,6 @@ orbits = pd.DataFrame( ) -# Eccentric anomaly via Newton-Raphson on Kepler's equation. Note: DSL kernel -# bodies do not support a docstring (or any other string-literal statement). @blosc2.jit def kepler(mean_anomaly, eccentricity): e = mean_anomaly + eccentricity * sin(mean_anomaly) @@ -329,93 +106,136 @@ def kepler(mean_anomaly, eccentricity): return e -result = kepler(orbits["mean_anomaly"], orbits["eccentricity"]) +orbits["E"] = kepler(**orbits) ``` -That's it — no `apply`, no `engine=` keyword. `orbits["colname"]` (a pandas -Series) is accepted directly as a kernel operand, exactly like a NumPy array. +No `apply`, no `engine=`. The parameter names match the column names, so `**` +does the wiring; each column arrives as a pandas Series, which the kernel +accepts like any array (zero-copy for ordinary numeric dtypes). `**df` passes +*every* column, so subset first if the frame has more: +`kepler(**orbits[["mean_anomaly", "eccentricity"]])`. -On 1,000,000 rows this runs **6.2x faster than fully vectorized NumPy**: +On 1,000,000 rows that runs in 0.027 s against 0.165 s for fully vectorized +NumPy — **6.2x** — and about 164x faster per row than a plain +`df.apply(..., axis=1)`. -| approach | time (1M rows) | -| --- | --- | -| vectorized NumPy (whole-array loop) | 0.164 s | -| `kepler(orbits["mean_anomaly"], orbits["eccentricity"])` | 0.026 s | +### Why it beats even vectorized NumPy -A plain Python `df.apply(..., axis=1)` is far enough outside this range that -timing it at 1M rows isn't practical: measured on 2,000 rows it runs at -about 4.4 microseconds/row, versus 0.026 microseconds/row for the direct -call — **around 167x faster per row**, before even accounting for the -per-row work Python duplicates on every call (`sin`/`cos` reimported, -Newton step re-interpreted, ...) that the compiled kernel pays for once. +Vectorized NumPy has to keep looping until the *worst* row converges: the loop +is at the whole-array level, so every row pays for the slowest one. The +kernel's `for`/`if`/`break` compile to a real per-element loop, so each row +stops as soon as *it* converges — and the whole thing still runs as one fused, +multi-threaded pass. -### Why it beats even vectorized NumPy +![Kepler speedup vs rows and vs eccentricity](pandas_engine/kepler.png) + +That explanation predicts the right-hand panel, which varies orbital +eccentricity — i.e. how much harder the worst row is than a typical one. With +near-circular orbits every row converges in the same 3 iterations, there is +nothing for `break` to skip, and the win falls to 2.7x. With near-parabolic +ones the slowest row needs 10 iterations while the average needs 4, and the +win climbs to 7.4x. **The more uneven the work per row, the more this +pattern wins.** The left panel is the familiar row-count story: break-even +around 20,000 rows, 3.5x at 100,000. + +Note that the kernel never learns it was handed a DataFrame. The same call +works with polars, xarray, h5py — or a `blosc2.NDArray`, which is how you +reach compressed, larger-than-memory operands. Only the `**df` spelling is +pandas-specific. + +### If your function has no per-row loop -Vectorized NumPy still has to loop until the *worst* row converges — every -row keeps recomputing `sin`/`cos`/the Newton step for however many iterations -the slowest-converging row needs, because the loop is at the whole-array -level. The DSL kernel's `for`/`if`/`break` compile to a real, independent -per-element loop: each row exits as soon as *it* converges, and the compiled -loop runs as one fused, multi-threaded pass with no NumPy-sized temporaries -in between. See the [DSL syntax reference](../reference/dsl_syntax.md) for -what a kernel body may contain. - -### What this costs - -- `df["colname"]` and `df["colname"].to_numpy()` are a **zero-copy view** for - ordinary numeric dtypes (confirmed via `np.shares_memory`, including - mixed-dtype frames — extracting one column never triggers the whole-frame - upcast that `df.to_numpy()` or `df.values` does, which is what - `engine=blosc2.jit` uses internally for `axis=0`). Each column keeps its - own dtype. -- Nullable (`Float64`/`Int64`), Arrow-backed, and tz-aware columns are not - zero-copy — extracting them allocates a converted array once, which is - noise next to iterative work like the kernel above. - -### This isn't really a pandas feature - -The kernel above takes arrays; a DataFrame column happens to *be* one (via -`__array__`). The same call works unmodified with a polars Series, an xarray -`DataArray`, an h5py dataset slice, or a `blosc2.NDArray` — where compressed -or larger-than-memory operands become relevant, unlike anywhere else on this -page. Treat `engine=blosc2.jit` as the pandas-specific on-ramp for column-wise -work, and a plain `@blosc2.jit` call as the general tool for everything else. - -## When to use which - -| situation | use | -| --- | --- | -| same function applied to every column independently | `df.apply(f, engine=blosc2.jit)` (`axis=0`) | -| a function combining a few named columns, no per-row loop | `df.apply(f, engine=blosc2.jit, axis=1)` — works, but consider the direct call below anyway | -| per-row convergence / iteration (Newton-Raphson class) | call the `@blosc2.jit` function directly with the columns, no `apply` | -| trivial arithmetic, or fewer than ~100,000 rows | plain pandas — neither engine wins here | +`df.apply(f, engine=blosc2.jit, axis=1)` does work for functions that merely +combine columns by name (`row["a"] + row["b"]`): they are dispatched to a +single whole-column call rather than a per-row Python loop. Add a `for` or +`while` to that idiom and it raises a `TypeError` pointing back here — that +shape can only be compiled, not traced. + +## Gotchas + +**Your function normally runs only once.** The engine calls it a single time +with stand-in objects that record operations rather than compute them, then +evaluates the recorded expression over the real data (this is *tracing*). So a +plain `if` on column values has nothing to look at and raises `ValueError: The +truth value of an array ... is ambiguous`. Use `np.where`, or write the +function so it compiles instead — see below. + +**Real `if`/`for`/`while` works, if the function fits the DSL.** When your +function branches or loops over column values *and* fits Blosc2's +[DSL grammar](../reference/dsl_syntax.md), it is compiled and runs as written, +branches and all — that is what makes the Kepler kernel above possible. If it +doesn't fit the grammar, it silently falls back to tracing (hence the +`ValueError`); if it looks DSL-shaped but calls an unsupported function, you +get a `RuntimeError` naming it. + +**`np.where` evaluates both arms.** Both branches are computed over the whole +column before one is selected, so each runs on values it was never meant to +see — negative bases, divisions by zero, `RuntimeWarning`s. The answer is +still correct, but clamp each arm to its own domain (as `yeo_johnson` does +with `np.maximum`) to keep the noise and wasted work away. + +**Don't put a reduction inside per-element control flow.** In a DSL kernel, +`sum`/`max`/`min` collapse the whole block being evaluated to a single value, +not one per row. So `if max(abs(diff)) < 1e-12: break` compiles, runs, and +silently gives wrong results for every row but the first. Write the +per-element form: `if abs(diff) < 1e-12: break`. + +**`std()` changes meaning.** A plain `apply` passes a pandas Series +(`ddof=1`); the engine passes a NumPy array (`ddof=0`). Pass `ddof` explicitly +if it matters. ## Limitations -- Only numeric dtypes are supported. A non-numeric (e.g. object-dtype or - string) column raises a `ValueError` naming the limitation rather than - attempting the computation. -- `na_action="ignore"` is not supported for `map` and raises - `NotImplementedError` — the vectorized-call contract means there is no - per-element step at which to skip a value. -- `Series.apply(func, engine=...)` and `DataFrame.map(func, engine=...)` do - not reach `blosc2.jit` at all: pandas 3's `Series.apply` does not accept - an `engine` keyword for non-string functions, and `DataFrame.map` doesn't - forward `engine` to a dispatch mechanism at all. These are limitations of - the pandas-side API surface, not of the Blosc2 engine. The two entry - points that do reach the engine are `DataFrame.apply` and `Series.map`. - -`Series.map(func, engine=blosc2.jit)` works the same way as `DataFrame.apply`: -`func` is called once with the Series' full underlying array. +- Numeric dtypes only; anything else raises `ValueError`. +- `na_action="ignore"` is not supported for `map` (`NotImplementedError`): + there is no per-element step at which to skip a value. +- Only `DataFrame.apply` and `Series.map` reach the engine. pandas 3's + `Series.apply` doesn't accept `engine=` for non-string functions, and + `DataFrame.map` doesn't forward it — both are pandas-side limits. +- `engine=` always gets auto-detection: pandas' protocol requires the plain + `blosc2.jit` object, so `strict=True`/`strict=False` cannot be passed + through it (a direct call can). + +## Details + +Two things worth knowing once you are actually using this, neither needed to +get started. + +**Against numexpr.** pandas' own +[Enhancing performance](https://pandas.pydata.org/docs/user_guide/enhancingperf.html) +guide describes `pd.eval(..., engine="numexpr")`, which fuses expressions on +the same principle. On the Yeo-Johnson example: + +| approach | time | vs plain apply | +| --- | --- | --- | +| plain `df.apply(f)` | 0.121 s | 1.00x | +| `df.apply(f, engine=blosc2.jit)` | 0.058 s | 2.07x | +| `numexpr.evaluate(...)` per column | 0.039 s | 3.09x | + +numexpr is faster on an in-memory frame, so the trade is not raw speed: its +version of `yeo_johnson` has to collapse into one quoted string, +`"where(c >= 0, ((maximum(c, 0.0) + 1.0) ** lam - 1.0) / lam, ...)"`, while +the Blosc2 version stays a Python function — intermediate variables, a name, +helper calls, unit tests, and your linter. Note also that neither +`engine=blosc2.jit` nor numexpr touches compressed data here: pandas +materialises each column as a plain NumPy array first. Only the direct-call +pattern reaches `blosc2.NDArray` operands. + +**What column extraction costs.** `df["col"]` is a zero-copy view for ordinary +numeric dtypes, including in mixed-dtype frames: pulling out one column never +triggers the whole-frame upcast that `df.to_numpy()` does (which is what +`engine=blosc2.jit` uses internally for `axis=0`), and each column keeps its +own dtype. Nullable (`Float64`/`Int64`), Arrow-backed and tz-aware columns are +the exception — they allocate a converted array once, which is noise next to +iterative work like the Kepler kernel. ## Reproducing these numbers All figures on this page come from `bench/bench_pandas_engine.py`, measured on -an Apple M4 with pandas 3.0.3 and 8 threads. Run it yourself with: +an Apple M4 with pandas 3.0.3 and 8 threads: ``` python bench/bench_pandas_engine.py ``` -It prints the `engine=` comparison table, both sweeps, the row-wise Kepler -table above, and regenerates the plot. +It prints every table on this page and regenerates both plots. diff --git a/doc/guides/pandas_engine/kepler.png b/doc/guides/pandas_engine/kepler.png new file mode 100644 index 0000000000000000000000000000000000000000..74dcdd41397cda5022253e43b5758aa4ca20a9aa GIT binary patch literal 58242 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

#ldD&k9JL5%5Yzo-RU$K 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 b: + out = a - b + else: + out = b - a + return out + + df = pd.DataFrame({"a": [-2.0, 1.0, 3.0], "b": [4.0, -5.0, 6.0]}) + + np.testing.assert_allclose(np.asarray(traced(**df)), np.hypot(df["a"], df["b"])) + np.testing.assert_allclose(np.asarray(dsl(**df)), np.abs(df["a"] - df["b"])) From 815b4db4e1e12375b63210f54f6caaafd8225cc5 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 18:55:27 +0200 Subject: [PATCH 13/14] Align DSL operand grids, so mixed-dtype NDArray inputs work Blocks are sized in bytes, so same-shaped operands of different itemsize get different chunks/blocks (float32 vs int64 over 1M elements: blocks of 31250 vs 15625). validate_inputs then refuses the miniexpr fast path, and a DSL kernel has no slow path to fall back on, so evaluation died with a misleading "slicing a DSL computation is not supported". Whether the grids diverge depends on array size and on the platform's cache detection, which is why CI caught it only on Windows (where platform.machine() is "AMD64", so compute_chunks_blocks' x86_64 branch never runs and blocks stay L1-sized) and WASM: their 10,007-element operands already disagree, while elsewhere both fit a single block. At 1M elements it failed everywhere, macOS included -- hence the regression test sits at that size. LazyUDF now copies mismatched NDArray operands onto the grid of the widest dtype (fewest elements per block, so every operand still fits the cache budget the heuristic aimed at), once at construction rather than per evaluation, and only for DSL kernels. Also trace the JS bridge under BLOSC_ME_JIT_TRACE. It bypasses miniexpr entirely, so it used to report nothing at all, which is what turned the WASM-only mandel dispatch test into a confusing empty-output failure rather than an obvious one. Both engines now trace, and that test asserts on both platforms instead of skipping. Co-Authored-By: Claude Opus 5 --- src/blosc2/lazyexpr.py | 47 +++++++++++++++++++++++++- tests/ndarray/test_dsl_kernels.py | 14 ++++++++ tests/ndarray/test_jit_dsl_dispatch.py | 4 ++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index afb8b2b07..f1955f4fe 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -1495,6 +1495,15 @@ def _js_dtypes_ok(operands, kwargs) -> bool: ) +def _trace_js_backend(expression): + """BLOSC_ME_JIT_TRACE counterpart for the JS bridge, which never reaches + miniexpr's trace point in `fast_eval` (see there for the message format).""" + if os.environ.get("BLOSC_ME_JIT_TRACE", "").lower() in ("1", "true", "on"): + source = getattr(expression, "dsl_source", None) or expression + expr_short = str(source)[:120].replace("\n", " ") + print(f"[blosc2] engine=js expr={expr_short}", flush=True) + + def _maybe_js_backend(expression, jit, jit_backend, reduce_args, operands, kwargs, shape=None): """Resolve the JS backend for a DSL kernel. @@ -1524,7 +1533,9 @@ def _maybe_js_backend(expression, jit, jit_backend, reduce_args, operands, kwarg 'jit_backend="js" requires a floating-point output dtype ' f"(got {np.dtype(out_dtype)}); drop jit_backend to use miniexpr" ) - return _as_js_udf(expression, shape), None, None + bridge = _as_js_udf(expression, shape) + _trace_js_backend(expression) + return bridge, None, None prefer_js = ( jit is not False # jit=True/None prefer the best JIT (js); only jit=False forces interpreter and jit_backend is None @@ -1541,6 +1552,7 @@ def _maybe_js_backend(expression, jit, jit_backend, reduce_args, operands, kwarg bridge = _as_js_udf(expression, shape) # transpiles; raises on any unsupported construct except Exception: return expression, jit, jit_backend # fall back to miniexpr, no regression + _trace_js_backend(expression) return bridge, None, None @@ -4576,12 +4588,45 @@ def _new_expr(cls, expression, operands, guess, out=None, where=None, ne_args=No return new_expr +def _align_dsl_operand_grids(inputs): + """Put every NDArray operand of a DSL kernel on a single chunks/blocks grid. + + Blocks are sized in bytes, so same-shaped operands of different itemsize get + different grids by default (float32 vs int64 over 1M elements: blocks of + 31250 vs 15625 elements). miniexpr needs one common grid, and a DSL kernel + has no slow path to fall back on, so evaluation would fail outright with a + confusing "slicing is not supported" error. Copy the odd operands onto the + grid of the widest dtype: its blocks hold the fewest elements, so every + operand still fits within the cache budget the heuristic aimed at. + + The copies happen once, at construction, and only when the grids actually + disagree -- whether they do depends on the array size and on the platform's + cache detection, which is why this used to fail only on some CI runners. + """ + nd = [x for x in inputs if isinstance(x, blosc2.NDArray) and x.ndim > 0] + if len(nd) < 2 or len({(x.shape, x.chunks, x.blocks) for x in nd}) < 2: + return inputs + ref = max(nd, key=lambda x: x.dtype.itemsize) + aligned = [] + for x in inputs: + misaligned = ( + isinstance(x, blosc2.NDArray) + and x.ndim > 0 + and x.shape == ref.shape + and (x.chunks, x.blocks) != (ref.chunks, ref.blocks) + ) + aligned.append(x.copy(chunks=ref.chunks, blocks=ref.blocks) if misaligned else x) + return aligned + + class LazyUDF(LazyArray): def __init__( self, func, inputs, dtype, shape=None, chunked_eval=True, jit=None, jit_backend=None, **kwargs ): # After this, all the inputs should be np.ndarray or NDArray objects self.inputs = convert_inputs(inputs) + if isinstance(func, DSLKernel): + self.inputs = _align_dsl_operand_grids(self.inputs) # Get res shape if shape is None: self._shape = compute_broadcast_shape(self.inputs) diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 729547892..a001a474e 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -1196,6 +1196,20 @@ def test_dsl_kernel_numpy_operands_mixed_dtype_promotes_output(): np.testing.assert_array_equal(res, ref) +def test_dsl_kernel_ndarray_operands_with_different_itemsize(): + # Blocks are sized in bytes, so a float32 and an int64 operand get different + # chunks/blocks by default; the DSL path has no slow fallback, so it used to + # raise "slicing is not supported" whenever the grids diverged (which depends + # on array size and on the platform's cache detection). + n = 1_000_000 + a = (np.arange(n) % 7).astype(np.float32) + b = (np.arange(n) % 5).astype(np.int64) + A, B = blosc2.asarray(a), blosc2.asarray(b) + assert (A.chunks, A.blocks) != (B.chunks, B.blocks) + res = blosc2.lazyudf(_numpy_operand_kernel, (A, B), dtype=None)[()] + np.testing.assert_array_equal(res, a * 2.0 + b) + + def test_dsl_kernel_mixed_ndarray_and_numpy_operand(): shape = (20, 10) a = np.arange(np.prod(shape), dtype=np.float64).reshape(shape) diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py index d7c7dc075..6a7ba8751 100644 --- a/tests/ndarray/test_jit_dsl_dispatch.py +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -53,7 +53,9 @@ def mandel(cr, ci, max_iter): monkeypatch.setenv("BLOSC_ME_JIT_TRACE", "1") res = mandel(cr, ci, 30) captured = capsys.readouterr() - assert "engine=miniexpr" in captured.out + # Under WebAssembly this kernel is transpiled to the JS bridge instead of + # going through miniexpr (see _maybe_js_backend); both engines trace. + assert f"engine={'js' if blosc2.IS_WASM else 'miniexpr'}" in captured.out assert "def mandel" in captured.out np.testing.assert_array_equal(res, _mandel_numpy(cr, ci, 30)) From dcbdd6fe4409327ae65cae2781873eb680ac21ad Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 25 Jul 2026 19:20:22 +0200 Subject: [PATCH 14/14] Point the "unexpected keyword" error at the kernel(**df) fix Unpacking a frame that carries more columns than the kernel has parameters is the expected way to get here, and the fix -- subset the frame -- was only findable in the guide: TypeError: traced() got an unexpected keyword argument 'note' If you are calling traced(**df), subset the frame to the kernel's parameters: traced(**df[['a', 'b']]) Extra keywords stay an error rather than being filtered to the kernel's parameters: the wrapper cannot tell "wide frame, take what you need" from "this keyword was meant to do something", and silently dropping the latter turns a typo or a stale argument name into wrong numbers. Both jit routes gain the hint, and the DSL route also gains the function name that sig.bind's message never carried. Missing operands are a different mistake and keep their own message. Also replace lazyudf's arity mismatch, which surfaced as a bare "zip() argument 2 is longer than argument 1", with one naming the kernel, its parameters and both counts. Co-Authored-By: Claude Opus 5 --- doc/guides/pandas_engine.md | 5 ++-- src/blosc2/lazyexpr.py | 11 +++++++- src/blosc2/proxy.py | 50 +++++++++++++++++++++++++++++++-- tests/test_pandas_udf_engine.py | 45 +++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/doc/guides/pandas_engine.md b/doc/guides/pandas_engine.md index ca98d6aff..25d90c556 100644 --- a/doc/guides/pandas_engine.md +++ b/doc/guides/pandas_engine.md @@ -110,8 +110,9 @@ orbits["E"] = kepler(**orbits) ``` No `apply`, no `engine=`. The parameter names match the column names, so `**` -does the wiring; each column arrives as a pandas Series, which the kernel -accepts like any array (zero-copy for ordinary numeric dtypes). `**df` passes +does the wiring — by name, so the column order in the frame is irrelevant. +Each column arrives as a pandas Series, which the kernel accepts like any +array (zero-copy for ordinary numeric dtypes). `**df` passes *every* column, so subset first if the frame has more: `kepler(**orbits[["mean_anomaly", "eccentricity"]])`. diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index f1955f4fe..515c7cd4e 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -4665,7 +4665,16 @@ def __init__( # DSL kernels are using input names that are extracted from params as a list, # and we need to use them for matching variables in miniexpr # (instead of the 'o{%d}' notation). - self.inputs_dict = dict(zip(self.func.input_names, self.inputs, strict=True)) + names = self.func.input_names + if len(names) != len(self.inputs): + # Otherwise this surfaces as a bare "zip() argument 2 is longer + # than argument 1", which names neither the kernel nor the counts. + udf_name = getattr(self.func.func, "__name__", self.func.__name__) + raise ValueError( + f"DSL kernel {udf_name!r} takes {len(names)} operand(s) " + f"({', '.join(names)}), but {len(self.inputs)} were passed." + ) + self.inputs_dict = dict(zip(names, self.inputs, strict=True)) else: self.inputs_dict = {f"o{i}": obj for i, obj in enumerate(self.inputs)} diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index f2afa6da3..a827f00b1 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -876,6 +876,34 @@ def _has_control_flow(source: str | None) -> bool: return any(isinstance(node, ast.If | ast.For | ast.While) for node in ast.walk(tree)) +def _wide_frame_hint(err: BaseException, func_name: str, params) -> str | None: + """Guidance to append when a call gets a keyword the function doesn't take. + + The usual cause is the `kernel(**df)` idiom (see doc/guides/pandas_engine.md) + against a frame carrying more columns than the kernel has parameters. Extra + keywords are rejected rather than dropped, so that a keyword meant to do + something -- a typo, a stale argument name -- never goes silently unused. + """ + if not isinstance(err, TypeError) or "unexpected keyword argument" not in str(err): + return None + params = list(params) + if not params: + return None + cols = ", ".join(repr(p) for p in params) + return ( + f"If you are calling {func_name}(**df), subset the frame to the " + f"kernel's parameters: {func_name}(**df[[{cols}]])" + ) + + +def _signature_params(func) -> list: + """Parameter names of *func*, or an empty list if it cannot be introspected.""" + try: + return list(inspect.signature(func).parameters) + except (TypeError, ValueError): + return [] + + def _jit_dsl_wrapper(kernel: DSLKernel, out, decorator_kwargs: dict): """Build the call wrapper for the DSL (control-flow) dispatch route of `jit`. @@ -889,7 +917,13 @@ def dsl_wrapper(*args, **func_kwargs): sig = kernel._sig if sig is None: raise TypeError(f"@blosc2.jit: cannot introspect the signature of {kernel.__name__!r}") - bound = sig.bind(*args, **func_kwargs) + try: + bound = sig.bind(*args, **func_kwargs) + except TypeError as e: + # sig.bind's message names no function; prefix it, and point at the + # subsetting fix when a wide DataFrame was unpacked into the call. + hint = _wide_frame_hint(e, kernel.__name__, kernel.input_names or sig.parameters) + raise TypeError(f"{kernel.__name__}() {e}" + (f"\n{hint}" if hint else "")) from None bound.apply_defaults() values = tuple(bound.arguments[name] for name in kernel.input_names) # Accept array-protocol operands (pandas Series, polars Series, ...) the @@ -1137,8 +1171,18 @@ def wrapper(*args, **func_kwargs): try: retval = func(*new_args, **func_kwargs) except Exception as e: - if _trace_hint is not None: - raise type(e)(f"{e}\n{_trace_hint}") from e + hints = [ + hint + for hint in ( + _wide_frame_hint( + e, getattr(func, "__name__", "the function"), _signature_params(func) + ), + _trace_hint, + ) + if hint is not None + ] + if hints: + raise type(e)("\n".join([str(e), *hints])) from e raise # Treat return value diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 39b8c07cd..f9df6bc16 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -325,10 +325,35 @@ def not_dsl(col): def test_columns_by_keyword_unpacking(self): # doc/guides/pandas_engine.md's row-wise pattern: a DataFrame is a # mapping of column name to Series, so `kernel(**df)` passes each - # column as a keyword argument. Both jit routes must accept that. + # column as a keyword argument. Both jit routes must accept that, and + # bind by name: the columns below are in neither the parameter order + # nor alphabetical order, and the operations are asymmetric, so a + # positional binding would give a different (wrong) answer. @blosc2.jit def traced(a, b): - return np.sqrt(a * a + b * b) + return b - a * 2.0 + + @blosc2.jit + def dsl(a, b): + if a > b: + out = a - b + else: + out = b - a * 2.0 + return out + + df = pd.DataFrame({"b": [4.0, -5.0, 6.0], "a": [-2.0, 1.0, 3.0]}) + assert list(df.columns) == ["b", "a"] + + np.testing.assert_allclose(np.asarray(traced(**df)), np.asarray(traced(df["a"], df["b"]))) + np.testing.assert_allclose(np.asarray(traced(**df)), df["b"] - df["a"] * 2.0) + np.testing.assert_allclose(np.asarray(dsl(**df)), np.asarray(dsl(df["a"], df["b"]))) + + def test_wide_frame_kwargs_error_names_the_fix(self): + # Extra columns are rejected, not dropped: a keyword that goes nowhere + # would otherwise fail silently. The message must name the subsetting fix. + @blosc2.jit + def traced(a, b): + return a + b @blosc2.jit def dsl(a, b): @@ -338,7 +363,17 @@ def dsl(a, b): out = b - a return out - df = pd.DataFrame({"a": [-2.0, 1.0, 3.0], "b": [4.0, -5.0, 6.0]}) + df = pd.DataFrame({"a": [1.0, 2.0], "b": [4.0, 5.0], "note": [7.0, 8.0]}) + + # (`func.__name__` is the jit wrapper's; the message uses the kernel's) + for name, func in (("traced", traced), ("dsl", dsl)): + with pytest.raises(TypeError) as excinfo: + func(**df) + message = str(excinfo.value) + assert name in message + assert "'note'" in message + assert "**df[['a', 'b']]" in message - np.testing.assert_allclose(np.asarray(traced(**df)), np.hypot(df["a"], df["b"])) - np.testing.assert_allclose(np.asarray(dsl(**df)), np.abs(df["a"] - df["b"])) + # A missing operand is a different mistake and keeps its own message + with pytest.raises(TypeError, match="missing a required argument"): + dsl(a=df["a"])