Repository navigation
Keep std/var and where() with scalars on the miniexpr path - #752
Conversation
Two common cases fell off miniexpr, and without numexpr they now run chunk by chunk through evaluate_chunk(): - Full std()/var() of float32/float64 (no axis, no where, same dtype) reduced (x - mean) ** 2 with the mean as a broadcast (1, ..., 1) operand. They are now two single-pass reductions: the mean, then the mean of (x - m) * (x - m) with m inlined as a scalar. The sign of m is folded into the operator, as miniexpr reductions mis-evaluate a negative literal on the right of a binary operator. Other cases keep the general path; std() and var() share one implementation. - where(cond, x, y) with a scalar x or y: specialize_miniexpr_inputs() inlined the scalar and dropped its operand before the where() was fused in, so the fused expression referenced a missing operand, miniexpr failed to compile it and the evaluation fell back. The where() is now fused before specializing. - The bare-array LazyExpr that the NDArray reductions build takes the array's dtype instead of inferring it. Measured on a Ryzen 9 7950X (300x512x512 float32 in memory, 28 threads), against this branch at 7db7b09: a.std() 470 -> 53 ms, (a.mean(), a.std()) 498 -> 78 ms, where(a > 100, a, 0).compute() 690 -> 469 ms; 100x100 a.std() 0.73 -> 0.52 ms. std/var may differ from before in the last ulps. Taken from the lazy-expression commit of Blosc#744 (b28fb0e), which was deferred until this branch lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the optimization work. I reviewed this PR and reproduced two correctness regressions in the newly enabled scalar 1. Float32 comparisons can select the wrong branchimport blosc2
import numpy as np
a = blosc2.asarray(
np.full(12, 0.1, dtype=np.float32),
chunks=(6,), blocks=(3,),
)
print(blosc2.where(a > 0.1, 1, 0)[:])The base and NumPy return all zeros; this PR returns all ones. This appears to expose a difference in scalar-promotion semantics: comparing the stored float32 value against a higher-precision 2. Large uint64 branch scalars silently lose precisiona = blosc2.asarray(
np.arange(12, dtype=np.uint64),
chunks=(6,), blocks=(3,),
)
expr = blosc2.where(a > 5, np.uint64(2**63 + 1), a)
print(expr[:])
print(expr.compute()[:])The selected elements should equal 9223372036854775809, as they do on the base. This PR returns 9223372036854775808 through both evaluation methods. The scalar appears to lose integer precision along the native specialization/compilation path. Both issues concern the changed dispatch around Validation: using the Posted by an AI coding assistant at the maintainer’s request. |
|
Oh my bad, I will take a look immediately |
Fusing where(cond, x, y) with scalar x/y (previous commit) exposed miniexpr to cases it gets wrong, as Francesc found in review: - where(a > 0.1, 1, 0) with a float32 `a` of 0.1 selected 1. miniexpr types the literals of the fused expression by its output dtype and computes in it: with an int64 output 0.1 became 0, with a float32 output it compared against the double 0.1, where NumPy rounds it to float32 first. Arithmetic in the condition also ran in the output dtype (where(a + b == c, 1, 0) with float32 operands). - where(a > 5, np.uint64(2**63 + 1), a) gave 2**63: miniexpr parses literals into C doubles. Inside where() it also passes 64-bit integer operands and branch values through doubles. where() is now fused only when computing the condition in the output dtype changes nothing: all its operands have the output dtype, or it only compares operands that cast safely to it against literals their dtypes hold exactly; never with 64-bit integers. Otherwise it falls back to the chunked evaluation. This also fixes array branches, which were fused before and had the same problems (where(a > 0.1, a, b)). Masks, label maps, thresholds and same-dtype arithmetic stay on miniexpr; where(a > 100, a, 0).compute() keeps its speed. Expressions with integer literals beyond 2**53, written or inlined scalars, are no longer sent to miniexpr (also a + np.uint64(2**63 + 1)). Tests: the review examples, a NumPy differential grid over condition dtypes, literals, comparisons, output dtypes and scalar/array branches, arithmetic conditions, large integer constants, and checks that the common mixed-dtype where() patterns still compile with miniexpr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for catching these, Francesc. Both come from miniexpr typing the whole fused
Before this PR, array branches were already fused, and they had the same problems. For example, Fix (new commit on top, python-only):
Tests:
Not changed here, but found on the way (all also on 7db7b09): reductions have the same literal problem ( AI disclosure: the analysis, fix and tests were done with Claude Code; I reviewed them. |
Two CI failures of the previous commits: - NumPy 1.26: a.std(ddof=1) and a.var(ddof=1) of float32 returned float64. The full std/var returns a float32 scalar, and under NumPy 1.x's value-based casting a float32 scalar times the Python int count of the n / (n - ddof) rescaling becomes float64 (with keepdims, a float32 array times a count above 65535 does too). The rescaled variance is now cast back to its own dtype; with NumPy 2 this changes nothing. This fixes the 24 test_std_var_full_reduction[...-1-...- float32] failures. - WebAssembly: test_large_integer_constants_exact and test_where_literal_condition_matches_numpy added 2**53 to a plain np.arange(), which is int32 on wasm32 and raised OverflowError. They build int64 inputs explicitly now. Not changed here: on NumPy 1.26, mean() of float32 already returns float64 for full reductions and for axis reductions over more than 65535 elements, and mean(where=) does on NumPy 2 too (also on 7db7b09). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
LGTM. Thanks for noting the new issues; we will address them separately. |
|
Update: the latest commits implementing
Validation against the unset/default evaluator with NumPy 2.5.4: 75 relevant regression tests passed, covering the This fixes ordinary-expression behavior by replacing the uniform-dtype execution route, not by repairing the old uniform evaluator. The temporary legacy escape hatch and explicit compatibility DSL kernels remain separate contracts. |
Hi Francesc,
this is a PR into #741, as you suggested in #741 (comment). It fixes the two slowdowns I reported in #741 (comment). It is one commit on top of
improved-miniexprat 7db7b09, taken from the deferred lazy-expression commit of #744.Why: two common cases fell off miniexpr, and since numexpr is gone they now run chunk by chunk through
evaluate_chunk().What:
std()/var()of float32/float64 (no axis, nowhere, same dtype) reduced(x - mean) ** 2, with the mean as a broadcast (1, ..., 1) operand. They are now two single-pass reductions: the mean, then the mean of(x - m) * (x - m)withminlined as a scalar.mis folded into the operator, because miniexpr reductions mis-evaluate a negative literal on the right of a binary operator ((o0 - -0.5)).where, other dtypes) keep the general path.std()andvar()now share one implementation.where(cond, x, y)with a scalarxory:specialize_miniexpr_inputs()inlined the scalar and dropped its operand before thewhere()was fused in. The fused expression then referenced a missing operand, miniexpr failed to compile it, and the evaluation fell back. Thewhere()is now fused before specializing.LazyExprthat the NDArray reductions build takes the array's dtype instead of inferring it.Measured on a Ryzen 9 7950X: 300x512x512 float32 in memory, default chunks, 28 threads. Medians of two interleaved rounds; all builds against c-blosc2
main(e5086bf).maina.std()a.mean(), a.std()blosc2.where(a > 100, a, 0).compute()a.std(), 100x100(a + b)[:]a.sum(axis=0)std/varmay differ from before in the last ulps.Tests:
test_where_scalar_uses_miniexprchecks that miniexpr compiles the fused expression; its 8 scalar cases fail without this change.test_std_var_full_reduction(float32/float64, ddof, keepdims, means of 0 and +-1e4, with and without miniexpr),..._wider_dtypeand..._nonfinite.pytest tests: 10707 passed. The 2 failures are theb2viewhttp tests that need aiohttp, which also fail without this change and onmain.Not in this PR: the rest of that commit (
expr[:]straight into the NumPy result:(a + b)[:]about 1.6x; chunked reductions into reused buffers) and the result-dtype fix ofcheck_dtype()(hypot,copysign,nextafterof integers; true division of bools). I'll send those as separate PRs into #741 if you like.AI disclosure: the port, the tests and the measurements were done with Claude Code; I reviewed them.
Best,
Fabian
🤖 Generated with Claude Code