From d3862915658d2c43d0a24be28edd3a302aff75a4 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sun, 15 Feb 2026 11:06:13 +0100 Subject: [PATCH 01/13] dsl_kernel wrapper does not reduce kernels to simple expressions anymore --- src/blosc2/dsl_kernel.py | 30 +++++---------------- tests/ndarray/test_dsl_kernels.py | 43 ++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 9c1cd2f8..6ace37af 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -141,31 +141,13 @@ def _extract_dsl(self, func): if func_node is None: raise ValueError("No function definition found for DSL extraction") - dsl_source_full = None - if _PRINT_DSL_KERNEL: - try: - dsl_source_full = _DSLBuilder().build(func_node) - func_name = getattr(func, "__name__", "") - print(f"[DSLKernel:{func_name}] dsl_source (full):") - print(dsl_source_full[0]) - except Exception as exc: - func_name = getattr(func, "__name__", "") - print(f"[DSLKernel:{func_name}] dsl_source (full) failed: {exc}") - - reducer = _DSLReducer() - reduced = reducer.reduce(func_node) - if reduced is not None: - if _PRINT_DSL_KERNEL: - func_name = getattr(func, "__name__", "") - print(f"[DSLKernel:{func_name}] reduced_expr:") - print(reduced[0]) - return reduced - - if dsl_source_full is not None: - return dsl_source_full - builder = _DSLBuilder() - return builder.build(func_node) + dsl_source, input_names = builder.build(func_node) + if _PRINT_DSL_KERNEL: + func_name = getattr(func, "__name__", "") + print(f"[DSLKernel:{func_name}] dsl_source (full):") + print(dsl_source) + return dsl_source, input_names def __call__(self, inputs_tuple, output, offset=None): if self._legacy_udf_signature: diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 38b3db46..534e907e 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -112,9 +112,14 @@ def kernel_fallback_tuple_assign(x, y): return lhs + rhs -def test_dsl_kernel_reduced_expr(): +@blosc2.dsl_kernel +def kernel_index_ramp(x): + return _i0 * _n1 + _i1 # noqa: F821 # DSL index/shape symbols resolved by miniexpr + + +def test_dsl_kernel_loop_kept_as_full_dsl_function(): assert kernel_loop.dsl_source is not None - assert "def " not in kernel_loop.dsl_source + assert "def kernel_loop(x, y):" in kernel_loop.dsl_source assert kernel_loop.input_names == ["x", "y"] a, b, a2, b2 = _make_arrays() @@ -125,9 +130,9 @@ def test_dsl_kernel_reduced_expr(): np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) -def test_dsl_kernel_integer_ops_reduced_expr(): +def test_dsl_kernel_integer_ops_kept_as_full_dsl_function(): assert kernel_integer_ops.dsl_source is not None - assert "def " not in kernel_integer_ops.dsl_source + assert "def kernel_integer_ops(x, y):" in kernel_integer_ops.dsl_source assert kernel_integer_ops.input_names == ["x", "y"] a, b, a2, b2 = _make_int_arrays() @@ -144,6 +149,36 @@ def test_dsl_kernel_integer_ops_reduced_expr(): np.testing.assert_equal(res[...], expected) +def test_dsl_kernel_index_symbols_keep_full_kernel(monkeypatch): + if blosc2.IS_WASM: + pytest.skip("miniexpr fast path is not available on WASM") + + assert kernel_index_ramp.dsl_source is not None + assert "def kernel_index_ramp(x):" in kernel_index_ramp.dsl_source + + original_set_pref_expr = blosc2.NDArray._set_pref_expr + captured = {"calls": 0, "expr": None} + + def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): + captured["calls"] += 1 + captured["expr"] = expression.decode("utf-8") if isinstance(expression, bytes) else expression + return original_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc, jit=jit) + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_expr", wrapped_set_pref_expr) + + shape = (10, 10) + x2 = blosc2.zeros(shape, dtype=np.float32) + expr = blosc2.lazyudf(kernel_index_ramp, (x2,), dtype=np.float32) + res = expr[:] + + assert captured["calls"] >= 1 + assert "def kernel_index_ramp(x):" in captured["expr"] + assert "_i0" in captured["expr"] + assert "_n1" in captured["expr"] + assert "_i1" in captured["expr"] + assert res.shape == shape + + def test_dsl_kernel_full_control_flow_kept_as_dsl_function(): assert kernel_control_flow_full.dsl_source is not None assert "def kernel_control_flow_full(x, y):" in kernel_control_flow_full.dsl_source From 6c946668c17aae71398e6fdd98b2106aec66de3a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 04:41:16 +0100 Subject: [PATCH 02/13] dsl_kernel respects user's comments and blanks in kernels now --- src/blosc2/dsl_kernel.py | 181 +++++++++++++++++++++++++----- tests/ndarray/test_dsl_kernels.py | 13 ++- 2 files changed, 159 insertions(+), 35 deletions(-) diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 6ace37af..28639e71 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -12,6 +12,8 @@ import inspect import os import textwrap +import tokenize +from io import StringIO from typing import ClassVar _PRINT_DSL_KERNEL = os.environ.get("PRINT_DSL_KERNEL", "").strip().lower() @@ -30,28 +32,129 @@ def _normalize_miniexpr_scalar(value): raise TypeError("Unsupported scalar type for miniexpr specialization") -class _MiniexprScalarSpecializer(ast.NodeTransformer): - def __init__(self, replacements: dict[str, int | float]): - self.replacements = replacements +def _line_starts(text: str) -> list[int]: + starts = [0] + for i, ch in enumerate(text): + if ch == "\n": + starts.append(i + 1) + return starts - def visit_Name(self, node): - if isinstance(node.ctx, ast.Load) and node.id in self.replacements: - return ast.copy_location(ast.Constant(value=self.replacements[node.id]), node) - return node - def visit_Call(self, node): - node = self.generic_visit(node) - if ( - isinstance(node.func, ast.Name) - and node.func.id in {"float", "int"} - and len(node.args) == 1 - and not node.keywords - and isinstance(node.args[0], ast.Constant) - and isinstance(node.args[0].value, int | float | bool) - ): - folded = float(node.args[0].value) if node.func.id == "float" else int(node.args[0].value) - return ast.copy_location(ast.Constant(value=folded), node) - return node +def _to_abs(line_starts: list[int], line: int, col: int) -> int: + return line_starts[line - 1] + col + + +def _find_def_signature_span(text: str): + tokens = list(tokenize.generate_tokens(StringIO(text).readline)) + for i, tok in enumerate(tokens): + if tok.type != tokenize.NAME or tok.string != "def": + continue + lparen = None + rparen = None + colon = None + depth = 0 + for j in range(i + 1, len(tokens)): + t = tokens[j] + if lparen is None: + if t.type == tokenize.OP and t.string == "(": + lparen = t + depth = 1 + continue + if t.type == tokenize.OP and t.string == "(": + depth += 1 + continue + if t.type == tokenize.OP and t.string == ")": + depth -= 1 + if depth == 0: + rparen = t + continue + if rparen is not None and t.type == tokenize.OP and t.string == ":": + colon = t + break + if lparen is not None and rparen is not None: + return lparen, rparen, colon + return None, None, None + + +def _remove_scalar_params_preserving_source(text: str, scalar_replacements: dict[str, int | float]): + if not scalar_replacements: + return text, 0 + + lparen, rparen, colon = _find_def_signature_span(text) + if lparen is None or rparen is None: + return text, 0 + + try: + tree = ast.parse(text) + except Exception: + return text, 0 + + func = next((n for n in tree.body if isinstance(n, ast.FunctionDef)), None) + if func is None: + return text, 0 + + kept = [a.arg for a in (func.args.posonlyargs + func.args.args) if a.arg not in scalar_replacements] + line_starts = _line_starts(text) + pstart = _to_abs(line_starts, lparen.end[0], lparen.end[1]) + pend = _to_abs(line_starts, rparen.start[0], rparen.start[1]) + updated = f"{text[:pstart]}{', '.join(kept)}{text[pend:]}" + body_start = 0 + if colon is not None: + body_start = _to_abs(_line_starts(updated), colon.end[0], colon.end[1]) + return updated, body_start + + +def _replace_scalar_names_preserving_source( + text: str, scalar_replacements: dict[str, int | float], body_start: int +): + if not scalar_replacements: + return text + + line_starts = _line_starts(text) + tokens = list(tokenize.generate_tokens(StringIO(text).readline)) + significant = { + tokenize.NAME, + tokenize.NUMBER, + tokenize.STRING, + tokenize.OP, + tokenize.INDENT, + tokenize.DEDENT, + } + assign_ops = {"=", "+=", "-=", "*=", "/=", "//=", "%=", "&=", "|=", "^=", "<<=", ">>=", ":="} + edits = [] + for i, tok in enumerate(tokens): + if tok.type != tokenize.NAME or tok.string not in scalar_replacements: + continue + start_abs = _to_abs(line_starts, tok.start[0], tok.start[1]) + if start_abs < body_start: + continue + + prev_sig = None + for j in range(i - 1, -1, -1): + if tokens[j].type in significant: + prev_sig = tokens[j] + break + if prev_sig is not None and prev_sig.type == tokenize.OP and prev_sig.string == ".": + continue + + next_sig = None + for j in range(i + 1, len(tokens)): + if tokens[j].type in significant: + next_sig = tokens[j] + break + if next_sig is not None and next_sig.type == tokenize.OP and next_sig.string in assign_ops: + continue + + end_abs = _to_abs(line_starts, tok.end[0], tok.end[1]) + edits.append((start_abs, end_abs, repr(scalar_replacements[tok.string]))) + + if not edits: + return text + + out = text + for start, end, repl in sorted(edits, key=lambda e: e[0], reverse=True): + out = f"{out[:start]}{repl}{out[end:]}" + return out def specialize_miniexpr_inputs(expr_string: str, operands: dict): @@ -73,14 +176,9 @@ def specialize_miniexpr_inputs(expr_string: str, operands: dict): if not scalar_replacements: return expr_string, operands - tree = ast.parse(expr_string) - tree = _MiniexprScalarSpecializer(scalar_replacements).visit(tree) - for node in tree.body: - if isinstance(node, ast.FunctionDef): - node.args.posonlyargs = [a for a in node.args.posonlyargs if a.arg not in scalar_replacements] - node.args.args = [a for a in node.args.args if a.arg not in scalar_replacements] - ast.fix_missing_locations(tree) - return ast.unparse(tree), array_operands + rewritten, body_start = _remove_scalar_params_preserving_source(expr_string, scalar_replacements) + rewritten = _replace_scalar_names_preserving_source(rewritten, scalar_replacements, body_start) + return rewritten, array_operands def specialize_dsl_miniexpr_inputs(expr_string: str, operands: dict): @@ -141,14 +239,37 @@ def _extract_dsl(self, func): if func_node is None: raise ValueError("No function definition found for DSL extraction") - builder = _DSLBuilder() - dsl_source, input_names = builder.build(func_node) + dsl_source = self._slice_function_source(source, func_node) + input_names = self._input_names_from_signature(func_node) if _PRINT_DSL_KERNEL: func_name = getattr(func, "__name__", "") print(f"[DSLKernel:{func_name}] dsl_source (full):") print(dsl_source) return dsl_source, input_names + @staticmethod + def _slice_function_source(source: str, func_node: ast.FunctionDef) -> str: + lines = source.splitlines() + start = func_node.lineno - 1 + end_lineno = getattr(func_node, "end_lineno", None) + if end_lineno is None: + end = len(lines) + else: + end = end_lineno + return "\n".join(lines[start:end]) + + @staticmethod + 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") + names = [a.arg for a in (args.posonlyargs + args.args)] + if not names: + raise ValueError("DSL kernel must accept at least one argument") + return names + def __call__(self, inputs_tuple, output, offset=None): if self._legacy_udf_signature: return self.func(inputs_tuple, output, offset) diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 534e907e..2e0a756d 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -86,6 +86,7 @@ def kernel_while_full(x, y): @blosc2.dsl_kernel def kernel_loop_param(x, y, niter): acc = x + # loop count comes from scalar niter for _i in range(niter): acc = np.where(acc < y, acc + 1, acc - 1) return acc @@ -183,10 +184,10 @@ def test_dsl_kernel_full_control_flow_kept_as_dsl_function(): assert kernel_control_flow_full.dsl_source is not None assert "def kernel_control_flow_full(x, y):" in kernel_control_flow_full.dsl_source assert "for i in range(4):" in kernel_control_flow_full.dsl_source - assert "elif (i == 1):" in kernel_control_flow_full.dsl_source + assert "if i == 1:" in kernel_control_flow_full.dsl_source assert "continue" in kernel_control_flow_full.dsl_source assert "break" in kernel_control_flow_full.dsl_source - assert "where(" in kernel_control_flow_full.dsl_source + assert "np.where(" in kernel_control_flow_full.dsl_source a, b, a2, b2 = _make_arrays() expr = blosc2.lazyudf( @@ -205,7 +206,7 @@ def test_dsl_kernel_full_control_flow_kept_as_dsl_function(): def test_dsl_kernel_while_kept_as_dsl_function(): assert kernel_while_full.dsl_source is not None assert "def kernel_while_full(x, y):" in kernel_while_full.dsl_source - assert "while (i < 3):" in kernel_while_full.dsl_source + assert "while i < 3:" in kernel_while_full.dsl_source a, b, a2, b2 = _make_arrays() expr = blosc2.lazyudf( @@ -280,6 +281,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, assert "def kernel_loop_param(x, y):" in captured["expr"] assert "for it in range(3):" not in captured["expr"] assert "for _i in range(3):" in captured["expr"] + assert "# loop count comes from scalar niter" in captured["expr"] assert "range(niter)" not in captured["expr"] assert "float(niter)" not in captured["expr"] finally: @@ -338,8 +340,9 @@ def test_jit_backend_pragma_wrapping_dsl_source(): ], ) def test_dsl_kernel_flawed_syntax_detected_fallback_callable(kernel): - assert kernel.dsl_source is None - assert kernel.input_names is None + assert kernel.dsl_source is not None + assert kernel.dsl_source.startswith("def ") + assert kernel.input_names == ["x", "y"] a, b, a2, b2 = _make_arrays() expr = blosc2.lazyudf( From a5ab2e48fc5babdc384bc82244298bcde077f785 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 04:59:16 +0100 Subject: [PATCH 03/13] First step in implementing the DSL checker --- plans/phase0-t1-dsl-syntax-inventory.md | 294 ++++++++++++++++++++++++ src/blosc2/dsl_kernel.py | 223 +++++++++++++++++- src/blosc2/lazyexpr.py | 8 +- tests/ndarray/test_dsl_kernels.py | 41 ++-- 4 files changed, 549 insertions(+), 17 deletions(-) create mode 100644 plans/phase0-t1-dsl-syntax-inventory.md diff --git a/plans/phase0-t1-dsl-syntax-inventory.md b/plans/phase0-t1-dsl-syntax-inventory.md new file mode 100644 index 00000000..064341cc --- /dev/null +++ b/plans/phase0-t1-dsl-syntax-inventory.md @@ -0,0 +1,294 @@ +# Phase 0 / T1: miniexpr DSL syntax inventory (source-anchored) + +This is the **ground-truth inventory** from current `../miniexpr` sources/tests. +It is intended to drive `P0-T2` (`../miniexpr/doc/dsl-syntax.md`) and later Python-side validation. + +## 1. Top-level program shape + +- Exactly one top-level function definition is expected. +- Leading blank/comment lines are allowed. +- Anything after the function body is rejected. + +References: +- `../miniexpr/src/dsl_parser.c:1531` +- `../miniexpr/src/dsl_parser.c:1548` +- `../miniexpr/src/dsl_parser.c:1553` + +## 2. Pragmas + +Supported header pragmas: +- `# me:fp=` +- `# me:compiler=` + +Behavior: +- duplicates are rejected (`duplicate me:fp pragma`, `duplicate me:compiler pragma`) +- unknown `me:*` pragma is rejected +- malformed assignments/trailing content are rejected + +References: +- `../miniexpr/src/dsl_parser.c:247` +- `../miniexpr/src/dsl_parser.c:312` +- `../miniexpr/src/dsl_parser.c:373` +- `../miniexpr/src/dsl_parser.c:411` +- `../miniexpr/src/dsl_parser.c:423` +- `../miniexpr/src/dsl_parser.c:435` +- `../miniexpr/tests/test_dsl_syntax.c:1203` +- `../miniexpr/tests/test_dsl_syntax.c:1388` + +## 3. Statement kinds (parser enum) + +Recognized statement kinds: +- assignment +- expression statement +- return +- print +- if/elif/else +- while +- for +- break +- continue + +References: +- `../miniexpr/src/dsl_parser.h:16` +- `../miniexpr/src/dsl_parser.c:1269` + +## 4. Function header and parameters + +Function header rules: +- must start with `def` +- requires function name +- requires `(...)` parameter list +- requires trailing `:` +- duplicate parameter names rejected + +References: +- `../miniexpr/src/dsl_parser.c:1461` +- `../miniexpr/src/dsl_parser.c:1491` +- `../miniexpr/src/dsl_parser.c:1510` +- `../miniexpr/src/dsl_parser.c:1520` +- `../miniexpr/src/dsl_parser.c:1429` +- `../miniexpr/src/dsl_parser.c:1445` + +## 5. Blocks and indentation + +Python-like indentation is enforced: +- block must be indented after `:` +- dedent ends block +- blank/comment-only lines are allowed in blocks + +References: +- `../miniexpr/src/dsl_parser.c:808` +- `../miniexpr/src/dsl_parser.c:813` +- `../miniexpr/src/dsl_parser.c:1360` +- `../miniexpr/src/dsl_parser.c:1401` + +## 6. Control flow forms + +### 6.1 if/elif/else +- supports `if`, chained `elif`, optional `else` +- `elif` after `else` is rejected +- duplicate `else` is rejected +- stray `elif`/`else` rejected + +References: +- `../miniexpr/src/dsl_parser.c:852` +- `../miniexpr/src/dsl_parser.c:914` +- `../miniexpr/src/dsl_parser.c:942` +- `../miniexpr/src/dsl_parser.c:1297` +- `../miniexpr/src/dsl_parser.c:1301` + +### 6.2 while +- `while :` supported +- body required/indented +- runtime loop-iteration cap exists (`ME_DSL_WHILE_MAX_ITERS`) + +References: +- `../miniexpr/src/dsl_parser.c:974` +- `../miniexpr/src/miniexpr.c:2745` +- `../miniexpr/src/miniexpr.c:8621` + +### 6.3 for +Only this form is accepted: +- `for in range(...):` + +`range` arity at compile-time: +- 1 arg: `range(stop)` +- 2 args: `range(start, stop)` +- 3 args: `range(start, stop, step)` +- other arities rejected + +Runtime: +- `step == 0` is runtime eval error + +References: +- `../miniexpr/src/dsl_parser.c:1005` +- `../miniexpr/src/dsl_parser.c:1027` +- `../miniexpr/src/dsl_parser.c:1044` +- `../miniexpr/src/miniexpr.c:3638` +- `../miniexpr/src/miniexpr.c:3652` +- `../miniexpr/src/miniexpr.c:8747` +- `../miniexpr/tests/test_dsl_syntax.c:180` + +### 6.4 break/continue +- only valid inside loops +- deprecated `break if ...` / `continue if ...` explicitly rejected + +References: +- `../miniexpr/src/dsl_parser.c:717` +- `../miniexpr/src/dsl_parser.c:726` +- `../miniexpr/src/dsl_parser.c:733` +- `../miniexpr/src/miniexpr.c:7153` +- `../miniexpr/tests/test_dsl_syntax.c:472` + +## 7. Assignments + +Supported syntactic forms: +- `x = expr` +- `x += expr` +- `x -= expr` +- `x *= expr` +- `x /= expr` +- `x //= expr` + +Desugaring: +- `//=` becomes `floor(lhs / (rhs))` + +References: +- `../miniexpr/src/dsl_parser.c:1100` +- `../miniexpr/src/dsl_parser.c:1138` +- `../miniexpr/src/dsl_parser.c:1152` +- `../miniexpr/src/dsl_parser.c:1164` +- `../miniexpr/src/dsl_parser.c:214` +- `../miniexpr/tests/test_dsl_syntax.c:1604` + +## 8. print statement + +Parser recognizes `print(...)` as dedicated statement. +Compiler rules: +- at least one argument +- optional first string-format argument +- placeholder count must match supplied value args +- print args must be uniform expressions + +References: +- `../miniexpr/src/dsl_parser.c:1245` +- `../miniexpr/src/dsl_parser.c:1305` +- `../miniexpr/src/miniexpr.c:6878` +- `../miniexpr/src/miniexpr.c:6932` +- `../miniexpr/src/miniexpr.c:6979` +- `../miniexpr/src/miniexpr.c:7026` +- `../miniexpr/tests/test_dsl_syntax.c:1451` + +## 9. Expressions: parser vs compiler responsibilities + +Parser-side expression handling is intentionally shallow: +- captures text until end-of-statement with balanced parentheses and string checks +- does **not** parse Python expression grammar deeply at DSL-parser level + +Compilation/evaluation is delegated to miniexpr expression compiler (`private_compile_ex`) plus DSL semantic checks. + +References: +- `../miniexpr/src/dsl_parser.c:575` +- `../miniexpr/src/dsl_parser.c:621` +- `../miniexpr/src/dsl_parser.c:633` +- `../miniexpr/src/miniexpr.c:3358` +- `../miniexpr/src/miniexpr.c:3429` + +## 10. Reserved identifiers and ND symbols + +Reserved names rejected for user vars/functions: +- `print`, `int`, `float`, `bool`, `def`, `return`, `_ndim`, `_i`, `_n` + +ND reserved symbol handling: +- `_i0.._iN`, `_n0.._nN`, `_ndim` scanned and injected as synthetic vars when used. + +References: +- `../miniexpr/src/miniexpr.c:546` +- `../miniexpr/src/miniexpr.c:602` +- `../miniexpr/src/miniexpr.c:7422` +- `../miniexpr/src/miniexpr.c:7431` +- `../miniexpr/src/miniexpr.c:7462` +- `../miniexpr/tests/test_dsl_syntax.c:855` + +## 11. Cast intrinsics (current explicit support) + +Supported intrinsics: +- `int(expr)` +- `float(expr)` +- `bool(expr)` + +Validation: +- must be called form +- exactly one argument +- bad arity rejected + +References: +- `../miniexpr/src/miniexpr.c:568` +- `../miniexpr/src/miniexpr.c:654` +- `../miniexpr/src/miniexpr.c:660` +- `../miniexpr/src/miniexpr.c:764` +- `../miniexpr/src/miniexpr.c:3377` +- `../miniexpr/tests/test_dsl_syntax.c:1485` +- `../miniexpr/tests/test_nd.c:116` + +## 12. Signature and variable binding constraints + +Compile-time constraints: +- DSL function parameters must match provided variable entries by name (set equality; order can differ) +- param count mismatch rejected +- duplicate/conflicting variable/function names rejected + +References: +- `../miniexpr/src/miniexpr.c:7247` +- `../miniexpr/src/miniexpr.c:7268` +- `../miniexpr/src/miniexpr.c:7386` +- `../miniexpr/src/miniexpr.c:7395` +- `../miniexpr/tests/test_dsl_syntax.c:503` + +## 13. Return semantics and dtype consistency + +Compile-time: +- at least one return expression must be compilable +- all return paths that do return must share dtype + +Runtime: +- non-guaranteed-return programs can compile, but missing return at runtime yields eval error + +References: +- `../miniexpr/src/miniexpr.c:6857` +- `../miniexpr/src/miniexpr.c:6869` +- `../miniexpr/src/miniexpr.c:7497` +- `../miniexpr/tests/test_dsl_syntax.c:494` +- `../miniexpr/tests/test_dsl_syntax.c:552` + +## 14. DSL detection and compile error mapping + +- DSL candidate detection is heuristic (`dsl_is_candidate`) +- If parsed/treated as DSL and compile fails, compile API returns parse error with offset + +References: +- `../miniexpr/src/miniexpr.c:2790` +- `../miniexpr/src/miniexpr.c:7534` +- `../miniexpr/src/miniexpr.c:7573` +- `../miniexpr/src/miniexpr.h:234` + +## 15. Known unsupported / risky constructs (current behavior) + +These are important for a Python-side validator because DSL parser accepts expression text broadly: + +- Python expression forms not representable in miniexpr grammar (example: ternary `a if c else b`) are not blocked at DSL-parser level and rely on downstream expression compile behavior. +- Unsupported/unknown function calls in expressions are also largely deferred to expression compilation. +- Current user-facing diagnostics in Python can be poor if failures happen late; this matches the need for preflight syntax checks in `dsl_kernel.py`. + +Evidence: +- parser stores expression text opaquely: `../miniexpr/src/dsl_parser.c:575` +- compile delegation: `../miniexpr/src/miniexpr.c:3429` +- DSL parse/compile failure path in compile API: `../miniexpr/src/miniexpr.c:7573` + +## 16. Notes for P0-T2 doc authoring + +When moving this into `../miniexpr/doc/dsl-syntax.md`, keep two explicit tables: +- **Syntax rejection (parse/compile time)** +- **Runtime semantic errors** (e.g., zero `range` step, missing return path) + +This distinction is already visible in tests and should remain explicit. diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 28639e71..2ae46607 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -20,6 +20,10 @@ _PRINT_DSL_KERNEL = _PRINT_DSL_KERNEL not in ("", "0", "false", "no", "off") +class DSLSyntaxError(ValueError): + """Raised when a @dsl_kernel function uses unsupported DSL syntax.""" + + def _normalize_miniexpr_scalar(value): # NumPy scalar-like values expose .item(); plain Python scalars do not. if hasattr(value, "item") and callable(value.item): @@ -186,6 +190,209 @@ def specialize_dsl_miniexpr_inputs(expr_string: str, operands: dict): return specialize_miniexpr_inputs(expr_string, operands) +class _DSLValidator: + _binop_map: ClassVar[dict[type[ast.operator], str]] = { + ast.Add: "+", + ast.Sub: "-", + ast.Mult: "*", + ast.Div: "/", + ast.FloorDiv: "//", + ast.Mod: "%", + ast.Pow: "**", + ast.BitAnd: "&", + ast.BitOr: "|", + ast.BitXor: "^", + ast.LShift: "<<", + ast.RShift: ">>", + } + _cmp_map: ClassVar[dict[type[ast.cmpop], str]] = { + ast.Eq: "==", + ast.NotEq: "!=", + ast.Lt: "<", + ast.LtE: "<=", + ast.Gt: ">", + ast.GtE: ">=", + } + + def __init__(self, source: str, line_base: int = 0): + self._source = source + self._line_base = line_base + + def validate(self, func_node: ast.FunctionDef): + self._args(func_node.args) + if not func_node.body: + self._err(func_node, "DSL kernel must have a body") + for stmt in func_node.body: + self._stmt(stmt) + + def _err(self, node: ast.AST, msg: str, *, line: int | None = None, col: int | None = None): + if line is None: + line = getattr(node, "lineno", 0) + if col is None: + col = getattr(node, "col_offset", 0) + 1 + line -= self._line_base + location = f"{msg} at line {line}, column {col}" + dump = self._format_source_with_pointer(line, col) + raise DSLSyntaxError(f"{location}\n\nDSL kernel source:\n{dump}") + + def _format_source_with_pointer(self, line: int, col: int) -> str: + lines = self._source.splitlines() + if not lines: + return "" + width = len(str(len(lines))) + out = [] + for lineno, text in enumerate(lines, start=1): + out.append(f"{lineno:>{width}} | {text}") + if lineno == line: + pointer = " " * max(col - 1, 0) + out.append(f"{' ' * width} | {pointer}^") + return "\n".join(out) + + def _args(self, args: ast.arguments): + 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") + names = [a.arg for a in (args.posonlyargs + args.args)] + if not names: + self._err(args, "DSL kernel must accept at least one argument") + + def _stmt(self, node: ast.stmt): # noqa: C901 + if isinstance(node, ast.Assign): + if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): + self._err(node, "Only simple assignments are supported in DSL kernels") + self._expr(node.value) + return + if isinstance(node, ast.AugAssign): + if not isinstance(node.target, ast.Name): + self._err(node, "Only simple augmented assignments are supported") + self._binop(node.op) + self._expr(node.value) + return + if isinstance(node, ast.Return): + if node.value is None: + self._err(node, "DSL kernel return must have a value") + self._expr(node.value) + return + if isinstance(node, ast.Expr): + self._expr(node.value) + return + if isinstance(node, ast.If): + self._expr(node.test) + if not node.body: + self._err(node, "Empty if blocks are not supported in DSL kernels") + for stmt in node.body: + self._stmt(stmt) + for stmt in node.orelse: + self._stmt(stmt) + return + if isinstance(node, ast.For): + if node.orelse: + self._err(node, "for/else is not supported in DSL kernels") + if not isinstance(node.target, ast.Name): + self._err(node, "DSL for-loop target must be a simple name") + if not isinstance(node.iter, ast.Call): + self._err(node, "DSL for-loop must iterate over range()") + func_name = self._call_name(node.iter.func) + if func_name != "range": + self._err(node, "DSL for-loop must iterate over range()") + if node.iter.keywords or not (1 <= len(node.iter.args) <= 3): + self._err(node, "DSL range() must take 1 to 3 positional arguments") + for arg in node.iter.args: + self._expr(arg) + if not node.body: + self._err(node, "Empty for-loop bodies are not supported in DSL kernels") + for stmt in node.body: + self._stmt(stmt) + return + if isinstance(node, ast.While): + if node.orelse: + self._err(node, "while/else is not supported in DSL kernels") + self._expr(node.test) + if not node.body: + self._err(node, "Empty while-loop bodies are not supported in DSL kernels") + for stmt in node.body: + self._stmt(stmt) + return + if isinstance(node, ast.Break | ast.Continue): + return + self._err(node, f"Unsupported DSL statement: {type(node).__name__}") + + def _expr(self, node: ast.AST): # noqa: C901 + if isinstance(node, ast.Name): + return + if isinstance(node, ast.Constant): + val = node.value + if isinstance(val, bool | int | float | str): + return + self._err(node, "Unsupported constant in DSL expression") + if isinstance(node, ast.UnaryOp): + if isinstance(node.op, ast.UAdd | ast.USub | ast.Not): + self._expr(node.operand) + return + self._err(node, "Unsupported unary operator in DSL expression") + if isinstance(node, ast.BinOp): + self._binop(node.op) + self._expr(node.left) + self._expr(node.right) + return + if isinstance(node, ast.BoolOp): + for value in node.values: + self._expr(value) + return + if isinstance(node, ast.Compare): + if len(node.ops) != 1 or len(node.comparators) != 1: + self._err(node, "Chained comparisons are not supported in DSL") + self._cmpop(node.ops[0]) + self._expr(node.left) + self._expr(node.comparators[0]) + return + if isinstance(node, ast.Call): + self._call_name(node.func) + if node.keywords: + self._err(node, "Keyword arguments are not supported in DSL calls") + for arg in node.args: + self._expr(arg) + return + if isinstance(node, ast.IfExp): + seg = ast.get_source_segment(self._source, node) + col = getattr(node, "col_offset", 0) + 1 + if seg is not None: + rel = seg.find(" if ") + if rel >= 0: + col += rel + 1 + self._err( + node, + "Ternary expressions are not supported in DSL; use where(cond, a, b)", + col=col, + ) + self._err(node, f"Unsupported DSL expression: {type(node).__name__}") + + def _call_name(self, node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in {"np", "numpy", "math"} + ): + return node.attr + self._err(node, "Unsupported call target in DSL") + raise AssertionError("unreachable") + + def _binop(self, op: ast.operator): + for k in self._binop_map: + if isinstance(op, k): + return + self._err(op, "Unsupported binary operator in DSL") + + def _cmpop(self, op: ast.cmpop): + for k in self._cmp_map: + if isinstance(op, k): + return + self._err(op, "Unsupported comparison in DSL") + + class DSLKernel: """Wrap a Python function and optionally extract a miniexpr DSL kernel from it.""" @@ -214,8 +421,13 @@ def __init__(self, func): self._legacy_udf_signature = p2 in {"output", "out"} and p3 == "offset" self.dsl_source = None self.input_names = None + self.dsl_error = None try: dsl_source, input_names = self._extract_dsl(func) + except DSLSyntaxError as e: + dsl_source = None + input_names = None + self.dsl_error = e except Exception: dsl_source = None input_names = None @@ -240,7 +452,12 @@ def _extract_dsl(self, func): raise ValueError("No function definition found for DSL extraction") dsl_source = self._slice_function_source(source, func_node) - input_names = self._input_names_from_signature(func_node) + dsl_tree = ast.parse(dsl_source) + dsl_func = next((node for node in dsl_tree.body if isinstance(node, ast.FunctionDef)), None) + if dsl_func is None: + raise ValueError("No function definition found in sliced DSL source") + _DSLValidator(dsl_source).validate(dsl_func) + input_names = self._input_names_from_signature(dsl_func) if _PRINT_DSL_KERNEL: func_name = getattr(func, "__name__", "") print(f"[DSLKernel:{func_name}] dsl_source (full):") @@ -271,6 +488,8 @@ def _input_names_from_signature(func_node: ast.FunctionDef) -> list[str]: return names def __call__(self, inputs_tuple, output, offset=None): + if self.dsl_error is not None: + raise self.dsl_error if self._legacy_udf_signature: return self.func(inputs_tuple, output, offset) @@ -418,7 +637,7 @@ def _block_terminates(self, body) -> bool: return self._stmt_terminates(body[-1]) def _stmt_terminates(self, node: ast.stmt) -> bool: - if isinstance(node, (ast.Return, ast.Break, ast.Continue)): + if isinstance(node, ast.Return | ast.Break | ast.Continue): return True if isinstance(node, ast.If) and node.orelse: return self._block_terminates(node.body) and self._block_terminates(node.orelse) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 159e008a..78a33a53 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -42,7 +42,7 @@ import blosc2 -from .dsl_kernel import DSLKernel, specialize_miniexpr_inputs +from .dsl_kernel import DSLKernel, DSLSyntaxError, specialize_miniexpr_inputs if blosc2._HAS_NUMBA: import numba @@ -3588,6 +3588,9 @@ def __init__( self.kwargs["jit_backend"] = jit_backend self._dtype = dtype self.func = func + if isinstance(self.func, DSLKernel) and self.func.dsl_error is not None: + udf_name = getattr(self.func.func, "__name__", self.func.__name__) + raise DSLSyntaxError(f"Invalid DSL kernel '{udf_name}'.\n{self.func.dsl_error}") from None # Prepare internal array for __getitem__ # Deep copy the kwargs to avoid modifying them @@ -3963,6 +3966,9 @@ def lazyudf( [17.5 20. 22.5] [25. 27.5 30. ]] """ + if isinstance(func, DSLKernel) and func.dsl_error is not None: + udf_name = getattr(func.func, "__name__", func.__name__) + raise DSLSyntaxError(f"Invalid DSL kernel '{udf_name}'.\n{func.dsl_error}") from None return LazyUDF(func, inputs, dtype, shape, chunked_eval, jit, jit_backend, **kwargs) diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 2e0a756d..2c08b2dc 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -9,6 +9,7 @@ import pytest import blosc2 +from blosc2.dsl_kernel import DSLSyntaxError from blosc2.lazyexpr import _apply_jit_backend_pragma @@ -113,6 +114,11 @@ def kernel_fallback_tuple_assign(x, y): return lhs + rhs +@blosc2.dsl_kernel +def kernel_fallback_ternary(x): + return 1 if x else 0 + + @blosc2.dsl_kernel def kernel_index_ramp(x): return _i0 * _n1 + _i1 # noqa: F821 # DSL index/shape symbols resolved by miniexpr @@ -333,26 +339,33 @@ def test_jit_backend_pragma_wrapping_dsl_source(): @pytest.mark.parametrize( "kernel", [ - kernel_fallback_range_2args, kernel_fallback_kw_call, kernel_fallback_for_else, kernel_fallback_tuple_assign, ], ) def test_dsl_kernel_flawed_syntax_detected_fallback_callable(kernel): - assert kernel.dsl_source is not None - assert kernel.dsl_source.startswith("def ") - assert kernel.input_names == ["x", "y"] + assert kernel.dsl_source is None + assert kernel.input_names is None + assert isinstance(kernel.dsl_error, DSLSyntaxError) a, b, a2, b2 = _make_arrays() - expr = blosc2.lazyudf( - kernel, - (a2, b2), - dtype=a2.dtype, - chunks=a2.chunks, - blocks=a2.blocks, - ) - res = expr.compute() - expected = kernel.func(a, b) + with pytest.raises(DSLSyntaxError, match="Invalid DSL kernel"): + _ = blosc2.lazyudf( + kernel, + (a2, b2), + dtype=a2.dtype, + chunks=a2.chunks, + blocks=a2.blocks, + ) - np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) + +def test_dsl_kernel_ternary_rejected_with_actionable_error(): + assert kernel_fallback_ternary.dsl_source is None + assert isinstance(kernel_fallback_ternary.dsl_error, DSLSyntaxError) + msg = str(kernel_fallback_ternary.dsl_error) + assert "Ternary expressions are not supported in DSL" in msg + assert "line" in msg + assert "column" in msg + assert "DSL kernel source:" in msg + assert "^" in msg From 77cb8551baf35725f4c74223905688829cbbf9c5 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 06:36:23 +0100 Subject: [PATCH 04/13] Improve DSL kernels: add syntax diagnostics, enable zero-input shape-based kernels, and expand tutorial coverage --- CMakeLists.txt | 2 +- doc/getting_started/overview.rst | 2 + doc/getting_started/tutorials.rst | 1 + .../tutorials/03.lazyarray-udf-kernels.ipynb | 265 ++++++++++++++++++ doc/reference/lazyarray.rst | 14 + src/blosc2/__init__.py | 4 +- src/blosc2/dsl_kernel.py | 44 ++- src/blosc2/lazyexpr.py | 101 ++++++- tests/ndarray/test_dsl_kernels.py | 50 ++++ tests/ndarray/test_lazyexpr.py | 2 +- 10 files changed, 470 insertions(+), 15 deletions(-) create mode 100644 doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb diff --git a/CMakeLists.txt b/CMakeLists.txt index 23397ac4..2c030eff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,7 +64,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 1bd8d0cfe92b63ad463cd28783e824b5e64afea8 + GIT_TAG 24c8ce8d02ff0d6f52c29ebc9406215a7b81607b # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) diff --git a/doc/getting_started/overview.rst b/doc/getting_started/overview.rst index a9fd84c4..19b080c5 100644 --- a/doc/getting_started/overview.rst +++ b/doc/getting_started/overview.rst @@ -31,6 +31,8 @@ and tools in the Python ecosystem, including: * Excellent integration with Numba and Cython via `User Defined Functions `_. +* DSL kernels for miniexpr-backed UDF authoring and validation (see + `this tutorial `_). * By making use of the simple and open `C-Blosc2 format `_ for storing compressed data, Python-Blosc2 facilitates seamless integration with many other diff --git a/doc/getting_started/tutorials.rst b/doc/getting_started/tutorials.rst index 899126a7..35f347d4 100644 --- a/doc/getting_started/tutorials.rst +++ b/doc/getting_started/tutorials.rst @@ -8,6 +8,7 @@ Tutorials tutorials/01.ndarray-basics tutorials/02.lazyarray-expressions tutorials/03.lazyarray-udf + tutorials/03.lazyarray-udf-kernels tutorials/04.reductions tutorials/05.persistent-reductions tutorials/06.remote_proxy diff --git a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb new file mode 100644 index 00000000..cf5a7d71 --- /dev/null +++ b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb @@ -0,0 +1,265 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c87d8acac9166018", + "metadata": {}, + "source": [ + "# LazyArray UDF DSL Kernels\n", + "\n", + "`@blosc2.dsl_kernel` lets you write kernels with Python function syntax while executing through the miniexpr DSL path.\n", + "\n", + "Use DSL kernels when you want:\n", + "\n", + "- A vectorized UDF model (operate over NDArray chunks/blocks, not Python scalar loops)\n", + "- Optional JIT compilation via miniexpr backends (for example `tcc`/`cc`) without requiring Numba\n", + "- Early syntax validation and actionable diagnostics for unsupported constructs\n", + "\n", + "This tutorial complements `03.lazyarray-udf.ipynb` (generic Python UDFs).\n", + "\n", + "For the canonical DSL syntax contract, see the miniexpr docs: `doc/dsl-syntax.md`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4743791e5436aa04", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:35.309530Z", + "start_time": "2026-02-16T05:32:35.071164Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "import blosc2" + ] + }, + { + "cell_type": "markdown", + "id": "c400c3d7e37cda03", + "metadata": {}, + "source": [ + "## 1. Define a DSL Kernel\n", + "\n", + "A valid DSL kernel can be used with `blosc2.lazyudf(...)` like a regular UDF." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8926a0c21237fef3", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:35.322622Z", + "start_time": "2026-02-16T05:32:35.311059Z" + } + }, + "outputs": [], + "source": [ + "@blosc2.dsl_kernel\n", + "def kernel_index_ramp(x):\n", + " # _i* and _n* are reserved DSL index/shape symbols, so disable linter warnings\n", + " return _i0 * _n1 + _i1 # noqa: F821" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fbe9cb59a4515c9c", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:35.365979Z", + "start_time": "2026-02-16T05:32:35.333433Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9.],\n", + " [10., 11., 12., 13., 14., 15., 16., 17., 18., 19.],\n", + " [20., 21., 22., 23., 24., 25., 26., 27., 28., 29.],\n", + " [30., 31., 32., 33., 34., 35., 36., 37., 38., 39.],\n", + " [40., 41., 42., 43., 44., 45., 46., 47., 48., 49.]], dtype=float32)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "shape = (5, 10)\n", + "x = blosc2.zeros(shape, dtype=np.float32)\n", + "expr = blosc2.lazyudf(kernel_index_ramp, (x,), dtype=np.float32)\n", + "res = expr[:]\n", + "res" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3bcf440eef3435f4", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:36.250173Z", + "start_time": "2026-02-16T05:32:36.234923Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[ 0., 1., 2., 3., 4.],\n", + " [10., 11., 12., 13., 14.]], dtype=float32)" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Optional: request miniexpr JIT backend for this DSL kernel\n", + "expr_jit = blosc2.lazyudf(\n", + " kernel_index_ramp,\n", + " (x,),\n", + " dtype=x.dtype,\n", + " jit=True,\n", + " jit_backend=\"tcc\",\n", + ")\n", + "res_jit = expr_jit.compute()\n", + "res_jit[:2, :5]" + ] + }, + { + "cell_type": "markdown", + "id": "2539c7b3c5c828e3", + "metadata": {}, + "source": [ + "## 2. Preflight Validation (`validate_dsl`)\n", + "\n", + "You can validate a kernel and inspect diagnostics without executing it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e408f3ced12bb48e", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:36.435536Z", + "start_time": "2026-02-16T05:32:36.402775Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "{'valid': True,\n", + " 'dsl_source': 'def kernel_index_ramp(x):\\n # _i* and _n* are reserved DSL index/shape symbols, so disable linter warnings\\n return _i0 * _n1 + _i1 # noqa: F821',\n", + " 'input_names': ['x'],\n", + " 'error': None}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_ok = blosc2.validate_dsl(kernel_index_ramp)\n", + "report_ok" + ] + }, + { + "cell_type": "markdown", + "id": "f62d5a74a417eb12", + "metadata": {}, + "source": [ + "## 3. Invalid Syntax Example\n", + "\n", + "Python ternary expressions are not part of the DSL subset.\n", + "`validate_dsl` reports the issue, and `lazyudf(...)` raises early with a detailed message." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2cfb6d28ee3cf2d8", + "metadata": { + "ExecuteTime": { + "end_time": "2026-02-16T05:32:36.497700Z", + "start_time": "2026-02-16T05:32:36.475885Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "False\n", + "Ternary expressions are not supported in DSL; use where(cond, a, b) at line 2, column 14\n", + "\n", + "DSL kernel source:\n", + "1 | def kernel_invalid_ternary(x):\n", + "2 | return 1 if x else 0\n", + " | ^\n", + "\n", + "See: https://github.com/Blosc/miniexpr/blob/main/doc/dsl-usage.md\n" + ] + } + ], + "source": [ + "@blosc2.dsl_kernel\n", + "def kernel_invalid_ternary(x):\n", + " return 1 if x else 0\n", + "\n", + "\n", + "report_bad = blosc2.validate_dsl(kernel_invalid_ternary)\n", + "print(report_bad[\"valid\"])\n", + "print(report_bad[\"error\"])" + ] + }, + { + "cell_type": "markdown", + "id": "d8c345f8091b1078", + "metadata": {}, + "source": [ + "## 4. Advanced Example: Mandelbrot DSL\n", + "\n", + "For a more advanced real-world DSL kernel, see:\n", + "\n", + "- `examples/ndarray/mandelbrot-dsl.ipynb`\n", + "\n", + "GitHub link:\n", + "\n", + "- https://github.com/Blosc/python-blosc2/blob/main/examples/ndarray/mandelbrot-dsl.ipynb" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/reference/lazyarray.rst b/doc/reference/lazyarray.rst index 3dddaff2..afc8214c 100644 --- a/doc/reference/lazyarray.rst +++ b/doc/reference/lazyarray.rst @@ -10,6 +10,7 @@ You can get an object following the LazyArray API with any of the following ways * Any expression that involves one or more NDArray objects. e.g. ``a + b``, where ``a`` and ``b`` are NDArray objects (see `this tutorial <../getting_started/tutorials/03.lazyarray-expressions.html>`_). * Using the ``lazyexpr`` constructor. * Using the ``lazyudf`` constructor (see `a tutorial <../getting_started/tutorials/03.lazyarray-udf.html>`_). +* Using ``@dsl_kernel`` and ``lazyudf`` for miniexpr-backed DSL kernels (see `this tutorial <../getting_started/tutorials/03.lazyarray-udf-kernels.html>`_). The LazyArray object is a thin wrapper around the expression or user-defined function that allows for lazy computation. This means that the expression is not computed until the ``compute`` or ``__getitem__`` methods are called. The ``compute`` method will return a new NDArray object with the result of the expression evaluation. The ``__getitem__`` method will return an NumPy object instead. @@ -53,3 +54,16 @@ For getting a LazyUDF object (which is LazyArray-compliant) from a user-defined This object follows the `LazyArray`_ API for computation, although storage is not supported yet. .. autofunction:: lazyudf + +.. _DSLKernelReference: + +DSL Kernels +----------- + +For miniexpr-backed kernels, see `the dedicated tutorial <../getting_started/tutorials/03.lazyarray-udf-kernels.html>`_. + +.. autofunction:: dsl_kernel + +.. autofunction:: validate_dsl + +.. autoclass:: DSLSyntaxError diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 2c348a19..8d9a68b8 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -503,7 +503,7 @@ def _raise(exc): from .c2array import c2context, C2Array, URLPath -from .dsl_kernel import DSLKernel, dsl_kernel +from .dsl_kernel import DSLSyntaxError, DSLKernel, dsl_kernel, validate_dsl from .lazyexpr import ( LazyExpr, lazyudf, @@ -687,6 +687,7 @@ def _raise(exc): "Filter", "LazyArray", "DSLKernel", + "DSLSyntaxError", "LazyExpr", "LazyUDF", "NDArray", @@ -805,6 +806,7 @@ def _raise(exc): "jit", "lazyexpr", "dsl_kernel", + "validate_dsl", "lazyudf", "lazywhere", "less", diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 2ae46607..41850e15 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -18,6 +18,7 @@ _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/miniexpr/blob/main/doc/dsl-usage.md" class DSLSyntaxError(ValueError): @@ -219,7 +220,7 @@ def __init__(self, source: str, line_base: int = 0): self._line_base = line_base def validate(self, func_node: ast.FunctionDef): - self._args(func_node.args) + self._args(func_node) if not func_node.body: self._err(func_node, "DSL kernel must have a body") for stmt in func_node.body: @@ -233,7 +234,7 @@ def _err(self, node: ast.AST, msg: str, *, line: int | None = None, col: int | N line -= self._line_base location = f"{msg} at line {line}, column {col}" dump = self._format_source_with_pointer(line, col) - raise DSLSyntaxError(f"{location}\n\nDSL kernel source:\n{dump}") + raise DSLSyntaxError(f"{location}\n\nDSL kernel source:\n{dump}\n\nSee: {_DSL_USAGE_DOC_URL}") def _format_source_with_pointer(self, line: int, col: int) -> str: lines = self._source.splitlines() @@ -248,14 +249,12 @@ def _format_source_with_pointer(self, line: int, col: int) -> str: out.append(f"{' ' * width} | {pointer}^") return "\n".join(out) - def _args(self, args: ast.arguments): + 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") - names = [a.arg for a in (args.posonlyargs + args.args)] - if not names: - self._err(args, "DSL kernel must accept at least one argument") def _stmt(self, node: ast.stmt): # noqa: C901 if isinstance(node, ast.Assign): @@ -482,10 +481,7 @@ def _input_names_from_signature(func_node: ast.FunctionDef) -> list[str]: 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") - names = [a.arg for a in (args.posonlyargs + args.args)] - if not names: - raise ValueError("DSL kernel must accept at least one argument") - return names + return [a.arg for a in (args.posonlyargs + args.args)] def __call__(self, inputs_tuple, output, offset=None): if self.dsl_error is not None: @@ -518,6 +514,34 @@ def dsl_kernel(func): return DSLKernel(func) +def validate_dsl(func): + """Validate a DSL kernel function without executing it. + + Parameters + ---------- + func + A Python callable or :class:`DSLKernel`. + + Returns + ------- + dict + A dictionary with: + - ``valid`` (bool): whether the DSL is valid + - ``dsl_source`` (str | None): extracted DSL source when valid + - ``input_names`` (list[str] | None): input signature names when valid + - ``error`` (str | None): user-facing error message when invalid + """ + + kernel = func if isinstance(func, DSLKernel) else DSLKernel(func) + err = kernel.dsl_error + return { + "valid": err is None, + "dsl_source": kernel.dsl_source, + "input_names": kernel.input_names, + "error": None if err is None else str(err), + } + + class _DSLBuilder: _binop_map: ClassVar[dict[type[ast.operator], str]] = { ast.Add: "+", diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 78a33a53..6220d6d6 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -1273,6 +1273,14 @@ def _apply_jit_backend_pragma(expression: str, inputs: dict, jit_backend: str | return f"{pragma}def __me_auto({params}):\n return {expression}" +def _inject_dummy_param_for_zero_input_dsl(expression: str, param_name: str) -> str: + pattern = re.compile(r"^(\s*def\s+[A-Za-z_]\w*)\(\s*\)(\s*:)", re.MULTILINE) + rewritten, nsubs = pattern.subn(rf"\1({param_name})\2", expression, count=1) + if nsubs == 0: + raise ValueError("Could not inject dummy DSL parameter for zero-input kernel") + return rewritten + + def fast_eval( # noqa: C901 expression: str | Callable[[tuple, np.ndarray, tuple[int]], None], operands: dict, @@ -1318,6 +1326,7 @@ def fast_eval( # noqa: C901 jit = kwargs.pop("jit", None) jit_backend = kwargs.pop("jit_backend", None) dtype = kwargs.pop("dtype", None) + requested_shape = kwargs.pop("shape", None) where: dict | None = kwargs.pop("_where_args", None) if where is not None: # miniexpr does not support where(); use the regular path. @@ -1331,7 +1340,19 @@ def fast_eval( # noqa: C901 else: # Otherwise, find the operand with the 'chunks' attribute and the longest shape operands_with_chunks = [o for o in operands.values() if hasattr(o, "chunks")] - basearr = max(operands_with_chunks, key=lambda x: len(x.shape)) + if operands_with_chunks: + basearr = max(operands_with_chunks, key=lambda x: len(x.shape)) + else: + if requested_shape is None: + raise ValueError("Cannot infer output shape without operands; pass `shape` explicitly") + if dtype is None: + raise ValueError("Cannot infer output dtype without operands; pass `dtype` explicitly") + basearr = blosc2.empty( + requested_shape, + dtype=dtype, + chunks=kwargs.get("chunks"), + blocks=kwargs.get("blocks"), + ) # Get the shape of the base array shape = basearr.shape @@ -1373,6 +1394,17 @@ def fast_eval( # noqa: C901 # Check whether we can use miniexpr if use_miniexpr: + if ( + is_dsl + and isinstance(expression, DSLKernel) + and expression.input_names == [] + and not operands_miniexpr + ): + dummy_name = "__me_dummy0" + expr_string_miniexpr = _inject_dummy_param_for_zero_input_dsl(expr_string_miniexpr, dummy_name) + operands_miniexpr = { + dummy_name: blosc2.zeros(shape, dtype=np.uint8, chunks=chunks, blocks=blocks) + } if math.prod(shape) <= 1: # Avoid miniexpr for scalar-like outputs; current prefilter path is unstable here. use_miniexpr = False @@ -2386,6 +2418,51 @@ def convert_none_out(dtype, reduce_op, reduced_shape): return out +def _validate_chunked_eval_inputs(operands: dict, out, shape, reduce_args: dict) -> bool: + if operands: + _, _, _, fast_path = validate_inputs(operands, out, reduce=reduce_args != {}) + return fast_path + if shape is None and out is None: + raise ValueError( + "For UDFs with no inputs, provide `shape` (or an output array) to indicate result shape" + ) + return False + + +def _eval_zero_input_dsl_if_needed( + expression, + operands: dict, + where, + getitem: bool, + item, + shape, + jit, + jit_backend, + kwargs: dict, +): + use_zero_input_dsl_fast_eval = ( + not operands + and isinstance(expression, DSLKernel) + and expression.dsl_source is not None + and where is None + ) + if not use_zero_input_dsl_fast_eval: + return False, None + + full_res = fast_eval( + expression, + operands, + getitem=False, + shape=shape, + jit=jit, + jit_backend=jit_backend, + **kwargs, + ) + if getitem: + return True, full_res[item.raw] + return True, full_res + + def chunked_eval( expression: str | Callable[[tuple, np.ndarray, tuple[int]], None], operands: dict, item=(), **kwargs ): @@ -2441,7 +2518,7 @@ def chunked_eval( operands = {**operands, **where} reduce_args = kwargs.pop("_reduce_args", {}) - _, _, _, fast_path = validate_inputs(operands, out, reduce=reduce_args != {}) + fast_path = _validate_chunked_eval_inputs(operands, out, shape, reduce_args) # Activate last read cache for NDField instances for op in operands: @@ -2460,6 +2537,12 @@ def chunked_eval( **kwargs, ) + handled, result = _eval_zero_input_dsl_if_needed( + expression, operands, where, getitem, item, shape, jit, jit_backend, kwargs + ) + if handled: + return result + if not is_full_slice(item.raw) or (where is not None and len(where) < 2): # The fast path is possible under a few conditions if getitem and (where is None or len(where) == 2): @@ -3652,6 +3735,13 @@ def info_items(self): def chunks(self): if hasattr(self, "_chunks"): return self._chunks + if not self.inputs_dict: + req_chunks = self.kwargs.get("chunks") + req_blocks = self.kwargs.get("blocks") + self._chunks, self._blocks = compute_chunks_blocks( + self.shape, req_chunks, req_blocks, dtype=self.dtype + ) + return self._chunks shape, self._chunks, self._blocks, fast_path = validate_inputs( self.inputs_dict, getattr(self, "_out", None) ) @@ -3668,6 +3758,13 @@ def chunks(self): def blocks(self): if hasattr(self, "_blocks"): return self._blocks + if not self.inputs_dict: + req_chunks = self.kwargs.get("chunks") + req_blocks = self.kwargs.get("blocks") + self._chunks, self._blocks = compute_chunks_blocks( + self.shape, req_chunks, req_blocks, dtype=self.dtype + ) + return self._blocks shape, self._chunks, self._blocks, fast_path = validate_inputs( self.inputs_dict, getattr(self, "_out", None) ) diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 2c08b2dc..2b102132 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -124,6 +124,16 @@ def kernel_index_ramp(x): return _i0 * _n1 + _i1 # noqa: F821 # DSL index/shape symbols resolved by miniexpr +@blosc2.dsl_kernel +def kernel_index_ramp_float_cast(x): + return float(_i0) * _n1 + _i1 # noqa: F821 # DSL index/shape symbols resolved by miniexpr + + +@blosc2.dsl_kernel +def kernel_index_ramp_no_inputs(): + return _i0 * _n1 + _i1 # noqa: F821 # DSL index/shape symbols resolved by miniexpr + + def test_dsl_kernel_loop_kept_as_full_dsl_function(): assert kernel_loop.dsl_source is not None assert "def kernel_loop(x, y):" in kernel_loop.dsl_source @@ -186,6 +196,32 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, assert res.shape == shape +def test_dsl_kernel_with_no_inputs_works_with_explicit_shape(): + assert kernel_index_ramp_no_inputs.dsl_source is not None + assert "def kernel_index_ramp_no_inputs():" in kernel_index_ramp_no_inputs.dsl_source + assert kernel_index_ramp_no_inputs.input_names == [] + + shape = (10, 10) + expr = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32, shape=shape) + res = expr[:] + expected = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) + np.testing.assert_equal(res, expected) + + +def test_dsl_kernel_with_no_inputs_requires_shape_or_out(): + with pytest.raises(ValueError, match="shape"): + _ = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32) + + +def test_dsl_kernel_index_symbols_float_cast_matches_expected_ramp(): + shape = (32, 5) + x2 = blosc2.zeros(shape, dtype=np.float32) + expr = blosc2.lazyudf(kernel_index_ramp_float_cast, (x2,), dtype=np.float32) + res = expr[:] + expected = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) + np.testing.assert_allclose(res, expected, rtol=0.0, atol=0.0) + + def test_dsl_kernel_full_control_flow_kept_as_dsl_function(): assert kernel_control_flow_full.dsl_source is not None assert "def kernel_control_flow_full(x, y):" in kernel_control_flow_full.dsl_source @@ -369,3 +405,17 @@ def test_dsl_kernel_ternary_rejected_with_actionable_error(): assert "column" in msg assert "DSL kernel source:" in msg assert "^" in msg + + +def test_validate_dsl_api_valid_and_invalid(): + valid_report = blosc2.validate_dsl(kernel_loop) + assert valid_report["valid"] is True + assert valid_report["error"] is None + assert "def kernel_loop(x, y):" in valid_report["dsl_source"] + assert valid_report["input_names"] == ["x", "y"] + + invalid_report = blosc2.validate_dsl(kernel_fallback_ternary) + assert invalid_report["valid"] is False + assert "Ternary expressions are not supported in DSL" in invalid_report["error"] + assert invalid_report["dsl_source"] is None + assert invalid_report["input_names"] is None diff --git a/tests/ndarray/test_lazyexpr.py b/tests/ndarray/test_lazyexpr.py index c882c0cc..8d5047c4 100644 --- a/tests/ndarray/test_lazyexpr.py +++ b/tests/ndarray/test_lazyexpr.py @@ -1514,7 +1514,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, np.testing.assert_allclose(res[...], na + b, rtol=1e-6, atol=1e-6) assert captured["calls"] >= 1 assert captured["keys"] == ("o0",) - assert captured["expr"] == "o0 + 3" + assert captured["expr"] in {"o0 + 3", "(o0 + 3)"} assert "b" not in captured["expr"] finally: lazyexpr_mod.try_miniexpr = old_try_miniexpr From bf08f6e22e0265d3df4773700d6a441365ca281d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 06:47:57 +0100 Subject: [PATCH 05/13] Make x param in example of tutorial a bit more useful --- .../tutorials/03.lazyarray-udf-kernels.ipynb | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb index cf5a7d71..8b36368c 100644 --- a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb +++ b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb @@ -22,20 +22,20 @@ }, { "cell_type": "code", - "execution_count": 1, "id": "4743791e5436aa04", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:35.309530Z", - "start_time": "2026-02-16T05:32:35.071164Z" + "end_time": "2026-02-16T05:46:57.649941Z", + "start_time": "2026-02-16T05:46:57.358347Z" } }, - "outputs": [], "source": [ "import numpy as np\n", "\n", "import blosc2" - ] + ], + "outputs": [], + "execution_count": 1 }, { "cell_type": "markdown", @@ -49,41 +49,47 @@ }, { "cell_type": "code", - "execution_count": 2, "id": "8926a0c21237fef3", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:35.322622Z", - "start_time": "2026-02-16T05:32:35.311059Z" + "end_time": "2026-02-16T05:46:57.677192Z", + "start_time": "2026-02-16T05:46:57.660322Z" } }, - "outputs": [], "source": [ "@blosc2.dsl_kernel\n", "def kernel_index_ramp(x):\n", " # _i* and _n* are reserved DSL index/shape symbols, so disable linter warnings\n", - " return _i0 * _n1 + _i1 # noqa: F821" - ] + " return x + _i0 * _n1 + _i1 # noqa: F821" + ], + "outputs": [], + "execution_count": 2 }, { "cell_type": "code", - "execution_count": 3, "id": "fbe9cb59a4515c9c", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:35.365979Z", - "start_time": "2026-02-16T05:32:35.333433Z" + "end_time": "2026-02-16T05:46:57.700393Z", + "start_time": "2026-02-16T05:46:57.678344Z" } }, + "source": [ + "shape = (5, 10)\n", + "x = blosc2.ones(shape, dtype=np.float32)\n", + "expr = blosc2.lazyudf(kernel_index_ramp, (x,), dtype=np.float32)\n", + "res = expr[:]\n", + "res" + ], "outputs": [ { "data": { "text/plain": [ - "array([[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9.],\n", - " [10., 11., 12., 13., 14., 15., 16., 17., 18., 19.],\n", - " [20., 21., 22., 23., 24., 25., 26., 27., 28., 29.],\n", - " [30., 31., 32., 33., 34., 35., 36., 37., 38., 39.],\n", - " [40., 41., 42., 43., 44., 45., 46., 47., 48., 49.]], dtype=float32)" + "array([[ 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.],\n", + " [11., 12., 13., 14., 15., 16., 17., 18., 19., 20.],\n", + " [21., 22., 23., 24., 25., 26., 27., 28., 29., 30.],\n", + " [31., 32., 33., 34., 35., 36., 37., 38., 39., 40.],\n", + " [41., 42., 43., 44., 45., 46., 47., 48., 49., 50.]], dtype=float32)" ] }, "execution_count": 3, @@ -91,37 +97,17 @@ "output_type": "execute_result" } ], - "source": [ - "shape = (5, 10)\n", - "x = blosc2.zeros(shape, dtype=np.float32)\n", - "expr = blosc2.lazyudf(kernel_index_ramp, (x,), dtype=np.float32)\n", - "res = expr[:]\n", - "res" - ] + "execution_count": 3 }, { "cell_type": "code", - "execution_count": 4, "id": "3bcf440eef3435f4", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:36.250173Z", - "start_time": "2026-02-16T05:32:36.234923Z" + "end_time": "2026-02-16T05:46:58.627822Z", + "start_time": "2026-02-16T05:46:58.610389Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - "array([[ 0., 1., 2., 3., 4.],\n", - " [10., 11., 12., 13., 14.]], dtype=float32)" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], "source": [ "# Optional: request miniexpr JIT backend for this DSL kernel\n", "expr_jit = blosc2.lazyudf(\n", @@ -133,7 +119,21 @@ ")\n", "res_jit = expr_jit.compute()\n", "res_jit[:2, :5]" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "array([[ 1., 2., 3., 4., 5.],\n", + " [11., 12., 13., 14., 15.]], dtype=float32)" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 4 }, { "cell_type": "markdown", @@ -147,20 +147,23 @@ }, { "cell_type": "code", - "execution_count": 5, "id": "e408f3ced12bb48e", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:36.435536Z", - "start_time": "2026-02-16T05:32:36.402775Z" + "end_time": "2026-02-16T05:46:58.791626Z", + "start_time": "2026-02-16T05:46:58.683662Z" } }, + "source": [ + "report_ok = blosc2.validate_dsl(kernel_index_ramp)\n", + "report_ok" + ], "outputs": [ { "data": { "text/plain": [ "{'valid': True,\n", - " 'dsl_source': 'def kernel_index_ramp(x):\\n # _i* and _n* are reserved DSL index/shape symbols, so disable linter warnings\\n return _i0 * _n1 + _i1 # noqa: F821',\n", + " 'dsl_source': 'def kernel_index_ramp(x):\\n # _i* and _n* are reserved DSL index/shape symbols, so disable linter warnings\\n return x + _i0 * _n1 + _i1 # noqa: F821',\n", " 'input_names': ['x'],\n", " 'error': None}" ] @@ -170,10 +173,7 @@ "output_type": "execute_result" } ], - "source": [ - "report_ok = blosc2.validate_dsl(kernel_index_ramp)\n", - "report_ok" - ] + "execution_count": 5 }, { "cell_type": "markdown", @@ -188,14 +188,23 @@ }, { "cell_type": "code", - "execution_count": 6, "id": "2cfb6d28ee3cf2d8", "metadata": { "ExecuteTime": { - "end_time": "2026-02-16T05:32:36.497700Z", - "start_time": "2026-02-16T05:32:36.475885Z" + "end_time": "2026-02-16T05:46:58.840100Z", + "start_time": "2026-02-16T05:46:58.818859Z" } }, + "source": [ + "@blosc2.dsl_kernel\n", + "def kernel_invalid_ternary(x):\n", + " return 1 if x else 0\n", + "\n", + "\n", + "report_bad = blosc2.validate_dsl(kernel_invalid_ternary)\n", + "print(report_bad[\"valid\"])\n", + "print(report_bad[\"error\"])" + ], "outputs": [ { "name": "stdout", @@ -213,16 +222,7 @@ ] } ], - "source": [ - "@blosc2.dsl_kernel\n", - "def kernel_invalid_ternary(x):\n", - " return 1 if x else 0\n", - "\n", - "\n", - "report_bad = blosc2.validate_dsl(kernel_invalid_ternary)\n", - "print(report_bad[\"valid\"])\n", - "print(report_bad[\"error\"])" - ] + "execution_count": 6 }, { "cell_type": "markdown", From 447c751b619212cf4b4d1d4e4bf09e0a1a06c4b1 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 07:13:49 +0100 Subject: [PATCH 06/13] Fix DSL scalar-cast specialization to keep miniexpr fast path --- examples/ndarray/mandelbrot-dsl.ipynb | 97 ++++++++++++++------------- src/blosc2/dsl_kernel.py | 47 +++++++++++++ tests/ndarray/test_dsl_kernels.py | 44 ++++++++++++ 3 files changed, 142 insertions(+), 46 deletions(-) diff --git a/examples/ndarray/mandelbrot-dsl.ipynb b/examples/ndarray/mandelbrot-dsl.ipynb index e43aa172..a545d151 100644 --- a/examples/ndarray/mandelbrot-dsl.ipynb +++ b/examples/ndarray/mandelbrot-dsl.ipynb @@ -12,8 +12,8 @@ "id": "imports", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:58:57.375120Z", - "start_time": "2026-02-13T17:58:57.058203Z" + "end_time": "2026-02-16T06:11:53.587412Z", + "start_time": "2026-02-16T06:11:53.158736Z" } }, "outputs": [], @@ -33,8 +33,8 @@ "id": "grid-setup", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:58:57.400486Z", - "start_time": "2026-02-13T17:58:57.375948Z" + "end_time": "2026-02-16T06:11:53.617510Z", + "start_time": "2026-02-16T06:11:53.587980Z" } }, "outputs": [ @@ -73,8 +73,8 @@ "id": "dsl-kernel", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:58:57.424675Z", - "start_time": "2026-02-13T17:58:57.401595Z" + "end_time": "2026-02-16T06:11:53.664348Z", + "start_time": "2026-02-16T06:11:53.618685Z" } }, "outputs": [ @@ -87,11 +87,11 @@ " zi = 0.0\n", " escape_iter = float(max_iter)\n", " for i in range(max_iter):\n", - " if (((zr * zr) + (zi * zi)) > 4):\n", + " if zr * zr + zi * zi > 4:\n", " escape_iter = i\n", " break\n", - " zr_new = (((zr * zr) - (zi * zi)) + cr)\n", - " zi = (((2 * zr) * zi) + ci)\n", + " zr_new = zr * zr - zi * zi + cr\n", + " zi = 2 * zr * zi + ci\n", " zr = zr_new\n", " return escape_iter\n" ] @@ -125,8 +125,8 @@ "id": "numba-kernel", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:58:57.443906Z", - "start_time": "2026-02-13T17:58:57.425794Z" + "end_time": "2026-02-16T06:11:53.710200Z", + "start_time": "2026-02-16T06:11:53.665577Z" } }, "outputs": [], @@ -200,8 +200,8 @@ "id": "benchmark", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:58:59.755891Z", - "start_time": "2026-02-13T17:58:57.444370Z" + "end_time": "2026-02-16T06:11:55.998926Z", + "start_time": "2026-02-16T06:11:53.710753Z" } }, "outputs": [ @@ -217,33 +217,33 @@ "output_type": "stream", "text": [ "First iteration timings (one-time overhead included):\n", - "Native numba first run (baseline): 0.438890 s\n", - "Blosc2+numba first run: 0.275028 s\n", - "Blosc2+DSL(cc) first run: 0.463609 s\n", - "Blosc2+DSL(tcc) first run: 0.095978 s\n", + "Native numba first run (baseline): 0.438515 s\n", + "Blosc2+numba first run: 0.277509 s\n", + "Blosc2+DSL(cc) first run: 0.503285 s\n", + "Blosc2+DSL(tcc) first run: 0.082106 s\n", "\n", "Best-time stats:\n", - "Native numba time (best): 0.056266 s\n", - "Blosc2+numba time (best): 0.051941 s\n", - "Blosc2+DSL(cc) time (best): 0.033036 s\n", - "Blosc2+DSL(tcc) time (best): 0.076250 s\n", - "Blosc2+numba / native: 0.92x\n", - "Blosc2+DSL(cc) / native: 0.59x\n", - "Blosc2+DSL(tcc) / native: 1.36x\n", - "Blosc2+DSL(cc) / Blosc2+numba: 0.64x\n", - "Blosc2+DSL(tcc) / Blosc2+numba: 1.47x\n", - "Blosc2+DSL(tcc) / Blosc2+DSL(cc): 2.31x\n", + "Native numba time (best): 0.051871 s\n", + "Blosc2+numba time (best): 0.051976 s\n", + "Blosc2+DSL(cc) time (best): 0.032784 s\n", + "Blosc2+DSL(tcc) time (best): 0.075033 s\n", + "Blosc2+numba / native: 1.00x\n", + "Blosc2+DSL(cc) / native: 0.63x\n", + "Blosc2+DSL(tcc) / native: 1.45x\n", + "Blosc2+DSL(cc) / Blosc2+numba: 0.63x\n", + "Blosc2+DSL(tcc) / Blosc2+numba: 1.44x\n", + "Blosc2+DSL(tcc) / Blosc2+DSL(cc): 2.29x\n", "\n", "Cold-start overhead (first - best):\n", - "Native numba overhead: 0.382623 s\n", - "Blosc2+numba overhead: 0.223087 s\n", - "Blosc2+DSL(tcc) overhead: 0.019728 s\n", - "Blosc2+DSL(cc) overhead: 0.430573 s\n", + "Native numba overhead: 0.386644 s\n", + "Blosc2+numba overhead: 0.225533 s\n", + "Blosc2+DSL(tcc) overhead: 0.007073 s\n", + "Blosc2+DSL(cc) overhead: 0.470500 s\n", "\n", "Steady-state speedup vs native (best):\n", - "Blosc2+numba speedup vs native: 1.08x\n", - "Blosc2+DSL(tcc) speedup vs native:0.74x\n", - "Blosc2+DSL(cc) speedup vs native: 1.70x\n", + "Blosc2+numba speedup vs native: 1.00x\n", + "Blosc2+DSL(tcc) speedup vs native:0.69x\n", + "Blosc2+DSL(cc) speedup vs native: 1.58x\n", "max |dsl(cc)-b2_numba|: 0.000000\n", "max |native-b2_numba|: 0.000000\n", "max |native-dsl(cc)|: 0.000000\n", @@ -333,10 +333,15 @@ "# Keep backward-compatible names for the plotting cell\n", "img_dsl = img_dsl_cc\n", "\n", - "a_max = float(np.max(np.abs(img_dsl_cc - img_b2_numba)))\n", - "b_max = float(np.max(np.abs(img_numba_native - img_b2_numba)))\n", - "c_max = float(np.max(np.abs(img_numba_native - img_dsl_cc)))\n", - "d_max = float(np.max(np.abs(img_dsl_cc - img_dsl_tcc)))\n", + "\n", + "def _max_abs_diff(a, b):\n", + " return np.max(np.abs(np.asarray(a) - np.asarray(b))).item()\n", + "\n", + "\n", + "a_max = _max_abs_diff(img_dsl_cc, img_b2_numba)\n", + "b_max = _max_abs_diff(img_numba_native, img_b2_numba)\n", + "c_max = _max_abs_diff(img_numba_native, img_dsl_cc)\n", + "d_max = _max_abs_diff(img_dsl_cc, img_dsl_tcc)\n", "\n", "print(\"First iteration timings (one-time overhead included):\")\n", "print(f\"Native numba first run (baseline): {t_numba_native_first:.6f} s\")\n", @@ -377,8 +382,8 @@ "id": "plot", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:59:00.074980Z", - "start_time": "2026-02-13T17:58:59.773429Z" + "end_time": "2026-02-16T06:11:56.307009Z", + "start_time": "2026-02-16T06:11:56.016194Z" } }, "outputs": [ @@ -430,14 +435,14 @@ "id": "timing-bars", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:59:00.153787Z", - "start_time": "2026-02-13T17:59:00.075655Z" + "end_time": "2026-02-16T06:11:56.389351Z", + "start_time": "2026-02-16T06:11:56.307596Z" } }, "outputs": [ { "data": { - "image/png": "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zp1uucT2CRMTk6WLKcv//w8AAL7sf0HDKHntpgEAADIL2swDAHwhcng0dZyldrRKCQUAAMhsqJkHAPiC2pKqwyy1H1UbZXWiqL4I1PYzuUOuAQAA+B0d4AEAfEEdId5///2uh2d1SKVxp9WhE4E8AADIjKiZBwAAAADAZ2gzDwAAAACAzxDMAwAAAADgM5myzXzRokVt7969ab0ZAAAAAAAkkDdvXtf5b1KyZ8ZAfvPmzWm9GQAAAAAAJOqCCy5IMqDPdMG8VyOvHUPtPAAAAAAgvdXKqwL6ZPFqpgvmPdoxBPMAAAAAAD+iAzwAAAAAAHyGYB4AAAAAAJ8hmAcAAAAAwGcybZt5AAAAAMhIsmTJYuecc47rQE3/I/0JBAKu77bdu3e7/08FwTwAAAAA+FzBggXt8ccft7Jly6b1piAGq1evtmHDhtnff/9tKaXimlMrDvAZlVLt2bPH8uXLR2/2AAAAAHwve/bs9tZbb9m+ffts3Lhxtn37djt+/HhabxaiyJYtm51//vnWqFEjO+uss6x169Z27NixFMesgcw05c2bNyD6m9bbwsTExMTExMTExMQU29S6devA+vXrAwcPHgzMmzcvULVq1Zhed99997n7/88//zzBc2XLlg188cUXgd27dwf27dsXmD9/fuDCCy+Mup5Jkya59dSvXz/N90XkpG0eNWpU4NJLL03zbWGymCYdKx2zYsWKpThmpQM8AAAAAOmaajEHDBhg3bt3t8qVK9uyZctsypQpLrU8KSVKlLD+/fvb999/n+C5UqVK2Zw5c1y684033mgVK1a0nj172qFDhxIs265du1Nu35yasmb9X1h3+PDhtN4UxMg7VqqpTymCeQAAAADpWvv27V374hEjRtiqVausZcuWduDAAWvevHmSAe7o0aOta9eutm7dugTP9+rVyyZNmmQdO3a0pUuXumW++uqrBG2Yr7jiCuvQoUPU91Jncx9++KFLa9f2rFmzxpo1axanTw0kjWAeAAAAQLqVI0cOq1Klik2fPj04T7Xkely9evVEX9elSxcXZL///vsJnlNP77fffrsLvidPnmzbtm2zefPmWf369cOWy507t3300Uf25JNPumUiqSa/XLlyVq9ePbvsssusVatWtmPHjlP+zEAs6M0eAAAAQLpVoEAB18FbZDCtx4n13F6jRg179NFH7corr4z6vDogUydjzz//vL344ouudr5u3br22WefWa1atYJp+QMHDrQff/zRvvzyy6jrKV68uC1ZssQWLVrkHm/cuNHSmzue//i0vt/XrzSO27pmzpzpsiaefvrpuK0zIyGYBwAAAJBhqIfwDz74wA3T9s8//yTZxvyLL76w1157zf2vdvjXXnutS+FXMP9///d/dtNNN1mlSpUSfa8hQ4bY+PHjXTv+qVOn2oQJE2zu3Lmp9MkypuHDh0dtmnDxxRdbgwYN7OjRo6e0/kAgYHfddZc71hkNwTwAAACAdEtp6xq6q1ChQmHz9Xjr1q0Jli9durSVLFnStX+PDN4VGJYpU8b++OMP9//KlSvDXqv2+DVr1nT/K5DXunbv3h22jIL32bNnuxp8peirk73bbrvN6tSpYzNmzLDBgwfbs88+G9d9kNF988039sgjj4TNU98FJ06cOGkTjKOnGOwnRtkgkUPGpTe0mQcAAACQbilYUxp77dq1w9q863G0WnD1Tn/55Ze7FHtvUpq8Urb1vxfIL1iwwAX2oS699NJgqvwrr7ziergPXY8o5Ts08FRhw6hRo+yhhx5yvd63aNEiFfdGxu3ZXc0mQicF8jpmaurgWb9+vWsWMXLkSPv333/tnXfecQH9G2+8YVu2bLGDBw/ahg0bXPMJb3lRxoRq6L3HkVQgo+c1asJ3333n1vPAAw+4zhPVjCJU27Ztw9ajzILPP//cdZKobdD58Oabb7rCgNRGzTwAAACAdE3D0imAW7hwoc2fP98FzXny5HGBlOi5zZs3W+fOnV1guGLFirDXe7XrofP79etnY8eOdSn1ChrVZl6p9RqmTrygMtKmTZtcwCgaKk8FDVpvrly57I477nC1+0g9zzzzjPXo0cPte3nqqafszjvvdIG4js2FF17oJqlataqr4Vcav7Iojh8/nuS6VYCjoFwBvIYofOKJJ2LaJmVp/PXXX+6vmgfovFJb/3fffddSE8E8AAAAgHRt3Lhxbkx5BXGFCxd2gZKCb/VW73VEd7KU7EiqrVX7+E6dOtnrr79uv/76q91zzz32ww8/xLyOI0eOWJ8+feyiiy5ytblKv2/cOH4dwGUWKgTZu3dvWNq9gvNovv32W1e449GxX7t2rc2ZM8c9VkDv8UYWUGFOtIKZSOo/QbXsybVr1y5r06aNOwd1Hk2cONFljhDMAwAAAMj01BZdUzSqEU1KZHtsj2r2vdr9WCi9P3Ksek04NcqM0LB+nv379ye6rLIzQo0YMcKmTZvmgmjVvn/99dfucUpErjtWyswILUxSLX2FChUstRHMAwAAAADSjIL333//PeZlQy1ZssR1eFivXj27+eabXRbH9OnT7d57703RdoRSgB5ZgKM2+pEiO+FT+3uv08XURDAPAAAAwNdjm2dk8Ry3PaPau3evC+I1ffrppzZlyhTLnz+/S39XU4hs2bKlaL1qb69mHaG8jhDTA4J5AAAAAIAvPf300y6tXTX0qklXjbwee50eqrNCtV9XXwjqHDFyqMGkqGd79dXw3HPPuUIC9dOgDIA9e/ZYekAwDwAAAAAZVEav2d+7d68Lti+55BLXW72GHLzttttcqruod3p1mPf444+7EQ+Ukh8rDXPYunVrN0rCSy+9ZOPHj7f+/funm+EH1QDgf58yk8ibN68rScmXL19Yj4kAAAAA4oM0+9MbjGuc9J49e7qAc+PGjadlu3Bqkjpmscasqd8qHwAAAAAAxBXBPAAAAAAAPkMwDwAAAACAzxDMAwAAAADgMwTzAAAAAAD4DME8AAAAAAA+QzCfCWmsxPXr19vBgwdt3rx5VrVq1Zhed99997nxGj///POw+V27drVVq1bZvn37bOfOnTZt2jSrVq1a2DKVKlWyqVOn2q5du2zHjh02dOhQy5MnT1w/FwAAAABkFgTzmUyjRo1swIAB1r17d6tcubItW7bMpkyZYgULFjzpOIj9+/e377//PsFza9assTZt2liFChWsZs2atmHDBhe4FyhQwD1fpEgRmz59uv3222929dVXW926da18+fI2YsSIVPucAAAAAJCREcxnMu3bt7dhw4a5QFq16S1btrQDBw5Y8+bNE31N1qxZbfTo0a4Gft26dQmeHzNmjM2YMcPV9q9cudK9x9lnn20VK1Z0z99xxx129OhRe/LJJ13gv3DhQve+DRs2tNKlS7tlzjnnHPvwww9t+/btbnu0XLNmzVJxTwAAAACAf2VP6w3A6ZMjRw6rUqWK9enTJzhPafOqNa9evXqir+vSpYsLst9//3277rrrTvoeLVq0sN27d7taf8mVK5cdOXLEvZdHKf6imvzff//devbsaeXKlbN69eq5NPyLL77YcufOHYdPDQAAAGRe+3sVPq3vl+eFraf1/TIzauYz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ueQSF/C3atXKFi5caGvWrHH/586d2+6///7gegYOHGivv/669e3b11auXOmW++STT+zIkSPu+Rw5ctgbb7xhW7ZscRkAGzZssOeff/407RUAQHpCMA8AABBCAXOVKlVcrbknEAi4xwrIo9H80OVFNfne8rly5XJ/Dx06FLbOw4cPW82aNd1j1fqrZn779u32ww8/2NatW+27776zGjVqBF/z1FNP2Z133ukyB8qUKWMPPPCAC+gBAJkPwTwAAECIAgUKWPbs2W3btm1h8/W4cOHCUV+j+Uktr9p6pdX36dPHzjnnHFdg8Nxzz9mFF15oRYoUccsoBV+6devmsgLq1q3ravfVft5rW1+8eHFbu3atzZkzxzZt2uSC/o8//jhV9gMAIH3L6qfOZcqVK2effvqpW16l2UppAwAASO+OHTtmDRo0sEsvvdR27drlUvZr1aplkyZNCraHz5r1f7dlQ4cOden9S5cuden+v/76azC9X/OvvPJKN2/QoEFWp06dNP1cAIBMGswnt3MZdRCzbt061zbsr7/+Ou3bCwAAMr4dO3a44LtQoUJh8/VYqe/RaP7Jllctuzq+O/vss11tvHq7P++889y9jXj3NmorH0pt9lUjL0uWLLGSJUvaSy+95Nrbjxs3zrWpBwBkPln91LmMOoxRStrYsWNdGzMAAIB4O3r0qC1atMhq164dnJclSxb3eO7cuVFfo/mhy4tqzaMtv2fPHldgoNT5q666yr744gs3X23fNWyd2sKHUm2+UvQ9e/fudUF8ixYt7L777nND4uXPn/+UPzcAwF+yp3XnMmo7FmvnMimRM2fOYKczkjdv3ritGwAAZEzKHBw5cqSrSJg/f761a9fO8uTJ48aJFz2nwLtz587usVLeNR68KiomTpxojRs3doG6Am6Pgu6///7btXWvUKGCe42Gq5s2bVpwmX79+rmMRWUrKs2+adOmVrZsWfdaefrpp10NvmrolZ5/7733use7d+8+7fsIAJBJg/mkOpfRj1a8dOrUyXUkAwAAECvVfKvZX48ePVwndgqs1SGdepoXpb2Hjv2uGvgmTZrYyy+/bL1793ad1N111122YsWK4DJKrVchgdLvFYCPGjXKevbsGfa+CvDPOOMMN0Tdueee64J61fB7qfiqlVeWooa6O378uC1YsMBuu+02VyECAMhcsqhCPC3eWD9o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oDuUHis1PhrNP9nyqrl/+OGHrXbt2taxY0e74YYbXNp91qz/u1RLlSrl/nbr1s1efvllu+OOO2zXrl323XffWf78+d1zxYsXtyVLltiiRYts48aNNmPGDPv666/jvg+A9Op092VRokQJe/fdd23dunUuG+a3335z16gK/QAAAFILwTyQjowdO9a++uor++WXX+yLL75wwXq1atVcbb14QX2vXr3ss88+c7XzjzzyiAsu7r33XvfckCFDrHHjxi6g79u3b6KZAkBGlBZ9WZQtW9Zdm0888YR73dNPP+2a3fTu3TvVPy8AAMi8COaBFFDa+rFjx6xQoUJh8/V469atUV+j+clZXlS7+Pfff9vFF1/sHv/111/u78qVK4PLHDlyxNUIqkbeq91XTeHAgQOtaNGirma+X79+p/BpAf9Ii74sVFig9U+bNs1dsyqQ0/rUWaVH1+eXX37pRp/Yt2+fK7CLzAAAAABIDoJ5IAWOHj3q0tiVDu/JkiWLezx37tyor9H80OWlTp06iS4vF1xwgZ133nnBIF7veejQIStTpkxwGaX7X3TRRS6lPrSwYdSoUfbQQw9Zu3btrEWLFqf0eQE/SKu+LKI5++yzXeDuGTx4sOXKlcuuv/56q1ChgmtGo6AeAAAgpbKn+JVAJqdU3pEjR9rChQtt/vz5LmjOkyePa18rem7z5s3WuXNn91i90M+aNcvVHE6cONGlwl911VXBQFuv7dq1q40fP97V1pcuXdr++9//uva3Ci5k79699vbbb7sU4j/++MMF8OrZXrwe7fWcgv4VK1a44EGp+qqhBDJzXxZKhT+VvizUrEW17roulT6vtHsF/OqMMpKW+c9//mPPPPNMWM28rm3VyIvWBQAAcCoI5oEUGjdunGuH26NHD3fjv3TpUqtbt64bEs67eQ+90VcNfJMmTVzHdQoGNKzcXXfd5YJuOX78uFWsWNGaNm3qhpdTT/VTp061l156yaXSexS8K8VfY83nzp3bfvrpJ7vpppuCab9atk+fPq62Xh16zZ492xUcAEh5XxYeBePqJE9NW1Rb/+2334Ytq6YtCv5VuKZO8Tyvv/6668/illtucZkACux//vnn0/o5AABAxkIwD5wCpc5qiqZWrVoJ5n366aduikbp8yoMOBkF8grovRr5SOocTxOQ2aRFXxahwXyRIkVs5syZ9uOPPyZo2qIO9pRhc/vtt7uAvlOnTtahQwd78803U/hpAQBAZkebeQBAhpBWfVl4NfIaIlLv740wEenPP/+0oUOH2j333GOvvvqqPf744yn8pAAAANTMAwAykLToy8IL5NWHhdrJhw6D57XH1+gSame/Zs0ay58/v8vcoS8LAABwKgjmkWns7/X/OrRCyuV5IfH0YyAz9mWhmvxLLrnETSooCKXMAMmWLZtrklOsWDHbs2ePa1ev8egBAABSimAeAJChnO6+LFTbrykpTz31VJLPAwAAJBdt5gEAAAAA8BmCeQAAAAAAfIY0ewBAmqEvi/igLwsAADIfauYBAAAAAPAZgnkAAAAAAHyGYB4AAAAAAJ8hmAcAAAAAwGcI5gEAAAAgE2ndurWtX7/eDh48aPPmzbOqVasmuXzDhg1t1apVbvnly5dbvXr1wp7v2rWre37fvn22c+dOmzZtmlWrVi1smS+++MI2btzo1rFlyxYbNWqUFSlSJFU+X2ZBMA8AAAAAmUSjRo1swIAB1r17d6tcubItW7bMpkyZYgULFoy6fPXq1W3MmDH23nvvWaVKlWzChAluKl++fHCZNWvWWJs2baxChQpWs2ZN27Bhg02dOtUKFCgQXGbmzJnuvcuUKWP33HOPlS5d2j799NPT8pkzKoJ5AAAAAMgk2rdvb8OGDbMRI0a42vSWLVvagQMHrHnz5lGXb9u2rU2ePNn69+9vq1evti5dutjixYtd8O5RsD9jxgxX279y5Ur3HmeffbZVrFgxuMxrr71mP/30k23atMnmzp1rr7zyil1zzTWWPfv/RksvXry4ffnll65mXzX8v/zyS4IMAIQjmAcAAACATCBHjhxWpUoVmz59enBeIBBwj1UDH43mhy4vqslPbHm9R4sWLWz37t2u1j+a/Pnz2wMPPGA//vijHTt2zM0bPHiw5cqVy66//npXw9+xY0cX1CNxBPMAAAAAkAko7V014du2bQubr8eFCxeO+hrNj2X522+/3fbu3WuHDh2yp59+2urUqWP//PNP2DKqjffa1asmvn79+sHn9PiHH35wNfKq4Z84caLNnj07Dp864yKYBwAAAACcErWJv/LKK+3aa691afnjxo1L0A6/X79+rt29Av3jx4+7TvA8r7/+ur344os2Z84c69atm6udR9II5gEAAAAgE9ixY4dLay9UqFDYfD3eunVr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tNtuuy3sBn/9+vXu/eKF367Mg+sz9a/PU9G/f39r2rSplSxZMi7rA8F8uqWLTsH8OeecE/d16+JXkH065M2b15555hnLSHLnzm2PPvqovffee5aepdZx1g/lAw884H6kcPrppkSFNJpq165tx44ds6+//vq0b0fRokXdpOv78ssvt2bNmlndunWTvC6ULaKb9JN99+n6uvjii1Nhq4H4yOjX4S+//OI+W/Hixa1WrVr2ySefuJo5FRJ7BfTZs2e3adOmueyvBg0auADhvvvus59//jlZ9y7/+c9/3Pr3799vqYnfrsyD6zP9Xp8qWJsyZYq1atUqLuvD/6iqlykdTcOHDw98+eWXgZUrVwb69u0bnF+/fv2AeI/PPffcwEcffRT4888/A/v37w8sX7480Lhx47D1RCpRokTghhtucP+fffbZgbx58wYOHDgQqFu3btg23HXXXYE9e/YEcufO7R4XK1YsMHbs2MCuXbsC//zzT2DChAluXYl9Bj0n2n6tp2DBgsHnlixZEujatWvwseizhb5e79O0adOwdd17772B77//3m3v/PnzA5dcckngqquuCixYsCCwd+/ewKRJkwIFChQI+/yff/55oEuXLoHt27cH/v3338CQIUMCOXLkCC5z6623BmbPnu3eb8eOHYGvvvoqUKpUqSSPzz333BPYtm1b2Dxvn952222BZcuWBQ4ePBiYO3duoHz58jEfL2/dmq/PqO2ZNm1a4Mwzzww+/+ijj7rzQutftWpVoFWrVgn2+RVXXBG2TTrOeqz9qc95yy23uHVon33zzTeBwoULh21DUu/hTb///nugefPmaX6tZLbJO6dD59WoUcMdZ537keeApuuvvz7w008/BQ4dOhTYsmVLoE+fPoFs2bLFfM498sgjgV9++SX4+jfeeCPR7WvYsKFbLnT9oZPOyfXr1yf6en0v6PthypQp7vvGm6/P431/hZ7Loa+N/H701qXt37hxozvfBw8eHMiaNWvg2WefDfz111/uOu7cuXPYeqRly5bu+0T7ROe69lHoMq+88krg119/ddexnu/Ro0cge/bsaX5+MJ2eKbNch5Hzy5Qp49bbs2fPsOuyePHiSe6vaL/x3qTrUdeyfju9eTNnzkxw7+I9d+2117rnde3t3LkzMHny5MA555zjnsuSJYu7tteuXeu2U9d95PXNb1fGn7g+U/f61JQzZ073O7hp0yb3nrrmQq+rcuXKuftp3XcrBtC9e+i99UMPPeRem9bnimWQiZr5dOr48ePWuXNnVyKWWBuvM844wxYtWmS33367K/F755137IMPPrCqVau655Vyo1I6zfdKKJViE2rv3r2utLJJkyZh81V6rVQc1ZKpdE+laFr2uuuusxo1ati+ffts8uTJliNHjiQ/x5gxY+y3336zLl26nPI+UXrcyy+/bJUrV3alrB999JH997//dZ9T26WavB49eoS9RiWyl112md144412//33u9LJ0BT/PHny2IABA+yqq65yy544ccK1s8+SJUui26H30n6Ppl+/ftahQwd3DJT69NVXX7n9F8vx0vHR/nr//feD2/zZZ58Ft0XHSJ9PKf56XudHz5497eGHH455H5555pmuhPihhx6y66+/3pXqKuXJE+t7zJ8/3+0HpC2dvw8++KCtXbvWlXZHUo3ApEmTbMGCBS5VXSXhqvV+8cUXYzrnWrZsaYMHD3bnqlIU77zzTnc9J0Y1AHv27HHfX6fi+eeft3vuuceqVKlySuspXbq01atXz9WE6PrXZ584caIVK1bM1X4o+6lXr15WrVq1sNfpnB8/frzbZ0pR/Pjjj61s2bLB5/VdqBqWcuXKue+fxx9/3J5++ulT2lb4V0a9DiP9+uuvrsZTv6Oi3zi9R8OGDS1r1pTdTiqFV7WECxcuDM7T+nWv8tJLLwXvXUT7bsaMGbZy5UqrXr261axZ0/3GZsuWLdgWV98dun51ber3LDI7jd+uzIfrM77Xp6jJin5Tn3rqKbdPnnjiCRcXePvz+++/t8OHD9tNN93kfse177x7Ye86vPDCC2k6F0dpXqLAlHip4o8//hh49913o9Y8RZtUEtavX7/gY5VgDxw4MGyZyBpbrTe0Ft6rrVettR4/8MADroY2dB2q3VbJeJ06daJuR2jJp2qCDx8+HCyVS2nNfGip33333efm1apVKzivY8eOYdup/agSVO9zaXriiSfcZ1UJfrTtPu+889x6Q2vUIycdG++YRO7TRo0aBeflz5/f7SNlFMRyvCpVqpRkKapKPiNr8l944YXADz/8kGCfRzvO2p8SWjqqWnfVUMb6Ht706quvBr799ts0v1Yy26Rz+ujRo66WWZNs3rzZnTvRzoGXX345wbWrY+5dAyc755RF4pXyn2zS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", + "image/png": "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PfPMM8FRPRSgq0nTG2+8EdOIHrt377aWLVva8OHDoy4zbtw49/138803u8eFChVyw4PWqFHDfvrpJzcvT5487ntRy0ybNs0KFChg7733nt1yyy3uuU2bNln37t1tyJAhqfr5AQAZD/ewqYd72Mwnb4wxa4razIsy8Op5XtXtZeXKlda3b19bv359SlcJAJlOjhw5XFa8R48ewXmqDj916lSXdY/FOeec49ajKvfRnHfeeXb77bdb48aNg/N27tzpOhB95JFHbNGiRXbkyBF78sknbdu2bW7IUHn11Vft0ksvtbp167rAXx2fnn322af9mQEAAHD6UhTMK0szfvx410b+559/dvOuvfZaW7Fihf33v/91N6EAgFMrXLiwq+quIDqUHpcvXz6mdSh7v2XLlkS/exXEq1T3iy++CJuvDLzazOs5VcHXKCIaTWTPnj3B0UTUD4oX3G/cuDGFnxIAAADpIpjv2bOn9enTxzp06BA2X5kl3VQqywQASHvt2rWzhg0bunb0yq5H06RJExsxYkSC5zWKiAJ4VbVXx3vqWO/rr792nelt3brVBgwY4NrYqx2/2uQr8J89e/YZ+mQAAABI9Q7wVLX+448/TjBfvdurSiYAIDaqvn78+PEEnc7psQLqpLRt29bat2/vakslNiTodddd5zL8H330Udh8dXp3xx13uIKAWbNmuQz8008/7YJ6rzr+xIkTrVSpUq7wVp3rqR39W2+9ddqfGQAAAHEK5jWMUaVKlRLM1zxleQAAsTl27Jirxl6rVq3gPHWAp8dJZcFfeOEF69Spk6sW71WDj+bxxx+3BQsW2LJlyxK0sxdVrw+lx+pQL7SwYdiwYfbwww+7flKaNWuWos8JAACAdFDNftCgQfbhhx+6TvCU0fHazKu6p4ZXAgDETt+bQ4cOdUH3vHnzXNCcO3duGzx4sHtez23evNk6duzoHr/44ovWrVs3a9Sokf3+++/BrP7+/fvtwIEDYT2h3n///S6DH0kFBeoBX+vWupSRb9q0qRtidMKECW4ZDZWnggL1h5IrVy6XyV+1atUZ2isAAABI9WBePRyrwyTdIHo9MKvzpS5duti7776bklUCQKY1evRoN6a8gmqNCa/ORZVx92o6qSO60Az6U0895YLr0DHjRd/BCsA9qkKvLL/GpI+k3uz1Hq+//rp9//33rjd8Be133XVX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O8A7duyYLVy40GrVqhWclyVLFvd49uzZUV+j+aHLS+3atRNdHgAAAACAjCbu1exVBX7o0KG2YMECmzdvnrVu3dpy585tgwcPds/rOVUt6dixo3vct29f+/HHH10v+BMmTLCGDRvaFVdcYc2aNYvzJwEAAAAAIJME86NHj3Zjynfr1s11YrdkyRKrU6eObd++3T1fsmRJO3nyZHB5ZeAbNWpkr732mnXv3t3Wrl3rerJfsWJFHD8F0rsjR464jhD1F0D6wHUJpC9ck0D6w3WJpGTRaHBJLgEAAAAAANKVuLaZBwAAAAAAyUcwDwAAAACAzxDMAwAAAADgMwTzAAAAAAD4DMF8JnTDDTdYIBCw/PnzW0am4Q3HjRuXJus+99xzbdu2bVaqVKl0s0+1LdqG//znP2m2TYUKFXKfu0SJEqm2TqTdOYDk0/6766674r0Z8CGuv9N300032cqVKy1r1rS7PR05cqQb3hiZD9do+rpG//3vf9uff/5p55xzTqpsW2ZFMJ9Og1B92bRr1y5svm4wNT85pk+fbn369AmbN2vWLDcM4D///GNp/YWpwC9Pnjxhzy1evNg6d+5sfvbSSy/ZV199ZRs3brT0Ki2O886dO23YsGHWtWvXVFsnkv/d4E07duyw7777zipWrHjGt6VgwYL27rvv2urVq+3gwYPuWujbt6/ly5cvxevU94I+14ABA8Lm68ZL873CMyAzX3tpff1pOnbsmP3999/2448/WqtWrSxnzpxhy1544YU2YsQI27x5sx06dMjdkH/55ZdWrly50yoYe/PNN93Qw96QxNom3TOkJq1fv+Gns6+Q/nCNxucaPR2rVq2yOXPmULh2mgjm0yldeArmCxQokOrr1heAguwzIW/evPb8889bRnL22Wfb448/bh9//LGlZ2l1nPWD+eCDD7ofKpx5ujlRIY2mWrVq2fHjx+2bb74549tx/vnnu0nX92WXXWaPPvqo1alTJ8nrQrVFNmzYcMrvPl1fZcuWTYOtBvx/7aXl9ffLL7+4z1eyZEmrWbOmff7559ahQwdXOOwVzGfPnt2mTJnian3de++9Ljho0KCBLV++/LTuWa699lorU6aMjR071tLSihUrbN26dfbQQw+l6fvgzOMa9d81qnvKp556yrJly5Zq68yMlOplSkfT4MGDA+PHjw+sXLky8MYbbwTn33XXXQHxHp977rmBTz/9NLBp06bAgQMHAsuWLQs0bNgwbD2RSpUqFbjhhhvc//nz5w/kzZs3cPDgwUCdOnXCtuHuu+8O7N27N3D22We7xxdccEFg1KhRgd27dwd27twZ+PLLL926EvsMek60/VpPkSJFgs8tXrw40Llz5+Bj0WcLfb3ep3HjxmHruv/++wMzZsxw2ztv3rzAxRdfHLjiiisC8+fPD+zbty/w7bffBgoXLhz2+ceNGxd45ZVXAtu3bw/8888/gQEDBgRy5MgRXObWW28NzJw5073fjh07Al9//XXgoosuSvL41KtXL7Bt27awed4+ve222wJLly4NHDp0KDB79uxAhQoVYj5e3ro1X59R2zNlypTAOeecE3z+8ccfd+eF1r9q1arAU089lWCf/+c//wnbJh1nPdb+1Oe85ZZb3Dq0z7777rtAsWLFwrYhqffwpnXr1gWaNGkS92sls03eOR0679prr3XHWed+5Dmg6frrrw/MnTs3cPjw4cCWLVsCPXr0CGTLli3mc+6xxx4L/PLLL8HX9+vXL9Htu++++9xyoesPnXRObtiwIdHX63tB3w+TJk1y3zfefH0e7/sr9FwOfW3k96O3Lm3/xo0b3fnev3//QNasWQMvvPBC4K+//nLXcceOHcPWI82bN3ffJ9onOte1j0KX6dmzZ+DXX39117Ge79atWyB79uxxPz+Y4nft6XFGuf4i55crV86t99VXXw27HkuWLJnkPov2257UpM82evTo4GNd55G8+wL9rn3wwQeBrVu3ut+q5cuXB26//fbga6+55prA9OnT3TW6a9euwMSJEwMFChQIPt+pUyd3PxHv84op9Sau0TN/jXrTHXfc4e7LdS3+/fffgS+++CL4XM6cOd1v5h9//OG2ce3atWH3j7on1+tuuummuJ9D5tOJzHw6deLECevYsaM988wzibZPPuuss2zhwoV2++23u1K/Dz/80IYPH27VqlVzz6vajUrqNN8rqVQ1m1D79u1zpZaNGjUKm6/Mq6rjKEumEr5Jkya5ZWvUqOFK5vbv328TJ060HDlynLJt2m+//WavvPLKae8TVe1W1Z4qVaq40tZPP/3UVffR59R2KZPXrVu3sNeoZFZtcm688UZ74IEHXAllaBX/3LlzW+/eve2KK65wy6rakNrZZ8mSJdHt0Htpv0fz1ltvWdu2bd0x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", "text/plain": [ "
" ] @@ -450,7 +455,7 @@ } ], "source": [ - "labels = [\"Native Numba (baseline)\", \"Blosc2+Numba\", \"Blosc2+DSL(tcc)\", \"Blosc2+DSL(cc)\"]\n", + "labels = [\"Native Numba (baseline)\", \"Blosc2+Numba\", \"Blosc2+DSL (tcc)\", \"Blosc2+DSL (cc)\"]\n", "first_times = [t_numba_native_first, t_b2_numba_first, t_dsl_tcc_first, t_dsl_cc_first]\n", "best_times = [t_numba_native, t_b2_numba, t_dsl_tcc, t_dsl_cc]\n", "\n", @@ -481,8 +486,8 @@ "id": "a1e8dbea24ecc319", "metadata": { "ExecuteTime": { - "end_time": "2026-02-13T17:59:00.188247Z", - "start_time": "2026-02-13T17:59:00.154652Z" + "end_time": "2026-02-16T06:11:56.424876Z", + "start_time": "2026-02-16T06:11:56.390151Z" } }, "outputs": [], diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 41850e15..681d5e30 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -162,6 +162,52 @@ def _replace_scalar_names_preserving_source( return out +def _fold_numeric_cast_calls_preserving_source(text: str, body_start: int): + """Fold float() and int() calls into literals. + + miniexpr parses DSL function calls in a restricted way, and scalar specialization can + produce calls like float(200) that fail to parse. Fold those into literals while + preserving source formatting/comments elsewhere. + """ + try: + tree = ast.parse(text) + except Exception: + return text + + line_starts = _line_starts(text) + edits = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if node.keywords or len(node.args) != 1: + continue + if not isinstance(node.func, ast.Name) or node.func.id not in {"float", "int"}: + continue + + arg = node.args[0] + if not isinstance(arg, ast.Constant) or not isinstance(arg.value, int | float | bool): + continue + + start_abs = _to_abs(line_starts, node.lineno, node.col_offset) + if start_abs < body_start: + continue + end_abs = _to_abs(line_starts, node.end_lineno, node.end_col_offset) + + if node.func.id == "float": + repl = repr(float(arg.value)) + else: + repl = repr(int(arg.value)) + edits.append((start_abs, end_abs, repl)) + + if not edits: + return text + + out = text + for start, end, repl in sorted(edits, key=lambda e: e[0], reverse=True): + out = f"{out[:start]}{repl}{out[end:]}" + return out + + def specialize_miniexpr_inputs(expr_string: str, operands: dict): """Inline scalar operands as constants for miniexpr compilation.""" scalar_replacements = {} @@ -183,6 +229,7 @@ def specialize_miniexpr_inputs(expr_string: str, operands: dict): rewritten, body_start = _remove_scalar_params_preserving_source(expr_string, scalar_replacements) rewritten = _replace_scalar_names_preserving_source(rewritten, scalar_replacements, body_start) + rewritten = _fold_numeric_cast_calls_preserving_source(rewritten, body_start) return rewritten, array_operands diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 2b102132..0e9f74f8 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -93,6 +93,12 @@ def kernel_loop_param(x, y, niter): return acc +@blosc2.dsl_kernel +def kernel_scalar_float_cast(x, niter): + offset = float(niter) + return x + offset + + @blosc2.dsl_kernel def kernel_fallback_kw_call(x, y): return np.clip(x + y, a_min=0.5, a_max=2.5) @@ -330,6 +336,44 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr +def test_dsl_kernel_scalar_float_cast_inlined_without_float_call(monkeypatch): + if blosc2.IS_WASM: + pytest.skip("miniexpr fast path is not available on WASM") + + import importlib + + lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") + old_try_miniexpr = lazyexpr_mod.try_miniexpr + lazyexpr_mod.try_miniexpr = True + + original_set_pref_expr = blosc2.NDArray._set_pref_expr + captured = {"calls": 0, "expr": None, "keys": None} + + def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): + captured["calls"] += 1 + captured["expr"] = expression.decode("utf-8") if isinstance(expression, bytes) else expression + captured["keys"] = tuple(inputs.keys()) + return original_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc, jit=jit) + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_expr", wrapped_set_pref_expr) + + try: + a, _, a2, _ = _make_arrays(shape=(32, 32), chunks=(16, 16), blocks=(8, 8)) + niter = 3 + expr = blosc2.lazyudf(kernel_scalar_float_cast, (a2, niter), dtype=a2.dtype) + res = expr.compute() + expected = kernel_scalar_float_cast.func(a, niter) + + np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) + assert captured["calls"] >= 1 + assert captured["keys"] == ("x",) + assert "def kernel_scalar_float_cast(x):" in captured["expr"] + assert "offset = 3.0" in captured["expr"] + assert "float(3)" not in captured["expr"] + finally: + lazyexpr_mod.try_miniexpr = old_try_miniexpr + + def test_lazyudf_jit_policy_forwarding(monkeypatch): if blosc2.IS_WASM: pytest.skip("miniexpr fast path is not available on WASM") From 3eb60536eae3826ba8771642b825026556407115 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 07:24:44 +0100 Subject: [PATCH 07/13] Add strict_miniexpr compute policy and fail-fast defaults for DSL Introduce strict_miniexpr in lazy compute kwargs to control miniexpr failure handling: - Default strict_miniexpr=True for DSL kernels - Default strict_miniexpr=False for non-DSL expressions - Raise RuntimeError on miniexpr compile/eval failure when strict mode is enabled - Keep existing silent fallback behavior when strict mode is disabled Also: - Document strict_miniexpr in compute() kwargs - Add tests for DSL strict-by-default failure behavior - Add tests for non-DSL fallback by default and strict override raising - Update existing DSL tests that intentionally rely on fallback to pass strict_miniexpr=False --- src/blosc2/lazyexpr.py | 11 +++++++- tests/ndarray/test_dsl_kernels.py | 30 ++++++++++++++++++-- tests/ndarray/test_lazyexpr.py | 47 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 6220d6d6..6c0593b0 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -416,6 +416,9 @@ def compute( Keyword arguments that are supported by the :func:`empty` constructor. These arguments will be set in the resulting :ref:`NDArray`. Additionally, the following special kwargs are supported: + - ``strict_miniexpr`` (bool): controls whether miniexpr compilation/execution + failures are raised instead of silently falling back to regular chunked eval. + Defaults to ``True`` for DSL kernels and ``False`` otherwise. Returns ------- @@ -1325,9 +1328,13 @@ def fast_eval( # noqa: C901 fp_accuracy = kwargs.pop("fp_accuracy", blosc2.FPAccuracy.DEFAULT) jit = kwargs.pop("jit", None) jit_backend = kwargs.pop("jit_backend", None) + strict_miniexpr = kwargs.pop("strict_miniexpr", None) dtype = kwargs.pop("dtype", None) requested_shape = kwargs.pop("shape", None) where: dict | None = kwargs.pop("_where_args", None) + if strict_miniexpr is None: + # Be strict by default for DSL kernels to avoid silently losing DSL fast-path regressions. + strict_miniexpr = bool(is_dsl) if where is not None: # miniexpr does not support where(); use the regular path. use_miniexpr = False @@ -1468,8 +1475,10 @@ def fast_eval( # noqa: C901 # Exercise prefilter for each chunk for nchunk in range(res_eval.schunk.nchunks): res_eval.schunk.update_data(nchunk, data, copy=False) - except Exception: + except Exception as e: use_miniexpr = False + if strict_miniexpr: + raise RuntimeError("miniexpr evaluation failed while strict_miniexpr=True") from e finally: res_eval.schunk.remove_prefilter("miniexpr") global iter_chunks diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index 0e9f74f8..cc6d83db 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -320,7 +320,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, (a2, b2, niter), dtype=a2.dtype, ) - res = expr.compute() + res = expr.compute(strict_miniexpr=False) expected = kernel_loop_param.func(a, b, niter) np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) @@ -374,6 +374,30 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr +def test_dsl_kernel_miniexpr_failure_is_strict_by_default(monkeypatch): + if blosc2.IS_WASM: + pytest.skip("miniexpr fast path is not available on WASM") + + import importlib + + lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") + old_try_miniexpr = lazyexpr_mod.try_miniexpr + lazyexpr_mod.try_miniexpr = True + + def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): + raise ValueError("forced miniexpr failure") + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_expr", failing_set_pref_expr) + + try: + _, _, a2, b2 = _make_arrays(shape=(32, 32), chunks=(16, 16), blocks=(8, 8)) + expr = blosc2.lazyudf(kernel_loop, (a2, b2), dtype=a2.dtype) + with pytest.raises(RuntimeError, match="strict_miniexpr=True"): + _ = expr.compute() + finally: + lazyexpr_mod.try_miniexpr = old_try_miniexpr + + def test_lazyudf_jit_policy_forwarding(monkeypatch): if blosc2.IS_WASM: pytest.skip("miniexpr fast path is not available on WASM") @@ -396,8 +420,8 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, try: _, _, a2, b2 = _make_arrays(shape=(32, 32), chunks=(16, 16), blocks=(8, 8)) expr = blosc2.lazyudf(kernel_loop, (a2, b2), dtype=a2.dtype, jit=False) - _ = expr.compute() - _ = expr.compute(jit=True) + _ = expr.compute(strict_miniexpr=False) + _ = expr.compute(jit=True, strict_miniexpr=False) assert seen[0] is False assert seen[1] is True finally: diff --git a/tests/ndarray/test_lazyexpr.py b/tests/ndarray/test_lazyexpr.py index 8d5047c4..79f1de15 100644 --- a/tests/ndarray/test_lazyexpr.py +++ b/tests/ndarray/test_lazyexpr.py @@ -1563,6 +1563,53 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr +@pytest.mark.skipif(blosc2.IS_WASM, reason="miniexpr fast path is not available on WASM") +def test_lazyexpr_miniexpr_failure_falls_back_by_default(monkeypatch): + import importlib + + lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") + old_try_miniexpr = lazyexpr_mod.try_miniexpr + lazyexpr_mod.try_miniexpr = True + + def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): + raise ValueError("forced miniexpr failure") + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_expr", failing_set_pref_expr) + + try: + na = np.arange(32 * 32, dtype=np.float32).reshape(32, 32) + a = blosc2.asarray(na, chunks=(16, 16), blocks=(8, 8)) + b = blosc2.asarray(np.ones_like(na), chunks=(16, 16), blocks=(8, 8)) + res = blosc2.lazyexpr("a + b", operands={"a": a, "b": b}).compute() + np.testing.assert_allclose(res[...], na + 1.0, rtol=1e-6, atol=1e-6) + finally: + lazyexpr_mod.try_miniexpr = old_try_miniexpr + + +@pytest.mark.skipif(blosc2.IS_WASM, reason="miniexpr fast path is not available on WASM") +def test_lazyexpr_miniexpr_failure_raises_when_strict(monkeypatch): + import importlib + + lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") + old_try_miniexpr = lazyexpr_mod.try_miniexpr + lazyexpr_mod.try_miniexpr = True + + def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): + raise ValueError("forced miniexpr failure") + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_expr", failing_set_pref_expr) + + try: + na = np.arange(32 * 32, dtype=np.float32).reshape(32, 32) + a = blosc2.asarray(na, chunks=(16, 16), blocks=(8, 8)) + b = blosc2.asarray(np.ones_like(na), chunks=(16, 16), blocks=(8, 8)) + expr = blosc2.lazyexpr("a + b", operands={"a": a, "b": b}) + with pytest.raises(RuntimeError, match="strict_miniexpr=True"): + _ = expr.compute(strict_miniexpr=True) + finally: + lazyexpr_mod.try_miniexpr = old_try_miniexpr + + # Test the LazyExpr when some operands are missing (e.g. removed file) def test_missing_operator(): a = blosc2.arange(10, urlpath="a.b2nd", mode="w") From 0e43e92410573868ba329f15e70413785617f8f7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 07:29:54 +0100 Subject: [PATCH 08/13] Fix for windows --- src/blosc2/lazyexpr.py | 3 ++- tests/ndarray/test_dsl_kernels.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 6c0593b0..773522c5 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -1408,9 +1408,10 @@ def fast_eval( # noqa: C901 and not operands_miniexpr ): dummy_name = "__me_dummy0" + dummy_dtype = dtype if dtype is not None else np.uint8 expr_string_miniexpr = _inject_dummy_param_for_zero_input_dsl(expr_string_miniexpr, dummy_name) operands_miniexpr = { - dummy_name: blosc2.zeros(shape, dtype=np.uint8, chunks=chunks, blocks=blocks) + dummy_name: blosc2.zeros(shape, dtype=dummy_dtype, chunks=chunks, blocks=blocks) } if math.prod(shape) <= 1: # Avoid miniexpr for scalar-like outputs; current prefilter path is unstable here. diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index cc6d83db..bc6ae90d 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -219,6 +219,23 @@ def test_dsl_kernel_with_no_inputs_requires_shape_or_out(): _ = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32) +def test_dsl_kernel_with_no_inputs_handles_windows_dtype_policy(monkeypatch): + if blosc2.IS_WASM: + pytest.skip("miniexpr fast path is not available on WASM") + + import importlib + + lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") + monkeypatch.setattr(lazyexpr_mod.sys, "platform", "win32") + monkeypatch.setattr(lazyexpr_mod, "_MINIEXPR_WINDOWS_OVERRIDE", False) + + shape = (10, 10) + expr = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32, shape=shape) + res = expr[:] + expected = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) + np.testing.assert_equal(res, expected) + + def test_dsl_kernel_index_symbols_float_cast_matches_expected_ramp(): shape = (32, 5) x2 = blosc2.zeros(shape, dtype=np.float32) From 98b247754dd9b094b0c81813f028be582b2e976c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 08:03:33 +0100 Subject: [PATCH 09/13] Collapse into a numpy scalar in 0-d and 1-d reduction results --- src/blosc2/lazyexpr.py | 8 +++++++- tests/ndarray/test_dsl_kernels.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 773522c5..e014ecd8 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -2366,7 +2366,13 @@ def reduce_slices( # noqa: C901 dtype = np.float64 out = convert_none_out(dtype, reduce_op, reduced_shape) - out = out[()] if reduced_shape == () else out # undo dummy dim from inside convert_none_out + if reduced_shape == (): + # convert_none_out() may allocate shape (1,) as an internal buffer for scalar reductions. + # Collapse it to a numpy scalar while handling both 0-d and 1-d singleton arrays. + if isinstance(out, np.ndarray): + out = out[()] if out.ndim == 0 else out[0] + else: + out = out[()] final_mask = tuple(np.where(mask_slice)[0]) if np.any(mask_slice): # remove dummy dims out = np.squeeze(out, axis=final_mask) diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index bc6ae90d..de0a3d87 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -214,6 +214,16 @@ def test_dsl_kernel_with_no_inputs_works_with_explicit_shape(): np.testing.assert_equal(res, expected) +def test_dsl_kernel_with_no_inputs_sum_returns_scalar(): + shape = (10, 5) + expr = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32, shape=shape) + result = expr.sum() + + expected = np.arange(np.prod(shape), dtype=np.float32).reshape(shape).sum() + assert np.isscalar(result) + np.testing.assert_allclose(result, expected, rtol=0.0, atol=0.0) + + def test_dsl_kernel_with_no_inputs_requires_shape_or_out(): with pytest.raises(ValueError, match="shape"): _ = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32) From 306395968d2d63a2e2dc19c45fc47eeda996acca Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 10:31:34 +0100 Subject: [PATCH 10/13] Avoid the use of blosc2.linspace because it is too heavy --- tests/ndarray/test_reductions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/ndarray/test_reductions.py b/tests/ndarray/test_reductions.py index a3d445d8..42c75420 100644 --- a/tests/ndarray/test_reductions.py +++ b/tests/ndarray/test_reductions.py @@ -439,16 +439,14 @@ def test_fast_path(chunks, blocks, disk, fill_value, reduce_op, axis): assert np.allclose(res, nres) # Try with a slice - b = blosc2.linspace(0, 1, blocks=blocks, chunks=chunks, shape=shape, dtype=a.dtype) - nb = b[:] slice_ = (slice(5, 7),) if reduce_op in {"cumulative_sum", "cumulative_prod"}: axis = 0 if axis is None else axis oploc = "npcumsum" if reduce_op == "cumulative_sum" else "npcumprod" - nres = eval(f"{oploc}((na + nb)[{slice_}], axis={axis})") + nres = eval(f"{oploc}((na - .1)[{slice_}], axis={axis})") else: - nres = getattr((na + nb)[slice_], reduce_op)(axis=axis) - res = getattr(a + b, reduce_op)(axis=axis, item=slice_) + nres = getattr((na - 0.1)[slice_], reduce_op)(axis=axis) + res = getattr(a - 0.1, reduce_op)(axis=axis, item=slice_) assert np.allclose(res, nres) From 83b1dc99357eeb415a5205895e7004cd34f53159 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 11:33:48 +0100 Subject: [PATCH 11/13] Disable STUNE to get a hint on the blocksize because it is a bit too heavy --- src/blosc2/core.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/blosc2/core.py b/src/blosc2/core.py index 085ef942..fa392790 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1586,24 +1586,25 @@ def compute_chunks_blocks( # noqa: C901 if blocks is None: # Get the default blocksize for the compression params - # Using an 8 MB buffer should be enough for detecting the whole range of blocksizes - nitems = 2**23 // itemsize # compress2 is used just to provide a hint on the blocksize # However, it does not work well with filters that are not shuffle or bitshuffle, # so let's get rid of them - filters = cparams.get("filters", None) - if filters: - cparams2 = copy.deepcopy(cparams) - for i, filter in enumerate(filters): - if filter not in (blosc2.Filter.SHUFFLE, blosc2.Filter.BITSHUFFLE): - cparams2["filters"][i] = blosc2.Filter.NOFILTER - else: - cparams2 = cparams + # filters = cparams.get("filters", None) + # if filters: + # cparams2 = copy.deepcopy(cparams) + # for i, filter in enumerate(filters): + # if filter not in (blosc2.Filter.SHUFFLE, blosc2.Filter.BITSHUFFLE): + # cparams2["filters"][i] = blosc2.Filter.NOFILTER + # else: + # cparams2 = cparams # Force STUNE to get a hint on the blocksize - aux_tuner = cparams2.get("tuner", blosc2.Tuner.STUNE) - cparams2["tuner"] = blosc2.Tuner.STUNE - src = blosc2.compress2(np.zeros(nitems, dtype=f"V{itemsize}"), **cparams2) - _, _, blocksize = blosc2.get_cbuffer_sizes(src) + # aux_tuner = cparams2.get("tuner", blosc2.Tuner.STUNE) + # cparams2["tuner"] = blosc2.Tuner.STUNE + # src = blosc2.compress2(np.zeros(nitems, dtype=f"V{itemsize}"), **cparams2) + # _, _, blocksize = blosc2.get_cbuffer_sizes(src) + # We disable internal STUNE path as it is a bit costly, specially for small arrays. + # The heuristic below should be good enough in general. + blocksize = 32 * 1024 # Minimum blocksize calculation min_blocksize = blocksize if platform.machine() == "x86_64": @@ -1634,7 +1635,9 @@ def compute_chunks_blocks( # noqa: C901 if blocksize < itemsize: blocksize = itemsize - cparams2["tuner"] = aux_tuner + # We disable internal STUNE path as it is a bit costly, specially for small arrays. + # See above. + # cparams2["tuner"] = aux_tuner else: blocksize = math.prod(blocks) * itemsize From 065a456ebd48156c259c59242a921bccf934adcc Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 13:09:12 +0100 Subject: [PATCH 12/13] Re-enable STUNE for some codecs (and see if that helps with windows CI recent failure) --- src/blosc2/core.py | 57 ++++++++++++++++++++++--------------- tests/ndarray/test_lossy.py | 7 +++++ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/blosc2/core.py b/src/blosc2/core.py index fa392790..2f798c90 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1586,25 +1586,40 @@ def compute_chunks_blocks( # noqa: C901 if blocks is None: # Get the default blocksize for the compression params - # compress2 is used just to provide a hint on the blocksize - # However, it does not work well with filters that are not shuffle or bitshuffle, - # so let's get rid of them - # filters = cparams.get("filters", None) - # if filters: - # cparams2 = copy.deepcopy(cparams) - # for i, filter in enumerate(filters): - # if filter not in (blosc2.Filter.SHUFFLE, blosc2.Filter.BITSHUFFLE): - # cparams2["filters"][i] = blosc2.Filter.NOFILTER - # else: - # cparams2 = cparams - # Force STUNE to get a hint on the blocksize - # aux_tuner = cparams2.get("tuner", blosc2.Tuner.STUNE) - # cparams2["tuner"] = blosc2.Tuner.STUNE - # src = blosc2.compress2(np.zeros(nitems, dtype=f"V{itemsize}"), **cparams2) - # _, _, blocksize = blosc2.get_cbuffer_sizes(src) - # We disable internal STUNE path as it is a bit costly, specially for small arrays. - # The heuristic below should be good enough in general. - blocksize = 32 * 1024 + # Check if we need STUNE for lossy codecs/filters that have specific blocksize requirements + codec = cparams.get("codec") + filters = cparams.get("filters", None) + needs_stune = codec in ( + blosc2.Codec.ZFP_RATE, + blosc2.Codec.ZFP_PREC, + blosc2.Codec.ZFP_ACC, + blosc2.Codec.NDLZ, + ) or (filters and any(f in (blosc2.Filter.NDMEAN, blosc2.Filter.NDCELL) for f in filters)) + + if needs_stune: + # Lossy codecs need proper blocksize calculation via STUNE + # Using an 8 MB buffer should be enough for detecting the whole range of blocksizes + nitems = 2**23 // itemsize + # compress2 is used just to provide a hint on the blocksize + # However, it does not work well with filters that are not shuffle or bitshuffle, + # so let's get rid of them + if filters: + cparams2 = copy.deepcopy(cparams) + for i, filter in enumerate(filters): + if filter not in (blosc2.Filter.SHUFFLE, blosc2.Filter.BITSHUFFLE): + cparams2["filters"][i] = blosc2.Filter.NOFILTER + else: + cparams2 = cparams + # Force STUNE to get a hint on the blocksize + aux_tuner = cparams2.get("tuner", blosc2.Tuner.STUNE) + cparams2["tuner"] = blosc2.Tuner.STUNE + src = blosc2.compress2(np.zeros(nitems, dtype=f"V{itemsize}"), **cparams2) + _, _, blocksize = blosc2.get_cbuffer_sizes(src) + cparams2["tuner"] = aux_tuner + else: + # We disable internal STUNE path for regular codecs as it is a bit costly, specially for small arrays. + # The heuristic below should be good enough in general. + blocksize = 32 * 1024 # Minimum blocksize calculation min_blocksize = blocksize if platform.machine() == "x86_64": @@ -1634,10 +1649,6 @@ def compute_chunks_blocks( # noqa: C901 # Fix for #364 if blocksize < itemsize: blocksize = itemsize - - # We disable internal STUNE path as it is a bit costly, specially for small arrays. - # See above. - # cparams2["tuner"] = aux_tuner else: blocksize = math.prod(blocks) * itemsize diff --git a/tests/ndarray/test_lossy.py b/tests/ndarray/test_lossy.py index eabc98a0..595cf14d 100644 --- a/tests/ndarray/test_lossy.py +++ b/tests/ndarray/test_lossy.py @@ -64,6 +64,13 @@ ) def test_lossy(shape, cparams, dtype, urlpath, contiguous): cparams_dict = cparams if isinstance(cparams, dict) else asdict(cparams) + codec = cparams_dict.get("codec") + if codec is not None: + # Skip if the codec library is not available in this build (e.g. some Windows builds). + try: + blosc2.clib_info(codec) + except ValueError: + pytest.skip(f"codec {codec} is not supported in this build") if cparams_dict.get("codec") == blosc2.Codec.NDLZ: dtype = np.uint8 array = np.linspace(0, np.prod(shape), np.prod(shape), dtype=dtype).reshape(shape) From 8bb44c5b1e1d8a529846d49d528f5699f7163362 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Feb 2026 17:57:49 +0100 Subject: [PATCH 13/13] Update to latest miniexpr sources --- CMakeLists.txt | 2 +- src/blosc2/blosc2_ext.pyx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c030eff..0b9f5f8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,7 +64,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 24c8ce8d02ff0d6f52c29ebc9406215a7b81607b + GIT_TAG 92a5a222b034b148f29d8ae2d02c53f444afd36d # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 3f9d5c87..80c8ab5d 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -556,6 +556,7 @@ cdef extern from "miniexpr.h": const void *address int type void *context + size_t itemsize ctypedef struct me_expr: int type