Skip to content

Faster NDArray reads, writes, fills, serialization and expressions; fixes for crashes and wrong results - #744

Closed
FabianIsensee wants to merge 19 commits into
Blosc:mainfrom
FabianIsensee:perf-and-fixes
Closed

FabianIsensee wants to merge 19 commits into
Blosc:mainfrom
FabianIsensee:perf-and-fixes

Conversation

@FabianIsensee

Copy link
Copy Markdown

Hi Francesc,

this is the python-blosc2 side of Blosc/c-blosc2#833 (it needs that one: blosc2_schunk_to_buffer_alloc() and the ownership rule for failed writes, so this PR temporarily builds against that branch; the last commit switches back to a release tag once it is merged).

It contains #742 and #743: their original commits are included unchanged (merged at the bottom of this branch), because several changes here build on #743's per-handle locks. If you merge those two first, this diff shrinks accordingly; otherwise this PR supersedes them and you can close them when merging this one. Same approach: performance improvements with identical results, plus bugs we found on the way, each with a test that fails without the fix.

Performance (Ryzen 9 7950X, 28 threads; medians, interleaved against main + #742/#743)

before after
a[3, 5, 7] (memory / disk) 34 / 48 µs 23 / 34 µs
blosc2.zeros((100, 100)) 31 µs 20 µs
a[:], 315 MB float32 (memory / disk) 41 / 57 ms 23 / 39 ms
a[:] = 0.0 38 ms (and a full uncompressed copy of the region in RAM) 0.1 ms
a[:] = b (NDArray) 138 ms, 306 MB extra 18 ms, 28 MB
to_cframe() 97 ms 15 ms
(a + b)[:] 104 ms 59 ms
a.std() / a.mean(), a.std() 67 / 77 ms 22 / 31 ms
astype(np.uint8) 62 ms 42 ms
nnU-Net 128³ patch (data + seg, 1 thread, mmap) 2.6 ms 2.1 ms

On the Xeon node the bulk gains are larger (e.g. a[:] 1.8x, (a + b)[:] 3x, std 5.3x, writing a whole on-disk array 2x on NFS).

What changed:

  • Cython fast path for basic keys (ints, unit-step slices, ..., None) in __getitem__/__setitem__; less work when creating small arrays (lazy cparams/dparams, no dataclasses.asdict(), no inspect.stack()).
  • __setitem__ with a scalar fills fully covered chunks with special chunks instead of building np.full(region); with an NDArray value it goes chunk by chunk and copies compressed chunks when both arrays encode them the same way. Arrays opened with locking=True keep the single atomic set_slice path.
  • asarray/astype of NDArrays cast whole chunks in reused buffers.
  • expr[:] evaluates straight into the NumPy result (no temporary NDArray); full std/var are two single-pass reductions; chunked reductions read chunks into reused buffers.
  • to_cframe() writes the frame straight into the returned bytes.
  • decompress_chunk() reads only the chunk header for the size.

Bug fixes

  • Crash: lazy expressions over in-memory frame-backed arrays (contiguous=True, or ndarray_from_cframe(cf) with the default copy=False) and the fast matmul path with on-disk or frame-backed operands dereferenced a NULL chunk table.
  • Wrong results from miniexpr (see below), now routed to the numexpr path.
  • Result dtypes: hypot/copysign/nextafter of integer subexpressions and true division of bools were inferred as integers and truncated on every path.
  • update_special on an NDArray's super-chunk wrote chunks with the wrong block size (every later read failed); insert_data(copy=False) leaked on failure; bool-mask positions ran past the array length.
  • __getitem__'s general path raised or returned wrong shapes where NumPy returns a result (e.g. huge slice bounds, bool steps next to .../None/NumPy ints, (None, array), 0-d integer arrays, scalar bools next to arrays).
  • BLOSC_NTHREADS decides the real thread count in C but blosc2.nthreads ignored it; NUMEXPR_NUM_THREADS was overridden at import.
  • Memory: a[region] = scalar built an uncompressed np.full of the whole region (a fill of a large on-disk array could exceed RAM), and a[region] = other_ndarray decompressed the whole source first; both now work chunk by chunk.
  • Failed update_data/insert_data(copy=False) calls freed their chunk again after c-blosc2 had (double free) or leaked it, depending on where the C call failed; fixed together with c-blosc2's ownership rule (needs Faster slice reads/writes, serialization and NFS opens; fixes for memory-safety bugs c-blosc2#833).

miniexpr: bugs we could only route around

These are in the bundled miniexpr (pinned 3f4db93), reproduced on main; src/blosc2/miniexpr_guard.py detects affected expressions and sends them to numexpr (correct, 1.5-4x slower for those expressions only). Reports with repros, source locations and suggested fixes are in miniexpr_bug_reports.md:

  • reductions with a constant subexpression: (o * -2).sum() -> 0.0 on a ones array
  • two-argument float functions and / on integer/bool operands computed in integer arithmetic ((a / b)[:] truncates, bool / bool SIGFPE, hypot, logaddexp, ...); constants typed by the result dtype ((a / 3) > 2 wrong for ints, int32 < 2.5 compares with 2)
  • %/remainder follow C sign rules (also for floats)
  • mixed-dtype typing: a conversion buffer sized for the wrong type gives garbage or heap corruption (int16 ((i + i) * abs(i))[:] aborts in glibc)
  • bool + bool added numerically; -x ** 2, ** associativity and chained comparisons parse differently
    The guard reproduces miniexpr's typing in Python; it agrees with NumPy on 10.5k random expressions but is not a proof: two shapes from that test still crash (sums of where(...) * (b - 0) with uint8/int8, where(c > 3, a, b) - (b + a) with uint8/bool/float32), and narrow-integer reductions disagree with NumPy's wrap-around (uint8 (a ** 2).sum()). A simpler, safe alternative would be using miniexpr only for same-dtype float operands; happy to switch if you prefer that until miniexpr is fixed.

Found, not fixed (questions for you)

  • Compressing in memory with many threads right after reads can be slower than on main (float32, 28 threads: 1.4-2.4x; equal in a fresh process): update_data & co. allocate a fresh chunk-sized output per chunk, and main's read path happened to warm glibc's heap for that size, which the faster read path no longer does. Planned follow-up: reuse the output buffer (python side, or with an update_buffer call in c-blosc2).
  • NDArray.schunk.locking reports False even when the array was opened with locking=True.
  • With locking=True, a read can still fail with nchunk exceeds the number of chunks when another process shrinks the array: the slice is computed from a shape read under an earlier lock (c-blosc2 only exports the exclusive blosc2_schunk_lock(); a shared variant would let us hold one lock across refresh, slice computation and read).
  • pickle.dumps / copy.copy / copy.deepcopy raise TypeError for NDArray, SChunk and LazyExpr (nothing inside python-blosc2 needs them; user code with process pools has to pass paths or to_cframe() bytes).
  • expr[...], expr[:, :] and partial slices are evaluated by numexpr, expr[:] and compute() by miniexpr, so they differ in the last bits (transcendental functions, float32 arithmetic with float literals, int32 + float32 computed in float32 by numexpr). Routing them all through the direct miniexpr path would make (a + b)[...] 2x faster; we left it because it changes those spellings' results.

Behaviour changes

  • Keys containing None return arrays that own their data (was a view of a temporary).
  • std/var results may differ from before in the last ulps (same accuracy as NumPy).
  • Some arctan2 cases with mixed int/float operands moved from numexpr to miniexpr (correct values, ±1 ulp; .sum() now float64, matching expr.dtype).
  • blosc2.nthreads reflects BLOSC_NTHREADS.

Tests

11071 passed (2 failures: the b2view http tests that need aiohttp, also on main); new tests for every fix; randomized differential tests against main (+#742/#743): getitem keys (~150k vs NumPy), setitem regions, fills, expressions, reductions, cframes (identical apart from std rounding of constant arrays).

AI disclosure: I used Claude Code (Anthropic) for the investigation, the code, the tests and a first draft of this description. I reviewed and tested everything.

Best,
Fabian

FabianIsensee and others added 19 commits October 28, 2026 16:18
NDArray.__setitem__ calls mark_indexes_stale() after every write.  Loading
the store resolves the urlpath and stats every cached persistent path, and
on NFS each stat() of a file with buffered writes first pushes them to the
server.  Return early when no cached store has indexes and the array's
vlmeta has no index store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NDArray.get_slice_numpy() decompressed with the GIL held, so threads
reading separate arrays, or separate handles of one file, ran one at a
time; on NFS this included the wait for the server.  Release the GIL
around b2nd_get_slice_cbuffer().

The GIL was also what kept two threads from using one super-chunk at
the same time.  Replace the per-NDArray read_lock with one reentrant
lock per super-chunk, shared by the SChunk wrapper, the NDArray, its
views and vlmeta, and hold it in every entry point into C.  Keep the
GIL when a Python postfilter, codec or filter could run during the
read, and on wasm32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj7Wi7Sc8HtQpELyNXB
Hold the operands' handle locks while the miniexpr prefilter of a lazy
expression reads their super-chunks from C.  Count the reads that run
without the GIL, so that exit and the registration of Python codecs and
filters wait for them, and free handle locks that other threads held at
a fork in the child.  Lock the metalayer counters too.

Skip the new tests on wasm32, shorten them, retry the GIL check on busy
machines, and add tests for lazy expressions and fork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj7Wi7Sc8HtQpELyNXB
Move _may_have_indexes next to the store helpers, iterate over a snapshot of
the persistent store cache, and use plain helpers in the two tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mance work

Blosc#742 (setitem-skip-unindexed-store, 03db12a): skip the index store in
mark_indexes_stale for unindexed arrays.
Blosc#743 (release-gil-slice-reads, 078cd62): release the GIL in NDArray slice
reads, with a per-handle lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The miniexpr prefilter read the chunks of every in-memory operand from
sc->data, and the fast matmul prefilter did so for every operand.  Frames
have no chunk table there: in-memory arrays created with contiguous=True,
arrays from ndarray_from_cframe() (copy=False, the default) and, for
matmul, any on-disk array.  Any lazy expression or matmul with such an
operand dereferenced a NULL table and crashed the interpreter.

Read frame-backed in-memory operands through blosc2_schunk_get_chunk(),
as on-disk ones already are, and keep frame-backed operands off the fast
matmul path: in its Python checks, and in _set_pref_matmul() itself,
which now raises ValueError for them before changing any state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
The general indexing path (ndindex) raised, or returned a wrong shape,
for keys that NumPy indexes fine: 554 of 18000 random keys in a
comparison with NumPy.  Fix the classes found:

- slice bounds beyond the index range, and bool bounds or steps, next to
  ..., None or NumPy integers raised IndexError or TypeError; normalize
  them as NumPy reads them (clamped, bools as ints) before ndindex sees
  them;
- 0-d integer arrays index as integers (AttributeError before);
- scalar bools are 0-d boolean indices, as in NumPy: their dimension
  goes among adjacent advanced indices (integers included), else in
  front, and next to array indices they broadcast (True: no effect,
  False: an empty result or IndexError).  The result shape comes from
  NumPy on a stride-0 stand-in for the array, so it costs no memory;
- None next to (adjacent) array indices is supported;
- 0-d arrays take NumPy scalar bools (AttributeError before) and raise
  IndexError for integer lists (ValueError before).

Advanced indices separated by slices, ... or None still raise
NotImplementedError (NumPy moves their dimensions to the front there).
After this, none of the 18000 keys gives another result than NumPy; the
remaining differences are that limitation and which error a key that is
invalid for several reasons reports first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
Keys made of integers (Python or NumPy, not bools), unit-step slices,
one Ellipsis and, for reads, None went through the general path: 8-30 us
of Python per call (ndindex as soon as a NumPy integer, ... or None
appears), more than decompressing a small selection.

NDArray.get_basic_numpy() classifies such keys in Cython, resolves them
against the shape and reads the result under one acquisition of the
handle lock, through _get_slice_cbuffer(), the GIL-releasing read that
get_slice_numpy() now shares with it.  get_basic_start_stop() resolves
the same keys (without None) for __setitem__.  Any other key, or one that
does not fit the shape, gives None and takes the unchanged general path,
which raises the same exceptions as before.  NumPy integers without
__index__ (timedelta64) are left to it too, where they raise IndexError
as in NumPy.

SWMR: a read resolves the key against the cached shape and relies on the
refresh inside the C read; if b2nd_array_t.last_tick shows that this
refresh changed the shape (or the key did not fit, or the read failed),
it refreshes, resolves and reads again, so a key is never resolved
against a stale shape, at one file check per on-disk read instead of
two.  A write refreshes under the handle lock before resolving.

Keys with None now return arrays that own their data (they were views
of a temporary).

Measured (Ryzen 9 7950X): a[3, 5, 7] of a 300x512x512 float32 array
34 -> 25 us in memory and 48 -> 35 us on disk, a[500] of a 1000x1000
array 21.5 -> 13.9 us, a[np.int64(7), np.int32(9)] 37.5 -> 7.3 us,
iterating over 2000 rows 64 -> 44 ms; a[np.int64(i), j] = v 22 us less.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
Creating a small array cost ~30 us, mostly Python bookkeeping:

- compute_chunks_blocks() and _check_ndarray_kwargs() converted CParams
  and Storage with dataclasses.asdict(), which deep-copies; a shallow
  dict (vars() for Storage, whose fields are plain values) is enough;
- every SChunk wrapper, so every NDArray, built its cparams and dparams
  eagerly under the handle lock; they are now built on first use;
- create_cparams_from_kwargs() looked up Filter.BYTEDELTA.value (a slow
  enum descriptor) twice per filter slot; now once per call;
- is_inside_new_expr() and is_inside_ne_evaluate(), which arange,
  linspace, eye, reshape, fromiter and lazy functions such as clip run
  on every call, used inspect.stack(), which reads the source of every
  frame; walking the frames gives the same answer in under 1 us.

__setitem__ also wrapped plain NumPy values in a SimpleProxy, whose
constructor runs compute_chunks_blocks(); other array types keep the
wrapping, NumPy arrays give the same outcome without it (checked for
contiguous, strided, Fortran-order, 0-d, broadcast and converted values).

Measured (Ryzen 9 7950X): zeros((100, 100)) 31 -> 20 us, eye() 311 ->
179 us, reshape() 437 -> 276 us, clip(a, 0, 1) 84 -> 12 us; a 3x3 write
into a 100x100 array ~12 us less (asdict() ~5 us, SimpleProxy ~7 us).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
- update_special() built its special chunk in a temporary SChunk with
  default cparams, so on an NDArray's super-chunk the chunk header had
  another block size than the array (e.g. 20000 instead of 800 bytes)
  and every later read of the array failed: b2nd reads blocks
  selectively and checks their number per chunk.  The new
  SChunk._special_chunk() makes zero, NaN, uninitialized and
  repeated-value chunks with the super-chunk's own cparams.
- update_data() and insert_data() with copy=False did not free their
  compressed chunk when the C call failed (nor on a compression error),
  so it leaked wherever c-blosc2 did not free it itself.  They now free
  it whenever the call fails.  This needs the next c-blosc2 release (or
  the companion c-blosc2 PR) for its caller-keeps-the-chunk-on-failure
  rule; with an older c-blosc2, a failed frame update can free the chunk
  twice.
- decompress_chunk() without dst fetched the whole compressed chunk only
  to read its size from the header, then read it again to decompress
  it; it now gets the lazy chunk (header and block offsets) for the
  size.  astype(float64) of a 300x512x512 float32 array on disk:
  385 -> 366 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
a[region] = scalar built np.full() of the whole region, and
a[region] = ndarray decompressed all of the source first: an
uncompressed copy of the region in memory (a fill of a large on-disk
array could exceed RAM), then one set_slice.

Scalars: every chunk the region fully covers gets one shared special
(repeated-value) chunk from SChunk._special_chunk(), and the rest is
written in boxes of bounded size (32 MB, or one chunk) from one buffer
sized to the largest box.  Edge chunks get a special chunk only for
zero, as writing them leaves zeros in their padding (which a resize
brings to light).  Chunks go last to first: a frame compacts its payload
behind an updated chunk, and that tail then holds the tiny special
chunks already written.  Lossy codecs and filters, and dictionaries,
keep compressing the values.

NDArray values: read box by box along the destination's chunk
boundaries into reused buffers and written with one set_slice per box;
when both arrays encode chunks the same way (dtype, chunks, blocks,
lossless cparams) and the region starts at a chunk boundary, covered
chunks without padding are copied compressed.  A value that may share
storage with the destination (same super-chunk or same file) is still
read in full first.

Both hold the handle locks (HandleLocks with the source) for the whole
write, so that other threads see it as atomic as one set_slice.  Regions
smaller than a chunk, and arrays whose frame takes the cross-process
lock (locking=True or BLOSC_LOCKING, destination or source), keep the
single set_slice, which holds that lock from the shape refresh to the
last chunk.  A shared flat chunk index helper now also serves
detect_aligned_chunks(), and __setitem__ marks indexes stale through one
helper.

Measured (300x512x512 float32, default chunks, Ryzen 9 7950X):
a[:] = 0.0 41 -> 0.2 ms with no extra memory (was 303 MB); the first
a[:] = 1 into an existing on-disk array 588 -> 29 ms; a[:] = b with the
same encoding 142 -> 18 ms and 306 -> 28 MB peak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
asarray() of an NDArray, and so astype(), sliced every chunk with
__getitem__ and cast it into fresh arrays: chunk-sized allocations per
chunk, which glibc trims and page-faults back in each time.  When source
and destination have the same shape, chunks and blocks (the default for
astype), chunks without padding are now decompressed into one reused
buffer, cast item by item into another and compressed from there; edge
chunks keep the per-chunk path (their padding may hold anything, and
casting it could warn).  Chunks encoded the same way in both arrays are
copied compressed (e.g. asarray(a, copy=True, urlpath=...)).

Measured (300x512x512 float32, Ryzen 9 7950X, this change toggled):
astype(np.uint8) 110 -> 56 ms, with nnU-Net-like chunks 286 -> 267 ms;
astype(np.float64) unchanged (compression-bound).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
NDArray.to_cframe() and SChunk.to_cframe() got the frame from
b2nd_to_cframe() / blosc2_schunk_to_buffer() in a C buffer and copied it
into a new bytes object.  They now call blosc2_schunk_to_buffer_alloc()
with a callback that creates the bytes object of the final size, and the
frame is written into it directly: no second copy of the whole frame.
SChunk.to_cframe() still releases the GIL only under
set_releasegil(True); the callback takes it back to create the bytes.

Needs blosc2_schunk_to_buffer_alloc() from the next c-blosc2 release
(or the companion c-blosc2 PR).  Measured with it (300x512x512 float32 in
memory, default chunks): to_cframe() 54 -> 15 ms and peak memory 456 ->
230 MB.  Adds round-trip tests of to_cframe() / from_cframe() for every
storage kind and for special, updated and resized chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
- expr[:] and expr[()] with miniexpr ran the prefilter into a temporary
  uncompressed NDArray and read that back into NumPy.  A clevel=0 chunk
  is a header followed by the raw prefilter output, so when the chunks
  tile the flat array (blocks C-contiguous in their chunk, chunks in the
  array, only the last one partial) each chunk is now produced in its
  final place in the NumPy result, last to first, its header landing in
  bytes not computed yet.
- Full std()/var() of float32/float64 (no axis, no where, same dtype)
  are two single-pass reductions: the mean, then the mean of
  (x - m) * (x - m) with m inlined as a scalar, instead of a broadcast
  (1, ..., 1) operand that kept the second pass off the fast paths.
  Other cases, such as dtype=np.float64 for float32 data, keep the
  general path.  std() and var() share one implementation.
- Chunked reductions (axis=..., argmin, cumulative_sum, ...) and chunked
  evaluation decompress the chunks of operands whose blocks are
  C-contiguous runs of their chunk straight into reused chunk-sized
  buffers, compute flat chunk indices without NumPy and skip ndindex
  for whole arrays.  The unused `aligned` plumbing is gone.
- where(cond, x, y) with a scalar x or y is fused before the operands
  are specialized, so the scalar is inlined and miniexpr is used (it
  always fell back to numexpr).
- No dtype inference for the bare-array LazyExpr that the NDArray
  reductions build, and one regex for all constructor checks.

Measured (300x512x512 float32 in memory, 28 threads, Ryzen 9 7950X):
(a + b)[:] 120 -> 62 ms, a.std() 68 -> 21 ms, (a.mean(), a.std())
78 -> 31 ms, a.sum(axis=0) 28 -> 22 ms, where(a > 100, a, 0).compute()
184 -> 153 ms; 100x100: a.std() 0.61 -> 0.38 ms.  std/var may differ
from before in the last ulps (same accuracy as NumPy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
The bundled miniexpr (3f4db93) returns wrong results, or crashes, for
several classes of expressions, all reproducible on main: reductions
with a constant subexpression ((o * -2).sum() is 0.0 for an array of
ones); two-argument float functions and true division of integer or
bool operands computed in integer arithmetic (bool / bool died of
SIGFPE); constants typed by the result dtype ((a / 3) > 2 on integers,
int32 < 2.5); % and remainder() with C sign rules; mixed-dtype typing
(garbage values, and int16 ((i + i) * abs(i))[:] corrupted the heap);
bool + bool added as numbers; grammar differences (-x ** 2, **
associativity, chained comparisons).

The new miniexpr_guard module reproduces miniexpr's typing of an
expression and flags these cases, replacing the check for integer
arctan2 only; fast_eval() and reduce_slices() then evaluate them with
numexpr, which matches NumPy (1.5-4x slower for those expressions,
e.g. (a * -2).sum() 10 ms but wrong -> 39 ms; others unchanged, the
check is a cached lookup).  It agreed with NumPy on 10.5k random
expressions, but is not a proof.

check_dtype() also gave integer or bool result dtypes to hypot,
copysign and nextafter of integer subexpressions, to true division
involving bools ((a < b) / b, bool / bool) and to the bool //, % and **
that NumPy computes in int8, so their results were truncated on every
evaluation path.  It now asks NumPy (the operator or ufunc on empty
operands, with a scalar next to an array kept as it is, so that it
promotes as a Python scalar); int32 / int32 keeps its float32.  Array
operands' dtypes are read directly, which pays for that probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
LazyExpr._collect_flat_indices_from_bool_ndarray(), used for 1-D
masks, took every chunk as chunk_len items, so the padding of the last
chunk could yield positions past the end of the array:
blosc2.full(1000, True, chunks=(300,)) gave 1200 positions.  Clip each
chunk to the items inside the array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
C-Blosc2 uses BLOSC_NTHREADS, when set to a number it accepts, for every
compression and decompression context, whatever nthreads the parameters
ask for.  blosc2.nthreads, and so the CParams/DParams defaults and
numexpr's thread count, ignored it: with BLOSC_NTHREADS=4, Python
reported 28 threads and gave numexpr 28 while C used 4.  Start
blosc2.nthreads from what C-Blosc2 gives a compression context (the new
blosc2_ext.context_nthreads(), via blosc2_ctx_get_cparams()), and say in
set_nthreads()'s docs that the variable wins at the C level.

Importing blosc2 also overrode NUMEXPR_NUM_THREADS, which set_nthreads()
recommends setting; it now leaves numexpr alone then, and otherwise
gives it at most numexpr.MAX_THREADS (BLOSC_NTHREADS can go beyond that,
which numexpr refuses with an error).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
List the faster indexing, writes, casts, serialization and expressions,
the bug fixes, and the compatibility notes (the next C-Blosc2 release is
required; None keys return arrays owning their data; std/var rounding)
in the 4.14.2 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
Drop this commit before merging: once the c-blosc2 changes are merged
and released, point BLOSC2_BUNDLED_VERSION back to Blosc/c-blosc2 at a
release tag that contains them and bump BLOSC2_MIN_VERSION, since this
branch uses blosc2_schunk_to_buffer_alloc() and the documented ownership
of chunks after failed writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwMZj6jNaqsesMKnBde8Lr
@FrancescAlted

Copy link
Copy Markdown
Member

Thanks for this contribution, appreciated. As this is really big, I think we really should split this. Our proposal (well, my agent and mine) would be to close this and open 6 new PRs:

Sub-PR 1: Bug fixes & ASan/UBSan/LSan cleanups (Merge first)

  • Commits:
    • acc0052 — Do not copy the offsets chunk of a frame without chunks (frame_from_schunk UBSan fix)
    • 26b5e30 — Do not flag a stored copy of a chunk as having a dictionary (blosc_compress_context & initialize_context_decompression — fixes a nasty bug where incompressible data with use_dict=True couldn't be read back)
    • 8310ba4 — Compress frame offsets with typesize 8 everywhere (compress_offsets helper in frame.c)
    • ddda771 — Fix leaks and dangling state on error paths (fixes dangling storage->cparams stack pointer in blosc2_schunk_copy, short last chunk padding when recompressing, stale block_maskout on failed reads, and error-path leaks)
  • Why separate: Pure correctness and memory-safety fixes with zero architectural controversy.

Sub-PR 2: Skip byte shuffle for 1-byte types (Fast merge)

  • Commit: dae8a41 — Skip the byte shuffle for 1-byte types (blosc2.c, shuffle.c)
  • Why separate: Small, self-contained (~40 lines), format-preserving, and gives a huge 2.5–4.2x speedup for uint8/int8/bool when BLOSC_SHUFFLE is active by default (as in python-blosc2).

Sub-PR 3: Frame header-only reads, NFS handle adoption & slice locking

  • Commits:
    • 77f4a79 — Read only chunk headers where only sizes or flags are needed (frame_get_chunk_header in frame.c / schunk.c)
    • 043ce46 — Keep the handle that opened or created a frame as its cached handle (frame_from_file_offset and frame_from_schunk)
    • 8ec0212 — Hold the shared frame lock across b2nd and SChunk slice reads (b2nd_get_slice_cbuffer, b2nd_set_slice_cbuffer, blosc2_schunk_get_slice_buffer)
  • Why separate: All three are focused on frame I/O efficiency and locking consistency on disk/NFS.

Sub-PR 4: One-pass frame copy & blosc2_schunk_to_buffer_alloc()

  • Commit: 59b6cc5 — Fill a copied frame in one pass and add blosc2_schunk_to_buffer_alloc() (frame.c, schunk.c, blosc2.h)
  • Why separate: Introduces a new public API (blosc2_schunk_to_buffer_alloc) and a new one-pass frame builder (frame_fill_from_schunk / frame_fill_alloc). The callback-based allocation pattern (uint8_t* (*alloc)(int64_t len, void* user_data)) is great for avoiding a copy into Python bytes, but public API additions should always be reviewed in their own PR.

Sub-PR 5: b2nd and schunk slice read/write paths (direct, scatter/gather, uninit zeroing)

  • Commits:
    • 87cec8c — Read and write b2nd slices without a chunk scratch where possible (get_set_slice in b2nd.c)
    • 99d97ce — Read uninitialized chunks as zeros on every b2nd slice path
    • b692140 — Decompress large partial chunks of an SChunk slice in parallel (blosc2_schunk_get_slice_buffer)
  • Why separate: This is a substantial rewrite of get_set_slice (+744/-406 lines in b2nd.c) using internal prefilters/postfilters for multi-threaded block gather/scatter and Linux-specific THP prefaulting.

Sub-PR 6: Per-thread temporary buffer cache (blosc2_tmp_malloc / blosc2_tmp_free)

  • Commit: 34d9b49 — Reuse chunk-sized temporaries through a per-thread cache
  • Why separate: Introduces persistent thread-local state (pthread_key_t, up to 64 MB per thread) and new internal chunk ownership states (CHUNK_LENT / schunk_update_chunk_lent).

Regarding miniexpr enhacements, I'd defer this because we are in the middle of a big refactorization that would led to more portable, and more NumPy-compatible DSL kernels in #741.

When sending the new PRs, please mind that our resources are limited, and we are quite a bit over-flooded with PRs lately (as many open source projects are) so be patient with our responses; hopefully we will reach there.

@FabianIsensee

Copy link
Copy Markdown
Author

First of all thank you for your openness to PRs like this! Things move fast nowadays and it really is cool to see blosc2 moving fast as well :-)
In understand this monster PR is a lot at once, so I will split it as suggested and append a few more fixes I have been working on along the way. Is it OK if I do the split and resubmission via claude or would you prefer I do this manually?
Best,
Fabian

@FrancescAlted

Copy link
Copy Markdown
Member

Thanks for your understanding. Yeah, using Claude for sending the new PRs is fine.

@FabianIsensee

Copy link
Copy Markdown
Author

Closing in favour of the split, as proposed in #744 (comment):

  1. Fix frame-backed crashes, NumPy keys, update_special and BLOSC_NTHREADS (part 1/4 of #744) #746: bug fixes (stacked on Release the GIL in NDArray slice reads #743; needs Fix error-path leaks, dictionary chunks and offsets typesize (part 1/6 of #833) c-blosc2#835)
  2. Resolve basic keys in Cython and lower per-call overhead (part 2/4 of #744) #747: basic keys resolved in Cython, lower per-call overhead (stacked on 1)
  3. Write scalars and NDArray values chunk by chunk; cast whole chunks in asarray/astype (part 3/4 of #744) #748: chunk-wise writes in __setitem__, chunk-wise casts in asarray/astype (stacked on 2)
  4. Write cframes straight into the returned bytes (part 4/4 of #744) #749: to_cframe() straight into the returned bytes (stacked on 3; needs Fill a copied frame in one pass and add blosc2_schunk_to_buffer_alloc() (part 4/6 of #833) c-blosc2#838)

#742 and #743 stay open as they are. The c-blosc2 side replaces Blosc/c-blosc2#833 with Blosc/c-blosc2#835 to Blosc/c-blosc2#840.

As you suggested, two parts are not resubmitted until #741 has landed:

  • the miniexpr guard (expressions that miniexpr evaluates wrongly or crashes on go to numexpr; result dtypes of hypot & co.)
  • the lazy-expression speedups (expr[:] straight into the NumPy result, single-pass std/var, chunked reductions)

The miniexpr bug reports linked in the description above still apply.

(The split and this comment were made with Claude Code on my behalf.)

FabianIsensee added a commit to FabianIsensee/python-blosc2 that referenced this pull request Nov 3, 2026
…ctions

check_dtype() gave integer or bool result dtypes to hypot, copysign and
nextafter of integer subexpressions, to true division involving bools
((a < b) / b, bool / bool) and to the bool //, % and ** that NumPy
computes in int8, so their results were truncated on every evaluation
path: hypot(a + 1, b) of int8 arrays gave [2 3 4 5] for NumPy's
[2.83 3.61 4.47 5.39], (a < b) / b gave [0 0 0 0] for [0.5 0 0 0].

It now asks NumPy for these operators and functions: the operator or
ufunc on empty operands, with a scalar next to an array kept as it is,
so that it promotes as a Python scalar; int32 / int32 keeps its float32.
Array operands' dtypes are read directly instead of through
blosc2.result_type(), which more than pays for the probe: building
(a + b) * 2 - f takes 62 instead of 70 us.

The new test checks the dtype on every path and the values where the
typing follows NumPy (compute() and partial slices).  expr[:] and
reductions of integer true division and two-argument float functions of
two integer arrays still compute in integer arithmetic inside the
bundled miniexpr, which is for miniexpr to fix.

Taken from Blosc#744 (55660b4), without its miniexpr guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FabianIsensee added a commit to FabianIsensee/python-blosc2 that referenced this pull request Nov 3, 2026
…rs in chunked paths

- fast_eval()'s NumPy-output path wrote the miniexpr results of each
  chunk straight into the result only for 1-D arrays.  It now does so
  for any array whose chunks are consecutive runs of the C-order result:
  blocks C-contiguous in their chunk, chunks C-contiguous in the array,
  only the last one partial (_chunks_tile_flat()), as with the default
  partitions.  An N-D `out` must be C-contiguous for that; a 1-D one may
  still be strided.  Other arrays keep the uncompressed temporary
  NDArray.
- Chunked evaluation and reductions (axis=..., argmin, cumulative_sum,
  ...) decompress the chunks of operands whose blocks are C-contiguous
  runs of their chunk straight into reused chunk-sized buffers
  (_chunk_buffers()), instead of allocating per chunk; the unused
  `aligned` plumbing is gone.  blocks_contiguous() moves to module level
  in ndarray.py for that.
- One compiled regex for all constructor checks.

Measured on a Ryzen 9 7950X (300x512x512 float32 in memory, 28 threads),
on top of the std/var and where() change: (a + b)[:] 116 -> 71 ms,
a.sum(axis=0) 29.3 -> 26.3 ms.

Taken from the lazy-expression commit of Blosc#744 (b28fb0e), which was
deferred until this branch lands; its own NumPy-output mechanism is
replaced by extending the one of this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FabianIsensee added a commit to FabianIsensee/python-blosc2 that referenced this pull request Nov 3, 2026
…rs in chunked paths

- fast_eval()'s NumPy-output path wrote the miniexpr results of each
  chunk straight into the result only for 1-D arrays.  It now does so
  for any array whose chunks are consecutive runs of the C-order result:
  blocks C-contiguous in their chunk, chunks C-contiguous in the array,
  only the last one partial (_chunks_tile_flat()), as with the default
  partitions.  An N-D `out` must be C-contiguous for that; a 1-D one may
  still be strided.  Other arrays keep the uncompressed temporary
  NDArray.
- Chunked evaluation and reductions (axis=..., argmin, cumulative_sum,
  ...) decompress the chunks of operands whose blocks are C-contiguous
  runs of their chunk straight into reused chunk-sized buffers
  (_chunk_buffers()), instead of allocating per chunk; the unused
  `aligned` plumbing is gone.  blocks_contiguous() moves to module level
  in ndarray.py for that.
- One compiled regex for all constructor checks.

Measured on a Ryzen 9 7950X (300x512x512 float32 in memory, 28 threads),
on top of the std/var and where() change: (a + b)[:] 116 -> 71 ms,
a.sum(axis=0) 29.3 -> 26.3 ms.

Taken from the lazy-expression commit of Blosc#744 (b28fb0e), which was
deferred until this branch lands; its own NumPy-output mechanism is
replaced by extending the one of this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FrancescAlted pushed a commit that referenced this pull request Nov 3, 2026
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 #744 (b28fb0e), which was
deferred until this branch lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants