From 80762a3e152d0e1e86c1b7fffaaa579e889deb00 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Mon, 2 Mar 2026 15:04:00 +0100 Subject: [PATCH 01/69] Getting ready for release 4.1.1 --- ANNOUNCE.rst | 6 ++---- RELEASE_NOTES.md | 2 +- RELEASING.rst | 2 +- pyproject.toml | 2 +- src/blosc2/version.py | 2 +- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ANNOUNCE.rst b/ANNOUNCE.rst index f4a14acf..475fb9dd 100644 --- a/ANNOUNCE.rst +++ b/ANNOUNCE.rst @@ -1,9 +1,7 @@ -Announcing Python-Blosc2 4.0.0 +Announcing Python-Blosc2 4.1.1 =============================== -This is minor version release which introduces various new features: optimised compression and string functions (e.g. endswith) for unicode character arrays; cumulative reductions (see `the blog `_) ; memory-map support for store containers like ``DictStore`` ; as well as further improvements to our bespoke multi-threaded computation backend ``miniexpr``. - -The main new feature is a DSL kernel functionality for faster, compiled, user-defined functions: these DSL kernels accelerate ``arange`` / ``linspace`` constructors by 6-10x. Tutorial: https://blosc.org/python-blosc2/getting_started/tutorials/03.lazyarray-udf-kernels.html. +This is patch release which updates the ``miniexpr`` version to fix a bug for ubuntu ARM64 failure. You can think of Python-Blosc2 4.x as an extension of NumPy/numexpr that: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f148dcd6..1c4b50d8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## Changes from 4.1.0 to 4.1.1 -XXX version-specific blurb XXX +- Update `miniexpr` version to fix bug for ubuntu ARM64 failure ## Changes from 4.0.0 to 4.1.0 diff --git a/RELEASING.rst b/RELEASING.rst index 4fea2eab..917baa7d 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -26,7 +26,7 @@ Preliminaries * Build the package and make sure that tests are passing:: - pip install -e ".[test]" + pip install -e . --group test pytest * Make sure that ``RELEASE_NOTES.md`` and ``ANNOUNCE.rst`` are up to date with the diff --git a/pyproject.toml b/pyproject.toml index 6244b0d9..db226c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "numexpr>=2.14.1; platform_machine != 'wasm32'", "requests", ] -version = "4.1.1.dev0" +version = "4.1.1" [project.entry-points."array_api"] blosc2 = "blosc2" diff --git a/src/blosc2/version.py b/src/blosc2/version.py index 5aa6fa9c..710fa0e3 100644 --- a/src/blosc2/version.py +++ b/src/blosc2/version.py @@ -1,2 +1,2 @@ -__version__ = "4.1.1.dev0" +__version__ = "4.1.1" __array_api_version__ = "2024.12" From c5446387d693a78bac5c148e98bd2457969ed448 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 5 Mar 2026 18:20:11 +0100 Subject: [PATCH 02/69] Extension compiles with multithreaded matmul --- src/blosc2/blosc2_ext.pyx | 159 +++++++++++++++++++++++++++++++++++++- src/blosc2/linalg.py | 84 ++++++++++++++------ 2 files changed, 216 insertions(+), 27 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index c56104ab..07cba320 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -42,7 +42,6 @@ cimport numpy as np np.import_array() - cdef extern from "": ctypedef signed char int8_t ctypedef signed short int16_t @@ -53,6 +52,12 @@ cdef extern from "": ctypedef unsigned int uint32_t ctypedef unsigned long long uint64_t +ctypedef fused T: + float + double + int32_t + int64_t + cdef extern from "": int printf(const char *format, ...) nogil @@ -2017,7 +2022,6 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, cdef b2nd_array_t* ndarr cdef int rc cdef void** input_buffers = malloc(udata.ninputs * sizeof(uint8_t*)) - cdef float *buf cdef uint8_t* src cdef uint8_t* chunk cdef c_bool needs_free @@ -2143,6 +2147,124 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, return 0 +cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: + cdef int r, c, k + cdef T a + cdef int rowA, rowC, rowB + for r in range(M): + rowA = r * K + rowC = r * N + for k in range(K): + a = A[rowA + k] + rowB = k * N + for c in range(N): + C[rowC + c] += (a * B[rowB + c]) + +cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: + # Declare all C variables at the beginning + cdef b2nd_array_t* out_arr + cdef b2nd_array_t* ndarr + cdef int rc + cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) + cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) + cdef int32_t chunk_nbytes[2] + cdef int32_t chunk_cbytes[2] + cdef int32_t block_nbytes[2] + cdef int blocknitems[2] + cdef int startA, startB, expected_blocknitems + cdef blosc2_context* dctx + cdef int base, i, j, nchunkA, nchunkB, nblockA, nblockB, chunk_startA, chunk_startB, block_base, block_i, block_j, block_startA, block_startB, idx, chunk_idx, block_ncols, block_nrows, nblocks_per_2d + + out_arr = udata.array + cdef int ndim = out_arr.ndim + cdef int ncols = udata.chunks_in_array[ndim - 1] + cdef int nrows = udata.chunks_in_array[ndim - 2] + cdef int nchunks_per_2d = ncols * nrows + + block_ncols = udata.blocks_in_chunk[ndim - 1] + block_nrows = udata.blocks_in_chunk[ndim - 2] + nblocks_per_2d = block_ncols * block_nrows + + # nchunk = base * nchunks_per2d + i * ncols + j + base = nchunk // nchunks_per_2d + i = (nchunk % nchunks_per_2d) // ncols + j = nchunk % ncols + nchunkA = chunk_startA = nchunk - j + nchunkB = chunk_startB = nchunk - i * ncols + + # nblock = block_base * nblocks_per_2d + block_i * ncols + block_j + block_base = nblock // nblocks_per_2d + block_i = (nblock % nblocks_per_2d) // block_ncols + block_j = nblock % block_ncols + block_startA = nblock - j + block_startB = nblock - i * block_ncols + dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) + + cdef void* bufA = input_buffers[0] + cdef void* bufB = input_buffers[1] + + while True: # chunk loop + nblockA = block_startA + nblockB = block_startB + for i in range(2): + chunk_idx = nchunkA if i == 0 else nchunkB + ndarr = udata.inputs[i] + src[i] = ndarr.sc.data[chunk_idx] + rc = blosc2_cbuffer_sizes(src[i], &chunk_nbytes[i], &chunk_cbytes[i], &block_nbytes[i]) + if rc < 0: + raise ValueError("miniexpr: error getting cbuffer sizes") + if block_nbytes[i] <= 0: + raise ValueError("miniexpr: invalid block size") + input_buffers[i] = malloc(block_nbytes[i]) + if input_buffers[i] == NULL: + raise MemoryError("miniexpr: cannot allocate input block buffer") + blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize + if i == 0: + expected_blocknitems = blocknitems[i] + elif blocknitems[i] != expected_blocknitems: + raise ValueError("miniexpr: inconsistent block element counts across inputs") + while True: # block loop + startA = nblockA * blocknitems[0] + startB = nblockB * blocknitems[1] + rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], + input_buffers[0], block_nbytes[0]) + if rc < 0: + raise ValueError("matmul: error decompressing the A chunk") + rc = blosc2_getitem_ctx(dctx, src[1], chunk_cbytes[1], startB, blocknitems[1], + input_buffers[1], block_nbytes[1]) + if rc < 0: + raise ValueError("matmul: error decompressing the B chunk") + if typecode == 0: + if typesize == 4: + rc = matmul_block_kernel[float](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + rc = matmul_block_kernel[double](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + elif typecode == 1: + if typesize == 4: + rc = matmul_block_kernel[int32_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + rc =matmul_block_kernel[int64_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + with gil: + raise ValueError("Unsupported dtype") + nblockA += 1 + nblockB += ncols + if (nblockA % block_ncols != 0): + break + nchunkA += 1 + nchunkB += ncols + if (nchunkA % ncols != 0): + break + + blosc2_free_ctx(dctx) + # Free resources + for i in range(2): + free(input_buffers[i]) + free(src[i]) + free(input_buffers) + free(src) + + return 0 # Aux function for prefilter and postfilter udf cdef int aux_udf(udf_udata *udata, int64_t nchunk, int32_t nblock, @@ -2221,6 +2343,20 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): return aux_miniexpr( params.user_data, params.nchunk, params.nblock, False, params.output, params.output_typesize) +cdef int matmul_prefilter(blosc2_prefilter_params *params): + cdef int typecode + cdef b2nd_array_t* out_arr + + cdef me_udata* udata = params.user_data + out_arr = udata.array + cdef np.dtype out_type = np.dtype(out_arr.dtype) + if out_type.kind == 'f': + typecode = 0 + elif out_type.kind == 'f': + typecode = 1 + else: + raise ValueError("Unsupported dtype") + return aux_matmul(udata, params.nchunk, params.nblock, params.output, params.output_typesize, typecode) cdef int general_udf_prefilter(blosc2_prefilter_params *params): cdef udf_udata *udata = params.user_data @@ -3153,6 +3289,25 @@ cdef class NDArray: if self.array.sc.cctx == NULL: raise RuntimeError("Could not create compression context") + def _set_pref_matmul(self, inputs, fp_accuracy): + # Set prefilter for miniexpr + cdef blosc2_cparams* cparams = self.array.sc.storage.cparams + cparams.prefilter = matmul_prefilter + + cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, False) + cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) + preparams.user_data = udata + preparams.output_is_disposable = False + cparams.preparams = preparams + _check_cparams(cparams) + + if self.array.sc.cctx != NULL: + # Freeing NULL context can lead to segmentation fault + blosc2_free_ctx(self.array.sc.cctx) + self.array.sc.cctx = blosc2_create_cctx(dereference(cparams)) + if self.array.sc.cctx == NULL: + raise RuntimeError("Could not create compression context") + def _set_pref_udf(self, func, inputs_id): if self.array.sc.storage.cparams.nthreads > 1: raise AttributeError("compress `nthreads` must be 1 when assigning a prefilter") diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index b1bda5e7..57889a32 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -23,7 +23,7 @@ from collections.abc import Sequence -def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: +def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: # noqa: C901 """ Computes the matrix product between two Blosc2 NDArrays. @@ -112,30 +112,64 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra kwargs["_chunksize_reduc_factor"] = 1 result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) - if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s - p, q = result.chunks[-2:] - r = x2.chunks[-1] - - intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) - for chunk in intersecting_chunks: - chunk = chunk.raw - for row in range(0, n, p): - row_end = builtins.min(row + p, n) - for col in range(0, m, q): - col_end = builtins.min(col + q, m) - for aux in range(0, k, r): - aux_end = builtins.min(aux + r, k) - bx1 = ( - x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] - if x1.ndim > 2 - else x1[row:row_end, aux:aux_end] - ) - bx2 = ( - x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] - if x2.ndim > 2 - else x2[aux:aux_end, col:col_end] - ) - result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) + # multithreaded matmul + # TODO: handle a) type promotion, b) non-square blocks, c) and >2D + ops = (x1, x2, result) + shape, chunks, blocks = result.shape, result.chunks, result.blocks + all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) + use_miniexpr = True + if all_ndarray: + # can maybe relax this to just have A.blocks[-1] == B.blocks[-2] + # Require aligned NDArray operands with identical chunk/block grid, and square matrices/chunks/blocks + same_shape = all(op.shape[-1] == op.shape[-2] and op.shape == shape for op in ops) + same_chunks = all(op.shape[-1] == op.shape[-2] and op.chunks == chunks for op in ops) + same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) + if not (same_shape and same_chunks and same_blocks): + use_miniexpr = False + if any(op.dtype != ops[0].dtype for op in ops): + use_miniexpr = False + else: + use_miniexpr = False + + if use_miniexpr: + prefilter_set = False + try: + result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) + prefilter_set = True + # Data to compress is fetched from operands, so it can be uninitialized here + data = np.empty(result.schunk.chunksize, dtype=np.uint8) + for nchunk_out in range(result.schunk.nchunks): + result.schunk.update_data(nchunk_out, data, copy=False) + except Exception as e: + raise Exception from e + finally: + if prefilter_set: + result.schunk.remove_prefilter("miniexpr") + else: # couldn't do multithreading + if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + p, q = result.chunks[-2:] + r = x2.chunks[-1] + + intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) + for chunk in intersecting_chunks: + chunk = chunk.raw + for row in range(0, n, p): + row_end = builtins.min(row + p, n) + for col in range(0, m, q): + col_end = builtins.min(col + q, m) + for aux in range(0, k, r): + aux_end = builtins.min(aux + r, k) + bx1 = ( + x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] + if x1.ndim > 2 + else x1[row:row_end, aux:aux_end] + ) + bx2 = ( + x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] + if x2.ndim > 2 + else x2[aux:aux_end, col:col_end] + ) + result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) if x1_is_vector: result = result.squeeze(axis=-2) From c41dca89cbbca0af4acccaec780980d44f99ec8c Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 6 Mar 2026 13:52:59 +0100 Subject: [PATCH 03/69] Fix indexing bug and segmentation faults --- src/blosc2/blosc2_ext.pyx | 56 ++++++++++++++++++++++++--------------- src/blosc2/linalg.py | 2 +- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 07cba320..0f470805 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -987,7 +987,7 @@ cdef _check_cparams(blosc2_cparams *cparams): if ufilters[i] and cparams.filters[i] in blosc2.ufilters_registry.keys(): raise ValueError("Cannot use multi-threading with user defined Python filters") - if cparams.prefilter != NULL and cparams.prefilter != miniexpr_prefilter: + if cparams.prefilter != NULL and (cparams.prefilter != miniexpr_prefilter and cparams.prefilter != matmul_prefilter): # Note: miniexpr_prefilter uses miniexpr C API which is thread-friendly, raise ValueError("`nthreads` must be 1 when a prefilter is set") @@ -2159,12 +2159,14 @@ cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: rowB = k * N for c in range(N): C[rowC + c] += (a * B[rowB + c]) + return 0 cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: # Declare all C variables at the beginning cdef b2nd_array_t* out_arr cdef b2nd_array_t* ndarr - cdef int rc + cdef c_bool first_run + cdef int rc, M, K, N cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) cdef int32_t chunk_nbytes[2] @@ -2192,18 +2194,18 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param nchunkA = chunk_startA = nchunk - j nchunkB = chunk_startB = nchunk - i * ncols - # nblock = block_base * nblocks_per_2d + block_i * ncols + block_j + # nblock = block_base * nblocks_per_2d + block_i * block_ncols + block_j block_base = nblock // nblocks_per_2d block_i = (nblock % nblocks_per_2d) // block_ncols block_j = nblock % block_ncols - block_startA = nblock - j - block_startB = nblock - i * block_ncols + block_startA = nblock - block_j + block_startB = nblock - block_i * block_ncols dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) - cdef void* bufA = input_buffers[0] - cdef void* bufB = input_buffers[1] + first_run = True while True: # chunk loop + printf("chunks: %i, %i\n", nchunkA, nchunkB) nblockA = block_startA nblockB = block_startB for i in range(2): @@ -2215,7 +2217,13 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: error getting cbuffer sizes") if block_nbytes[i] <= 0: raise ValueError("miniexpr: invalid block size") - input_buffers[i] = malloc(block_nbytes[i]) + if first_run: + if i == 0: + K = ndarr.blockshape[ndim - 1] + M = ndarr.blockshape[ndim - 2] + else: # i = 1 + N = ndarr.blockshape[ndim - 1] + input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize @@ -2223,7 +2231,10 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param expected_blocknitems = blocknitems[i] elif blocknitems[i] != expected_blocknitems: raise ValueError("miniexpr: inconsistent block element counts across inputs") + + first_run = False while True: # block loop + printf("blocks: %i, %i\n", nblockA, nblockB) startA = nblockA * blocknitems[0] startB = nblockB * blocknitems[1] rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], @@ -2236,31 +2247,32 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("matmul: error decompressing the B chunk") if typecode == 0: if typesize == 4: - rc = matmul_block_kernel[float](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[float](input_buffers[0], input_buffers[1], params_output, M, K, N) else: - rc = matmul_block_kernel[double](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[double](input_buffers[0], input_buffers[1], params_output, M, K, N) elif typecode == 1: if typesize == 4: - rc = matmul_block_kernel[int32_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[int32_t](input_buffers[0], input_buffers[1], params_output, M, K, N) else: - rc =matmul_block_kernel[int64_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[int64_t](input_buffers[0], input_buffers[1], params_output, M, K, N) else: with gil: raise ValueError("Unsupported dtype") nblockA += 1 - nblockB += ncols - if (nblockA % block_ncols != 0): + nblockB += block_ncols + if (nblockA % block_ncols == 0): break nchunkA += 1 nchunkB += ncols - if (nchunkA % ncols != 0): + if (nchunkA % ncols == 0): break + printf("finished block %i for chunk %i\n", nblock, nchunk) + blosc2_free_ctx(dctx) # Free resources for i in range(2): free(input_buffers[i]) - free(src[i]) free(input_buffers) free(src) @@ -2345,14 +2357,13 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): cdef int matmul_prefilter(blosc2_prefilter_params *params): cdef int typecode - cdef b2nd_array_t* out_arr cdef me_udata* udata = params.user_data - out_arr = udata.array - cdef np.dtype out_type = np.dtype(out_arr.dtype) - if out_type.kind == 'f': + cdef b2nd_array_t* out_arr = udata.array + cdef char dtype_kind = out_arr.dtype[1] + if dtype_kind == 'f': typecode = 0 - elif out_type.kind == 'f': + elif dtype_kind == 'i': typecode = 1 else: raise ValueError("Unsupported dtype") @@ -3294,7 +3305,8 @@ cdef class NDArray: cdef blosc2_cparams* cparams = self.array.sc.storage.cparams cparams.prefilter = matmul_prefilter - cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, False) + cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, aux_reduc=None) + cdef b2nd_array_t* out_arr = udata.array cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) preparams.user_data = udata preparams.output_is_disposable = False diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 57889a32..3b258ea7 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -126,7 +126,7 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) if not (same_shape and same_chunks and same_blocks): use_miniexpr = False - if any(op.dtype != ops[0].dtype for op in ops): + if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False else: use_miniexpr = False From 50d187f07d31c40dce558738b26628be04009b61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:43:03 +0000 Subject: [PATCH 04/69] Bump pypa/cibuildwheel from 3.3 to 3.4 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 3.3 to 3.4. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v3.3...v3.4) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-version: '3.4' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cibuildwheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cibuildwheels.yml b/.github/workflows/cibuildwheels.yml index 1d7a632a..ed69f764 100644 --- a/.github/workflows/cibuildwheels.yml +++ b/.github/workflows/cibuildwheels.yml @@ -92,7 +92,7 @@ jobs: arch: amd64 - name: Build wheels - uses: pypa/cibuildwheel@v3.3 + uses: pypa/cibuildwheel@v3.4 - name: Make sdist if: ${{ matrix.os == 'ubuntu-latest' }} From 05ca54e362c5e3b8c31d2e3faf39a35cc8975441 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 9 Mar 2026 12:44:05 +0100 Subject: [PATCH 05/69] New version of pure DSL Mandelbrot example for better comparisons --- .../tutorials/03.lazyarray-udf-kernels.ipynb | 10 +- examples/ndarray/mandelbrot-pure-dsl.ipynb | 358 ++++++++++-------- 2 files changed, 214 insertions(+), 154 deletions(-) diff --git a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb index 8dc90eca..9b7c3645 100644 --- a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb +++ b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb @@ -27,11 +27,11 @@ "source": [ "### Choosing the Right Interface\n", "\n", - "| Goal | Recommended API |\n", - "|--------------------------------------------------------------|------------------------------------------|\n", - "| Elementwise formulas using built-in functions/operators | blosc2.lazyexpr(...) |\n", - "| Arbitrary Python logic (including numba) over blocks/chunks | blosc2.lazyudf(...) |\n", - "| DSL subset with early syntax checks and optional miniexpr JIT | @blosc2.dsl_kernel + blosc2.lazyudf(...) |\n" + "| Goal | Recommended API |\n", + "|--------------------------------------------------------------| --- |\n", + "| Elementwise formulas using built-in functions/operators | `blosc2.lazyexpr(...)` |\n", + "| Arbitrary Python logic (including numba) over blocks/chunks | `blosc2.lazyudf(...)` |\n", + "| DSL subset with early syntax checks and optional miniexpr JIT | `@blosc2.dsl_kernel` + `blosc2.lazyudf(...)` |\n" ] }, { diff --git a/examples/ndarray/mandelbrot-pure-dsl.ipynb b/examples/ndarray/mandelbrot-pure-dsl.ipynb index 68c1f505..e29ba7cd 100644 --- a/examples/ndarray/mandelbrot-pure-dsl.ipynb +++ b/examples/ndarray/mandelbrot-pure-dsl.ipynb @@ -4,18 +4,20 @@ "cell_type": "markdown", "id": "intro", "metadata": {}, - "source": "# Mandelbrot With Blosc2 DSL\n\nThis notebook shows how Blosc2 DSL can be used to accelerate the computation of the Mandelbrot set.\n- `@blosc2.dsl_kernel` through `blosc2.lazyudf` (`blosc2+DSL`)\n- a NumPy-based implementation for reference.\n\nThis can run in the three major platforms (Linux, Windows, MacOS) and on browsers via Web Assembly (WASM).\n" + "source": "# Mandelbrot With Blosc2 DSL\n\nThis notebook shows how Blosc2 DSL can be used to accelerate the computation of the Mandelbrot set.\n- `@blosc2.dsl_kernel` through `blosc2.lazyudf` (`blosc2+DSL`)\n- a pure Python `blosc2.lazyudf` using NumPy operations on each block (`blosc2+NumPy`).\n\nThis can run in the three major platforms (Linux, Windows, MacOS) and on browsers via Web Assembly (WASM).\n" }, { "cell_type": "code", + "execution_count": 1, "id": "imports", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:17:58.314706Z", - "start_time": "2026-03-04T09:17:58.035312Z" - } + "end_time": "2026-03-09T11:41:27.988249Z", + "start_time": "2026-03-09T11:41:27.734339Z" + }, + "trusted": true }, + "outputs": [], "source": [ "import time\n", "\n", @@ -23,20 +25,28 @@ "import numpy as np\n", "\n", "import blosc2" - ], - "outputs": [], - "execution_count": 1 + ] }, { "cell_type": "code", + "execution_count": 2, "id": "grid-setup", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:17:58.367732Z", - "start_time": "2026-03-04T09:17:58.326450Z" - } + "end_time": "2026-03-09T11:41:28.008743Z", + "start_time": "2026-03-09T11:41:27.994350Z" + }, + "trusted": true }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "grid: (800, 1200), dtype: float32\n" + ] + } + ], "source": [ "# Problem size and Mandelbrot domain\n", "WIDTH = 1200\n", @@ -52,32 +62,25 @@ "\n", "# Keep compression overhead low for the timing comparison\n", "cparams_fast = blosc2.CParams(codec=blosc2.Codec.LZ4, clevel=1)\n", + "cparams_single_thread = blosc2.CParams(codec=blosc2.Codec.LZ4, clevel=1, nthreads=1)\n", "cr_b2 = blosc2.asarray(cr_np, cparams=cparams_fast)\n", "ci_b2 = blosc2.asarray(ci_np, cparams=cparams_fast)\n", "\n", "print(f\"grid: {cr_np.shape}, dtype: {cr_np.dtype}\")" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "grid: (800, 1200), dtype: float32\n" - ] - } - ], - "execution_count": 2 + ] }, { "cell_type": "code", + "execution_count": 3, "id": "dsl-kernel", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:17:58.385387Z", - "start_time": "2026-03-04T09:17:58.368666Z" - } + "end_time": "2026-03-09T11:41:28.027809Z", + "start_time": "2026-03-09T11:41:28.014980Z" + }, + "trusted": true }, + "outputs": [], "source": [ "@blosc2.dsl_kernel\n", "def mandelbrot_dsl(cr, ci, max_iter):\n", @@ -92,26 +95,27 @@ " zi = 2 * zr * zi + ci\n", " zr = zr_new\n", " return escape_iter" - ], - "outputs": [], - "execution_count": 3 + ] }, { "cell_type": "code", + "execution_count": 4, "id": "d166bd73f67513f9", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:17:58.405886Z", - "start_time": "2026-03-04T09:17:58.388118Z" - } + "end_time": "2026-03-09T11:41:28.043053Z", + "start_time": "2026-03-09T11:41:28.028995Z" + }, + "trusted": true }, + "outputs": [], "source": [ - "def mandelbrot_numpy(cr, ci, max_iter):\n", - " zr = np.zeros_like(cr, dtype=np.float32)\n", - " zi = np.zeros_like(ci, dtype=np.float32)\n", - " out = np.full(cr.shape, np.float32(max_iter), dtype=np.float32)\n", - " active = np.ones(cr.shape, dtype=bool)\n", + "def mandelbrot_numpy_udf(inputs_tuple, output, offset):\n", + " cr, ci, max_iter = inputs_tuple\n", + " zr = np.zeros_like(output, dtype=np.float32)\n", + " zi = np.zeros_like(output, dtype=np.float32)\n", + " output[:] = np.float32(max_iter)\n", + " active = np.ones(output.shape, dtype=bool)\n", "\n", " for it in range(max_iter):\n", " if not active.any():\n", @@ -128,7 +132,7 @@ " escaped = zr2 + zi2 > np.float32(4.0)\n", "\n", " if escaped.any():\n", - " out[iy[escaped], ix[escaped]] = np.float32(it)\n", + " output[iy[escaped], ix[escaped]] = np.float32(it)\n", " active[iy[escaped], ix[escaped]] = False\n", "\n", " keep = ~escaped\n", @@ -140,11 +144,8 @@ " cr_s = cr_a[keep]\n", " ci_s = ci_a[keep]\n", " zr[iy[keep], ix[keep]] = zr_s * zr_s - zi_s * zi_s + cr_s\n", - " zi[iy[keep], ix[keep]] = np.float32(2.0) * zr_s * zi_s + ci_s\n", - " return out" - ], - "outputs": [], - "execution_count": 4 + " zi[iy[keep], ix[keep]] = np.float32(2.0) * zr_s * zi_s + ci_s" + ] }, { "cell_type": "markdown", @@ -154,14 +155,46 @@ }, { "cell_type": "code", + "execution_count": 5, "id": "benchmark", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:18:07.215852Z", - "start_time": "2026-03-04T09:17:58.406755Z" - } + "end_time": "2026-03-09T11:41:38.137557Z", + "start_time": "2026-03-09T11:41:28.044354Z" + }, + "trusted": true }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "First iteration timings (one-time overhead included):\n", + "Blosc2+NumPy first run: 1.529686 s\n", + "Blosc2+DSL (no JIT) first run: 0.208474 s\n", + "Blosc2+DSL (1 thread) first run: 0.268345 s\n", + "Blosc2+DSL first run: 0.036961 s\n", + "\n", + "Best-time stats:\n", + "Blosc2+NumPy time (best): 1.496923 s\n", + "Blosc2+DSL (no JIT) time (best): 0.194362 s\n", + "Blosc2+DSL (1 thread) time (best): 0.267587 s\n", + "Blosc2+DSL time (best): 0.033397 s\n", + "Blosc2+NumPy / Blosc2+DSL (no JIT): 7.70x\n", + "Blosc2+NumPy / Blosc2+DSL (1 thread): 5.59x\n", + "Blosc2+NumPy / Blosc2+DSL: 44.82x\n", + "\n", + "Cold-start overhead (first - best):\n", + "Blosc2+NumPy overhead: 0.032763 s\n", + "Blosc2+DSL (no JIT) overhead: 0.014112 s\n", + "Blosc2+DSL (1 thread) overhead: 0.000758 s\n", + "Blosc2+DSL overhead: 0.003564 s\n", + "max |blosc2+numpy-dsl(no jit)|: 0.000000\n", + "max |blosc2+numpy-dsl(1 thread)|: 0.000000\n", + "max |blosc2+numpy-dsl|: 0.000000\n" + ] + } + ], "source": [ "def best_time(func, repeats=3, warmup=1):\n", " for _ in range(warmup):\n", @@ -178,8 +211,35 @@ " return best, best_out\n", "\n", "\n", - "def run_numpy():\n", - " return mandelbrot_numpy(cr_np, ci_np, MAX_ITER)\n", + "def run_numpy_udf():\n", + " lazy = blosc2.lazyudf(\n", + " mandelbrot_numpy_udf,\n", + " (cr_b2, ci_b2, MAX_ITER),\n", + " dtype=np.float32,\n", + " cparams=cparams_fast,\n", + " )\n", + " return lazy.compute()\n", + "\n", + "\n", + "def run_dsl_no_jit():\n", + " lazy = blosc2.lazyudf(\n", + " mandelbrot_dsl,\n", + " (cr_b2, ci_b2, MAX_ITER),\n", + " dtype=np.float32,\n", + " cparams=cparams_fast,\n", + " jit=False,\n", + " )\n", + " return lazy.compute()\n", + "\n", + "\n", + "def run_dsl_single_thread():\n", + " lazy = blosc2.lazyudf(\n", + " mandelbrot_dsl,\n", + " (cr_b2, ci_b2, MAX_ITER),\n", + " dtype=np.float32,\n", + " cparams=cparams_single_thread,\n", + " )\n", + " return lazy.compute()\n", "\n", "\n", "def run_dsl():\n", @@ -192,10 +252,20 @@ " return lazy.compute()\n", "\n", "\n", - "# Measure first iteration (includes one-time overhead, especially JIT compile)\n", + "# Measure first iteration (includes one-time overhead, especially DSL JIT compile)\n", "t0 = time.perf_counter()\n", - "_ = run_numpy()\n", - "t_numpy_first = time.perf_counter() - t0\n", + "_ = run_numpy_udf()\n", + "t_numpy_udf_first = time.perf_counter() - t0\n", + "\n", + "\n", + "t0 = time.perf_counter()\n", + "_ = run_dsl_no_jit()\n", + "t_dsl_no_jit_first = time.perf_counter() - t0\n", + "\n", + "\n", + "t0 = time.perf_counter()\n", + "_ = run_dsl_single_thread()\n", + "t_dsl_single_thread_first = time.perf_counter() - t0\n", "\n", "\n", "t0 = time.perf_counter()\n", @@ -203,10 +273,14 @@ "t_dsl_first = time.perf_counter() - t0\n", "\n", "\n", - "t_numpy, img_numpy = best_time(run_numpy)\n", + "t_numpy_udf, img_numpy_udf = best_time(run_numpy_udf)\n", + "t_dsl_no_jit, img_dsl_no_jit = best_time(run_dsl_no_jit)\n", + "t_dsl_single_thread, img_dsl_single_thread = best_time(run_dsl_single_thread)\n", "t_dsl, img_dsl = best_time(run_dsl)\n", "\n", - "cold_overhead_native = t_numpy_first - t_numpy\n", + "cold_overhead_numpy_udf = t_numpy_udf_first - t_numpy_udf\n", + "cold_overhead_dsl_no_jit = t_dsl_no_jit_first - t_dsl_no_jit\n", + "cold_overhead_dsl_single_thread = t_dsl_single_thread_first - t_dsl_single_thread\n", "cold_overhead_dsl = t_dsl_first - t_dsl\n", "\n", "\n", @@ -214,60 +288,66 @@ " return np.max(np.abs(np.asarray(a) - np.asarray(b))).item()\n", "\n", "\n", - "c_max = _max_abs_diff(img_numpy, img_dsl)\n", + "b_max = _max_abs_diff(img_numpy_udf, img_dsl_no_jit)\n", + "c_max = _max_abs_diff(img_numpy_udf, img_dsl_single_thread)\n", + "d_max = _max_abs_diff(img_numpy_udf, img_dsl)\n", "\n", "print(\"First iteration timings (one-time overhead included):\")\n", - "print(f\"NumPy first run (baseline): {t_numpy_first:.6f} s\")\n", - "print(f\"Blosc2+DSL first run: {t_dsl_first:.6f} s\")\n", + "print(f\"Blosc2+NumPy first run: {t_numpy_udf_first:.6f} s\")\n", + "print(f\"Blosc2+DSL (no JIT) first run: {t_dsl_no_jit_first:.6f} s\")\n", + "print(f\"Blosc2+DSL (1 thread) first run: {t_dsl_single_thread_first:.6f} s\")\n", + "print(f\"Blosc2+DSL first run: {t_dsl_first:.6f} s\")\n", "\n", "print(\"\\nBest-time stats:\")\n", - "print(f\"NumPy time (best): {t_numpy:.6f} s\")\n", + "print(f\"Blosc2+NumPy time (best): {t_numpy_udf:.6f} s\")\n", + "print(f\"Blosc2+DSL (no JIT) time (best): {t_dsl_no_jit:.6f} s\")\n", + "print(f\"Blosc2+DSL (1 thread) time (best): {t_dsl_single_thread:.6f} s\")\n", "print(f\"Blosc2+DSL time (best): {t_dsl:.6f} s\")\n", - "print(f\"NumPy / Blosc2+DSL: {t_numpy / t_dsl:.2f}x\")\n", + "print(f\"Blosc2+NumPy / Blosc2+DSL (no JIT): {t_numpy_udf / t_dsl_no_jit:.2f}x\")\n", + "print(f\"Blosc2+NumPy / Blosc2+DSL (1 thread): {t_numpy_udf / t_dsl_single_thread:.2f}x\")\n", + "print(f\"Blosc2+NumPy / Blosc2+DSL: {t_numpy_udf / t_dsl:.2f}x\")\n", "print(\"\\nCold-start overhead (first - best):\")\n", - "print(f\"NumPy overhead: {cold_overhead_native:.6f} s\")\n", - "print(f\"Blosc2+DSL overhead: {cold_overhead_dsl:.6f} s\")\n", - "\n", - "print(f\"max |numpy-dsl(cc)|: {c_max:.6f}\")" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "First iteration timings (one-time overhead included):\n", - "NumPy first run (baseline): 1.797350 s\n", - "Blosc2+DSL first run: 0.057364 s\n", - "\n", - "Best-time stats:\n", - "NumPy time (best): 1.654180 s\n", - "Blosc2+DSL time (best): 0.034381 s\n", - "NumPy / Blosc2+DSL: 48.11x\n", - "\n", - "Cold-start overhead (first - best):\n", - "NumPy overhead: 0.143169 s\n", - "Blosc2+DSL overhead: 0.022983 s\n", - "max |numpy-dsl(cc)|: 0.000000\n" - ] - } - ], - "execution_count": 5 + "print(f\"Blosc2+NumPy overhead: {cold_overhead_numpy_udf:.6f} s\")\n", + "print(f\"Blosc2+DSL (no JIT) overhead: {cold_overhead_dsl_no_jit:.6f} s\")\n", + "print(f\"Blosc2+DSL (1 thread) overhead: {cold_overhead_dsl_single_thread:.6f} s\")\n", + "print(f\"Blosc2+DSL overhead: {cold_overhead_dsl:.6f} s\")\n", + "\n", + "print(f\"max |blosc2+numpy-dsl(no jit)|: {b_max:.6f}\")\n", + "print(f\"max |blosc2+numpy-dsl(1 thread)|: {c_max:.6f}\")\n", + "print(f\"max |blosc2+numpy-dsl|: {d_max:.6f}\")" + ] }, { "cell_type": "code", + "execution_count": 6, "id": "plot", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:18:07.479845Z", - "start_time": "2026-03-04T09:18:07.233302Z" - } + "end_time": "2026-03-09T11:41:38.385197Z", + "start_time": "2026-03-09T11:41:38.153710Z" + }, + "trusted": true }, + "outputs": [ + { + "data": { + "image/png": "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lpYWUaj7mWeeyfi5oihC2qlramoSKR60fWgbkJhPBt1wo+1/+PBhIa60Hu9617vE9qZ1tH8A0LqedNJJQuDf+9734sc//rEQ4qqqquS0aH//8pe/FOJHf9O0SRzPPvvsSZeBlpmgHxC5giT9G9/4Bl73uteJpx5zCW2DlStXivWj/U3bi+osUk0e2hd0DFL6ER3Pb33rW6ecHm1DklT68UL84he/EOk443+IHHfccVi9erXYxqk8/fTTYvzxwzMMwzBMocOeyZ5ZjJ6ZDToO6Ybyeeedl+xHT5JSWZxcetpLL70k5pOtPirDMIUNNVvGHXfcAeYVV1xhEps3bzavueYac2hoyHS73eKz3/3ud+bf//538ffevXvNP//5z2nj2sPZnaqq5nPPPWfed999af2JSCRiLlu2LNlv/fr1ov973/veZL8//vGPZigUMtva2pL91qxZY8bjcTGs3a+9vV30u/baa9Pms27dOjMWi6X1v+mmm8Sy2+8XL14sphUMBs2WlpZk/+OPP170/9///d+0cYmvfOUrafN53eteJ/p/+tOfTut/yy23mLqup63nyMiImM509sXZZ58tpnvBBRdM+Oz+++83M/Hiiy+aS5YsybhPaV3pfV1dndj+d911lylJUnI42t/ElVdeKd5v2LBBvL/sssuyLuPy5ctNTdPMW2+9NW1a0+nuuecec3Bw0KysrJx0uM9//vNp+yxTd8YZZySXNRAImH19feZtt92Wtu9o248/Dmna46dF80rdR/b2u/POO9OGe/jhh8X+/f73v5/sJ8uyeeDAAbF/xs/rxhtvNGtra8X2p+Pr3nvvFf0//OEPi2Fouel4v/7669PG/eY3vymW3ev1pvU/6aSTxPhvfOMbZ/Vd54477rjjjrv57tgzrf7smcXrmdmGefbZZ4V/2u9f9apXieOGOnLGr371q+a5554rjtvx42Y63rN127dvN//617/m7DvJHXfcYV46fuKRYSapI0LFoy+88ELxWD+9Zkt/IVILgVOkkiLAVAslUyvDVBeGWsezoVQYSkWgKCchy7JIibnttttEVNOGUksoOprK61//ejE8La8dmaWO0kEoKjudBjhoPhSVtXnyySdFvZtMUfQf/OAHae9pGIq6f/vb307rT3VfaLkogj8baB0ISuvNBKUEv+pVrxIdNVBCTwLQNqdC2pPVRqLh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", + "text/plain": [ + "
" + ] + }, + "jetTransient": { + "display_id": null + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "fig, ax = plt.subplots(1, 2, figsize=(13, 5), constrained_layout=True)\n", "\n", "im0 = ax[0].imshow(\n", - " img_numpy,\n", + " img_numpy_udf,\n", " cmap=\"magma\",\n", " extent=(X_MIN, X_MAX, Y_MIN, Y_MAX),\n", " origin=\"lower\",\n", @@ -289,97 +369,77 @@ "fig.colorbar(im1, ax=ax[1], shrink=0.82, label=\"Escape iteration\")\n", "\n", "plt.show()" - ], + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "timing-bars", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T11:41:38.459742Z", + "start_time": "2026-03-09T11:41:38.393777Z" + }, + "trusted": true + }, "outputs": [ { "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABFcAAAH/CAYAAACSKTLZAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAbSRJREFUeJzt3Qm8jPX////XsdexlCWkSIQWSagUKUpSn0RFixLFx9InosVSiYpEJGkjS30QLdqzlahI2SWqT4TsS/ad+d+e7+9/5jczZ845c84158xZHvfb7box11wz855rO3O9rtf79U4wM58BAAAAAAAgXfKk72UAAAAAAAAQgisAAAAAAAAeEFwBAAAAAADwgOAKAAAAAACABwRXAAAAAAAAPCC4AgAAAAAA4AHBFQAAAAAAAA8IrgAAAAAAAHhAcAUAAAAAAMADgisAkI317dvXfD5ful47duxYW7t2beBxhQoV3Hv16NHDsqs2bdq476DvkhXWcXYxe/ZsNwGZRecenYOQc+g8OWLECMsK/H/P9DcBADILwRUASOVCXdNVV10VcZn169e75z/77LNMb19216tXL2vWrFmqy+mi378dUpoUBEHohUXwtGfPHluyZIl16dLF8uTJWn/+zzrrLHv66adtwYIFtmvXLtu+fbvb7o0aNYrZZ+hCXuth2bJlWe7CMPhco+nQoUP222+/ufacccYZlh00aNAgyT63c+dOmz9/vt19992W1VStWtUGDRrkjom9e/fapk2b7PPPP7datWqle5tt3LjRpk2bZv/5z3+scOHCEV+nvyVffvml/f333+4169ats08//dTuuuuumOyP/mCwfzpx4oT7bvobdfnll6f5/QAA0cuXhmUBIFfSD2BdHPzwww9JLibOPvtsO3z4cNzalp317t3bPvjgA/vkk09SXO7555+30aNHBx7XqVPHunbt6uavWrUqMH/58uW2cuVKe++99+zIkSMxa+dzzz1nL7zwgmVHEydOdBdyUqxYMWvatKm9+uqrLvjy+OOPW1ahINsTTzxhH3/8sY0fP97y5ctn9913n82aNcvatm1r48aNi9lnXXzxxdaiRQv76KOPLKt56qmnXEZHoUKFrF69etapUye3zS666CJ3HsoOhg8fbj///LP7f4kSJaxVq1Y2YcIEO+200+y1116zrOLBBx+0Bx54wD788EPXLh0f//73v+3HH3+0Jk2a2Ndff52mbZY/f34rU6aMXXPNNfbyyy9b9+7d7ZZbbrEVK1YElr399ttt8uTJtnTpUree/vnnH6tYsaJdffXV1r59e5s0aVLMvl/Hjh1t//79LpCqv1N6/7lz59pll12WbIARAOCdcp2ZmJiYmMKmNm3a+OSDDz7wbdu2zZc3b96Q5998803fzz//7Fu7dq3vs88+i0sb+/bt69qYnteOHTvWtd3/uEKFCu69evToka73O/XUU9O0/L59+1wb0vo5t912m2tngwYN4r6PZNUppW25YMEC399//x0yb/bs2W6KV3svuOACX4kSJULmFShQwPfrr7/61q9fn+rrtR/rWEhpGe1rBw4c8K1evdq3dOnSJM/LiBEj4nquqVWrVsj8IUOGuPl33nln3Pep1CYdj6LjM3h+/vz5fRs2bPB9//33SbZZeo7/WE2XXnqpLzExMWRe8eLFfVu3bvV999136d5mmq699lq3r+k7FipUKDD/l19+8a1YscKtk/DXlCpVKib7o/9vQvjxpGNMnnvuuQxbp/E8hpI7B2o7xbstTExMlmumrJUXDABZkO4m6g7s9ddfH5inu5S6C6nMgEhUt0SZLjt27LCDBw/awoUL7bbbbkuynD/1W3fudYdTWTC//PKL3XDDDRHTyX/66Sd3B/t///ufdejQIdk233PPPe4z9dlKzdd3UNeLaHXr1s3++usv9/pvv/3WLrzwwiRdLPbt22fnnnuuffHFFy6tXnen5dRTT7UhQ4a4LlP6PqtXr05Sx0XfW2nz999/fyB9PRb1FyLVXNFdZaXEK9NId9T1nZTlosfSvHlz91jrVevskksuSbXmSlq2m/9zg7dbpPe87rrr7LvvvnN3s7Vutd6UnRNMd6DVncGLrVu32vHjx1NdrlSpUi5jaMuWLa7tutuubJJwykzQetM+oK5HWpcPP/xwyDLKChg6dKjbFlpXGzZscBkqOq7k119/dftpsKNHj7qsG33n5LpYpNXJkyddJlKNGjXcdk9P/R5/9xf//iPqwqT9oHr16u54OXDggP3xxx+BY16ZCcqI0L6n7Rptd6dvvvnG/avsBk36XB2b4erWreueu/POOyO+j7oWHTt2zHW9ClelShX3WnUXE2UNabnff//dbXedw7Rfav9MD32u9ulo9jl9xylTprh9QetQXYqU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r149F0hp3ry5lSxZ0l577TUrUaKEtWvXLuJrBg4caEOHDo24YgiuAAAAAAAAr7JVcEW1VVT49p577rG9e/e6ed27d7cPPvjAOnfuHDF75ejRo24CAAAAAACw3D4U8+bNm13WiT+wIqtWrXJBl7POOiuubQMAAAAAALlTtgqu/PDDD26kH40y5FelShU7ceKE/f3333FtGwAAAAAAyJ3iPhRz5cqVA48rVqxoNWrUsF27dtmGDRtswIABrvBsmzZt3PMTJ050xWvHjh1rffv2dTVXBg8e7IZyTq6gLQAAAAAAmS0hIcFOO+00V/dT/0fWo7IjqsW6e/du9/9sG1ypXbu2ffvtt4HHw4YNc/+OGzfO2rZt64ZNLl++fOB5jQ50/fXX24gRI9yoQTt37rQpU6bYk08+GZf2AwAAAAAQrlSpUta+fXurVq1avJuCKKxevdpGjRpl27dvt/RS+MxbeCabUdRQNVuKFi3KaEEAAAAAgJjKly+fG9V2//79Lhlg27ZtrpQFsp68efPaGWecYS1btrTChQu7gXKOHz+e7hiCLzdNRYoU8Yn+jXdbsspUv35936effurbuHGjWzfNmjVLcfkGDRr4IildunRgmY4dO/qWLVvm27Nnj5vmzZvna9KkSdy/KxMTExMTExMTExMTU0ZOZ599tu+dd97xValSJe5tYbKoJm0rbbOzzjor3TGEbFXQFhlX+2bZsmXWpUuXNL1OxYTLlCkTmBSR9VOB4Z49e1qtWrVc969vvvnGPvnkE7vgggsy4BsAAAAAQNag0WzlyJEj8W4KouTfVspkyZY1V5A1TJs2zU1ppWDKnj17Ij73+eefhzxWXZxOnTrZFVdcYb/++qubp6LE7dq1s9KlS7v6OR988IF17do1nd8CAAAAAID4IHMF6bZ06VLbtGmTzZgxw6688soUI7etWrVyGTLz589382677TZ75JFH7N///redd955duutt9qKFSsysfUAAAAAAMQGmStIs82bN7ugiEZsKliwoD344INu1KfLL7/clixZEljuoosucsGUQoUKuWJOzZs3t1WrVrnnNArUli1bbNasWa5gkIbe/vnnn+P4rQAAAAAgY93c871M/bzPX7gzZu81e/Zsd4NdN8mRFJkrSLPff//d3nrrLVu8eLELnjzwwAM2b968JAfZb7/9ZpdccokLurz++us2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W+b7779379GmTRt75pln7JJLLglkx2heclk0U6dOtd69e9vGjRvtt99+c/P1mmbNmoUsq8/wv4/aoWWaN29u33zzjR04cMCWLl1qV1xxhWUkgisAAAAAACCJxMREa926tf3xxx8uiCL58uWz6dOn2759+6x+/fp21VVXuSDKtGnTXHZJ3rx5XbBlzpw5dvHFF1vdunXtrbfecgEPBWSGDBliv/zySyA7RvOSo8yZqlWr2vXXX28333xzmtr+/PPPu89SIOf333+3SZMmubZlFAraAgAAAAAAR0EMBU6kcOHCtmnTJjdPwRFp1aqV5cmTxx588MHAa9q2bWu7d++2a665xhYuXGinnXaa60q0Zs0a9/zq1asDyyoQo65GyoxJjbJO9DnHjh1L8/dQYOXLL78M1Hn59ddfrXLlyoEMmFgjcwUAAAAAADizZ8922R6a6tSp47JUVIdF3XqkRo0aLkihAIx/2rVrlxUqVMgqVarkuuioS49e9+mnn9rDDz/sMlTSY8WKFekKrMjy5csD/9+8ebP794wzzrCMQnAFAAAAAAAEskX+/PNPNykLRZkj6h7Uvn37QDbLokWLAgEY/1SlShWbOHGiW6Zdu3auO9C8efNcpou65Vx++eXpaku4kydPWkJCQqrFboODMv6sG2XcZBS6BQEAAAAAgIgUmFBA45RTTnGPFy9e7AIm27ZtC3QfikRFZDWpIK6CLHfffbcrcnv06FFPtU+2b99uZcuWDTxWFo2CP/FG5goAAAAAAHAKFixopUuXdlO1atVsxIgRLlvls88+c89PmDDBduzYYZ988onVq1fPzjnnHDc60PDhw61cuXLu8YABA9zoPOpKpGK05513nq1atcq9/q+//rKKFSu67kUlSpSwAgUKpKl9GgHooYcectkytWrVsjfeeMMFbOKNzBUAAAAAADJBYp8tltXdeOONtmXL/7Vz7969rhjtHXfc4Ub/kUOHDtnVV19tgwYNso8++siKFCnihkr++uuv3fLKcFFQRkMjK3iieicjR460N998073+ww8/tBYtWrjaLqeffrrdf//9Nn78+Kjb16NHD1fT5bvvvnPFdrt27eqCLPFGcAUAAAAAALhRfzSlRiP9KCgSyb59+1zwJDnKMlGwJpq2RKJgTZMmTULmKUjjt27duiQ1Wfbs2ZNkXqzRLQgAAAAAAMADgisA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ddVVVqlSJfvwww8tXvSDy7+NNQ0ZMiSwTvzT5MmT3bLjxo2zhx9+OG5tRebh2M/8Y79gwYLuYkkBl2XLlllm03fNzN8hEydO5HyCHHvuyonnLcSejylrTGPHjvVNnTo1ZN5VV13lk5IlS7rHFSpUcI9r1KgRWObqq6/2LViwwHf48GHfpk2bfAMHDvTlzZs38Pxtt93mW758ue/gwYO+HTt2+GbOnOk79dRTA8+3bdvW98svvwReP2LEiGTbKGpDpOcaNGjgnh80aJBv3bp1vgIFCgSe++eff3xt2rRJ9jsUK1bMzdN7BL9X48aNfYsXL3Zt//rrr32lSpXyNWnSxPfrr7/69uzZ45swYYLvlFNOSXEdTp8+3Tdv3jz3nfUarY/g55s1a+bbv3+/r3DhwhG/l5bfunVrxO/atGlT37Jly3yHDh3yzZ8/33fhhRcGlilevLhv4sSJvr///tt34MABtw3uvPPOJO+d0rZ54IEH3HfV+69atcrXqVOnwHPh69HfJq1LPdb61nrXOtR77Nu3z/fVV1/5ypQpE9KGlD7DP/3555++du3axf0YYcod55nbb7/dLRf8/sGT9vW1a9cm+/q+ffv6lixZkmR+1apV3fs+++yz7rG+n5QvXz7FdSY6T0S7jvXdpkyZErFNrVu3dm3fvXu3b9KkSSHnHZ0zhw8f7s43Oh6/++47X+3atVP8rNmzZ/uGDRuW7OPU1omms88+233Hc889N+77J1PGTRz78Tn2g6fkjs/gyb8Ngmnb+V+vc4R+Z+3cudO3efNm953D29yxY0ffJ5984n7b+J+/5ZZbfIsWLXLnFv1Nf/rpp0PW8yOPPOK2o16zfv1638iRI32JiYkh763fFfp9p980H330ka979+7ud0bwMvXr13frulChQnHf55lyx7mL85a38xaTxXQicyULS0xMtNatW7uuLeqeEYmip19++aX9/PPPrq9hp06dXJbFk08+6Z5XFHTSpEkuVfP888+3a665xj766KNA//qOHTvayJEj7a233nLpdbfccov973//89Tul19+2UVX//Of/5hXyjp56KGH7Morr7Szzz7bpkyZYt26dbO7777bbrrpJmvcuHGqn6O0OaXVKX3vvffes7Zt24Y8r8cffPCB7d+/P+Lr69evb4sWLYr43ODBg61Hjx5Wp04dd2fqs88+C9wlKlSokHud2qnottbxu+++65aNZtvoO/bv3991SdLzvXv3tmeffTZNWUGnnnqqi67fe++9dvXVV7uIuO5g+0X7GT/99JNbD8h5suJ5Rndl9u7d61LoY0lp+br7pTs+omNWn3H77bfHtD6CjpWFCxcmma872rfeeqvdfPPNbtLdrJ49e4Z0J7jttttcdx2l1msdqdtORmeNKctly5YtHOO5DMd+5h37aT0e/e2sUqWKW8fqHuCn88OBAwfs8ssvt8cff9yefvppu+6665L8dpo6dapb59o26qajLr7qvnDBBRe42ii6466//X7qxqSMkwsvvNB9hmrH6Jzkd9lll7m786+++qpdcsklrguifz8Ipu+v30FqHxCPcxfnLcRb3CM8TP8vMnvs2DGXYaBJNm7c6KtZs2ZgmfDo7HPPPeeyDYLfR5kHe/fu9SUkJLjXphTlVFaFPyIazRRN5ooyJzp06OAiwUWLFvWUudKwYcPAMk888YSbV7FixcC8119/3WVjJBfdbtSokbtL8+KLL7rHderUcevYn72hTJijR4+6CHdy31nvN3r06IjftWXLloF5p59+urubc8cddyT7Xp999plv8ODB7v+pbZs//vgjSaZLnz59fD/88EPUmSvhd6O1b+hOV7Sf4Z9eeukl3zfffBP3Y4Qp559nSpQo4fvrr7/cZya3THrvAmnS3Ssdp/7HnTt3dndqldWm7Lgnn3wy5ByTnrtAOt8pQyW8TeEZcrr7rIw3/V93zI4cOeK76667As/ny5fPrbtHH300QzNXNOmOtu5kx3v/ZMq4iWM/Psd+WjNXIv09D3793LlzQ+bp7ry+W3Cbhw4dGrKM7sr37NkzZN4999zjtn9ybdCd/e3btwceK1P4888/D1lG2XfhmSualFVz3333xX2fZ8od5y7OW97OW0wW04mwVxajOwG6I6BJGQ66a6mopTIOIlHEdf78+SHzfvjhBytSpIidddZZrn/vrFmzXH88ZX08+OCDgX55pUqVcqPAfP3118m2R5Hfffv2BSZZuXJl4LH6CEaiuxuKJj/xxBMe1obZ8uXLA/9XPRHdrQnut6h5Z5xxRshrdEdYbTt8+LBbd6oroLs4oii22u8vrqvIt4pRzZ07N8WaK3qvSILXvYr2KsqsbSKKKitKru+gdaE2aWQO/7ZMadso46Ry5cpuPQavf72f7n5HS+tr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", "text/plain": [ - "
" - ], - "image/png": "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lpYWUaj7mWeeyfi5oihC2qlramoSKR60fWgbkJhPBt1wo+1/+PBhIa60Hu9617vE9qZ1tH8A0LqedNJJQuDf+9734sc//rEQ4qqqquS0aH//8pe/FOJHf9O0SRzPPvvsSZeBlpmgHxC5giT9G9/4Bl73uteJpx5zCW2DlStXivWj/U3bi+osUk0e2hd0DFL6ER3Pb33rW6ecHm1DklT68UL84he/EOk443+IHHfccVi9erXYxqk8/fTTYvzxwzMMwzBMocOeyZ5ZjJ6ZDToO6Ybyeeedl+xHT5JSWZxcetpLL70k5pOtPirDMIUNNVvGHXfcAeYVV1xhEps3bzavueYac2hoyHS73eKz3/3ud+bf//538ffevXvNP//5z2nj2sPZnaqq5nPPPWfed999af2JSCRiLlu2LNlv/fr1ov973/veZL8//vGPZigUMtva2pL91qxZY8bjcTGs3a+9vV30u/baa9Pms27dOjMWi6X1v+mmm8Sy2+8XL14sphUMBs2WlpZk/+OPP170/9///d+0cYmvfOUrafN53eteJ/p/+tOfTut/yy23mLqup63nyMiImM509sXZZ58tpnvBBRdM+Oz+++83M/Hiiy+aS5YsybhPaV3pfV1dndj+d911lylJUnI42t/ElVdeKd5v2LBBvL/sssuyLuPy5ctNTdPMW2+9NW1a0+nuuecec3Bw0KysrJx0uM9//vNp+yxTd8YZZySXNRAImH19feZtt92Wtu9o248/Dmna46dF80rdR/b2u/POO9OGe/jhh8X+/f73v5/sJ8uyeeDAAbF/xs/rxhtvNGtra8X2p+Pr3nvvFf0//OEPi2Fouel4v/7669PG/eY3vymW3ev1pvU/6aSTxPhvfOMbZ/Vd54477rjjjrv57tgzrf7smcXrmdmGefbZZ4V/2u9f9apXieOGOnLGr371q+a5554rjtvx42Y63rN127dvN//617/m7DvJHXfcYV46fuKRYSapI0LFoy+88ELxWD+9Zkt/IVILgVOkkiLAVAslUyvDVBeGWsezoVQYSkWgKCchy7JIibnttttEVNOGUksoOprK61//ejE8La8dmaWO0kEoKjudBjhoPhSVtXnyySdFvZtMUfQf/OAHae9pGIq6f/vb307rT3VfaLkogj8baB0ISuvNBKUEv+pVrxIdNVBCTwLQNqdC2pPVRqLh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" + "
" + ] }, - "metadata": {}, - "output_type": "display_data", "jetTransient": { "display_id": null - } + }, + "metadata": {}, + "output_type": "display_data" } ], - "execution_count": 6 - }, - { - "cell_type": "code", - "id": "timing-bars", - "metadata": { - "trusted": true, - "ExecuteTime": { - "end_time": "2026-03-04T09:18:07.538518Z", - "start_time": "2026-03-04T09:18:07.480603Z" - } - }, "source": [ - "labels = [\"NumPy\", \"Blosc2+DSL\"]\n", - "first_times = [t_numpy_first, t_dsl_first]\n", - "best_times = [t_numpy, t_dsl]\n", + "labels = [\"Blosc+NumPy (baseline)\", \"Blosc2+DSL (no JIT)\", \"Blosc2+DSL (1 thread)\", \"Blosc2+DSL\"]\n", + "first_times = [t_numpy_udf_first, t_dsl_no_jit_first, t_dsl_single_thread_first, t_dsl_first]\n", + "best_times = [t_numpy_udf, t_dsl_no_jit, t_dsl_single_thread, t_dsl]\n", "\n", "x = np.arange(len(labels))\n", - "width = 0.36\n", + "width = 0.28\n", "\n", - "fig, ax = plt.subplots(figsize=(10, 5), constrained_layout=True)\n", + "fig, ax = plt.subplots(figsize=(11, 5), constrained_layout=True)\n", "ax.bar(x - width / 2, first_times, width, label=\"First run\", color=\"#4C78A8\")\n", "ax.bar(x + width / 2, best_times, width, label=\"Best run\", color=\"#F58518\")\n", "\n", "ax.set_xticks(x)\n", "ax.set_xticklabels(labels)\n", "ax.set_ylabel(\"Time (seconds)\")\n", - "ax.set_title(\"Mandelbrot Timings: NumPy vs Blosc2 DSL Backends\")\n", + "ax.set_title(\"Mandelbrot Timings: Blosc2+NumPy vs Blosc2 DSL Backends\")\n", "ax.legend()\n", "\n", - "speedup_first = first_times[0] / first_times[1]\n", - "speedup_best = best_times[0] / best_times[1]\n", + "speedup_first = [first_times[0] / t for t in first_times[1:]]\n", + "speedup_best = [best_times[0] / t for t in best_times[1:]]\n", "for i, t in enumerate(first_times):\n", - " label = f\"{t:.3f}s\"\n", - " if i == 1:\n", - " label += f\" ({speedup_first:.1f}x)\"\n", + " label = f\"{speedup_first[i - 1]:.1f}x\" if i > 0 else f\"{t:.3g}s\"\n", " ax.text(i - width / 2, t, label, ha=\"center\", va=\"bottom\")\n", "for i, t in enumerate(best_times):\n", - " label = f\"{t:.3f}s\"\n", - " if i == 1:\n", - " label += f\" ({speedup_best:.1f}x)\"\n", + " label = f\"{speedup_best[i - 1]:.1f}x\" if i > 0 else f\"{t:.3g}s\"\n", " ax.text(i + width / 2, t, label, ha=\"center\", va=\"bottom\")\n", "\n", "plt.show()" - ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA/MAAAH/CAYAAAAboY3xAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAZExJREFUeJzt3Qm4jPX///H3sVaWVGQrEpUWZE8RZYk2hbTbSoXKUokoW5EWKu2UpZUoKWUriSxF9khEZF+yZKf5X6/P73vPf2bOnGPOcY459/F8XNfnOmfuueee+77nnuX9Wd6fBDMLGAAAAAAA8I0s8d4BAAAAAACQMgTzAAAAAAD4DME8AAAAAAA+QzAPAAAAAIDPEMwDAAAAAOAzBPMAAAAAAPgMwTwAAAAAAD5DMA8AAAAAgM8QzAMAAAAA4DME8wCQgfTo0cMCgUCqHjt06FBbvXp18Hbx4sXdth577DHzq+bNm7tj0LFkhHMMpNd1ifjyPhfOOussywgiP88BIBqCeQAn3Q9wlauuuirqOmvXrnX3f/XVVyd8//yua9eu1rBhw2OuN3Xq1ODrkFzRj2uEV8yoNGrUKMMFIgo6Ql+7zZs3248//mi33HKL+UXkdXnw4EH7888/7Z133rFzzjnHMpKEhAT3efbll1+6z6x///3XFi9ebN26dbOcOXOm+DU7evSo/fPPP7Zo0SJ3vFWqVIn6mFy5clnPnj3dc+k5t23bZvPnz7dXXnnFChcunCbXY+TngJ5n6dKl7thOPfXUFG8PADKzbPHeAQA40fbv32933XWX/fTTT2HLa9asaeeee64dOHAgbvvmZ0899ZSNHj3aBRjJee6552zIkCHB25UrV7b27du75cuWLQsuV2ChH/GffvqpC6zSyrPPPmvPP/+8+dUzzzxjn3/+uWU0Cupefvll93+RIkXswQcftC+++MIeeughFyD6wbp161yllOTIkcMuueQSt//XXXedXXzxxe6zIyM47bTTbNiwYTZr1ix7++23bcuWLVatWjXr1auX1a5d26699toUv2Z58uRxx3jbbbfZAw88YAMGDAjr1ZMtWzZXQVO6dGkbPny4DRo0yHLnzm2XXnqp+zzVa71x48Y0Ob5JkybZiBEj3P96jho1arj3bbly5axp06Zp8hwAkBkQzAM46XzzzTfuB+ujjz7qWqQ8+kE6d+5cy58/f1z3L6NQwLBv37403+6UKVPCbqvyRMH85MmTbdq0aYnWT8tAXvSah77ufqLgq3z58nbrrbe64CkjWb9+vX300UfB2wrGVq5caR07dvRNML9r166wY/BasN944w3Xmyfy2o2XQ4cO2ZVXXumCeY8qyNasWWO9e/d2Af13332X4tdMnnzySfv444+tU6dO9scff7jKAlEviwoVKrjPyU8++STsMeoNoMqPtLJixYqw/dL1o+2rV4qeK60/EwDAr+hmD+Ckox+i6v5Zt27d4LLs2bNbkyZN3I/YaNRCpZZ8dStVgKugv3HjxonWU7dQtVipu7m6oipQXbJkiWvZi6Tg4Oeff3atfQp61BqWlLvvvts9p557+/bt7hhS0vW3Q4cO7oe+Hv/DDz+41rTI8Zl79uyx888/38aPH2+7d+8O/phWUP/SSy+57rw6nuXLlycah6/jVgtaixYtgt1jtc30GJus4ErDINST4pdffnHHpFZ83RYFurqt86pzdvnllx9zzHxKXjfveUNft2jbrFOnjk2fPt11X9a51XlT74NQ6gly0UUXxXw+1Evh999/d63zx6LzFO01UHdyldDj0b6rgkvb/fvvv93r/9lnn1nevHldEDVw4EDXdV7H8f7778cUuGl99bQoUaKEu62W5K1bt7oW3kgTJ0505ycpem303NG6Wes9qxbhLFn+7ydNxYoVbcKECe65dG2oq/x7771nqbVp0yb398iRI8dct02bNu660fWjQPn111+3008/PWydUqVKuR4s2mddQ+oNoPezznXke37OnDm2d+9e27Fjh6vo8j6zDh8+HBbIe7wKHrWwp5b2/d5773WfM+ra7ilZsqT7G9mjSRRc6/VJT3oddJ2Gvg7Vq1e3UaNG2V9//eX2W59R6lFwyimnJHq83mcjR450vRh0Xeh6U2t/cooVK+YqNPSZcPbZZ7tlej31fvA+D3V/586d3dCHaPlKWrdu7T4ntK4+7ytVqpToebzPHV0P+pvU8JTbb7/dfabp/amKJ33OqVIawMmLlnkAJx0FtfohfOedd7of/dKgQQP3I03BUrQfR2o5HjdunAtwFcjccccd7gf5DTfc4Fr6Q+kHplqQ3nzzTfcDV9sbM2aM+2GoH+Vy2WWXua6kCjg0BlUBjrrIKgCK1n29T58+7kerWt8KFChgjzzyiOvyqlZa/ahLTrNmzVwXWrUu6keujuX777+3MmXKuB+2Hu2DgqoZM2bY448/HmyV13Ffc801LiBasGCBC3AV3BctWtS13sk999zj9k0/Vt999123bNWqVZZeFBApiFOL3Ycffuj2VwG+ukT37dvXnXtRl2mdN/2QP1bSu1heN1UM6JpRIKYAPmvWrC4A1usYSt2zv/76a/djW/cr2NE+R+ZqUOt1rVq1wgKB5KhHgQKQDz74IM1b53WuFExoCIL2VdeYgsb//vvPzjjjDHedXnHFFdayZUtXUaBrMjm6nlRZoaBQtM+qnNH1owojT8GCBV23cF3/SVEQ9vDDD7v3m953HgX3N910k6so0H7qveG9r3QcO3futPPOOy9qnoFo9Hp647xVwaegWPulgC1aEBtK14POkXqYvPXWW+6aU3CvYSR63RWEapt6j6l1WRUUClD1PrrxxhstX758LkgTXTN6Xj2n/ldLfNWqVd150vaTUqhQIfdXlY7HQxUIurbuv/9+dy3/9ttvLmD2Pk+OFQQfL31Oea+Dxunr/Ona0Xs+tFeNKqBU2ajzretMY/113aqiM7Q7vj7rVLGm61mfT/oOUOWErp3u3btH3QdVbOpzUu99VaJo+7reVKmi10yfPQro1UOiX79+LmeAeqGEUi8GffZqXX3+KOjXEBlt26uU0Lb1OaNzrPegjluVcKpUi6wc1PeTeoeo94To+tS5ee2119Lw7APwG/26oVAolExfmjdvHpCKFSsG2rZtG9i1a1fglFNOcfeNHDky8N1337n/V69eHfjqq6/CHuut55Vs2bIFFi1aFJgyZUrYcjlw4EDg/PPPDy4rU6aMW96uXbvgss8//zywb9++wLnnnhtcVrp06cDhw4fdut6yYsWKuWVdu3YNe55LL700cOjQobDlQ4cOdfvu3S5evLjb1t69ewNFihQJLq9cubJb/vLLL4c9Vvr27Rv2PDfffLNb/tRTT4UtHzVqVODo0aNhx7lnzx63nZS+Lo0bN3bPUbNmzSRfMx2Lt0zHKFdccUVwWd26dYPHGnpOW7dunWjbPXr0CDvHKXndvvzyy8C///4bKFy4cHBZyZIl3WsRus327du722eddVayxz516tRE+xKteK/lY489FsiSJUvg999/D8yfPz/RMYU+n85TtNdDz6ni3da5EV3Puq695R999JF7jcePHx/2+J9++insOvOe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2HvvvZfkOvv27bNNmzZZvnz57LrrrrMvv/wy6pj7uXPn2qJFixLdp673CuIfeOABu/32261JkyZ2xhlnpPmxAAAAAH6jXq0a0hpa1BNW3ewHDhwYXE8Bfvfu3W348OG2a9cue/fdd11AP2jQINeTVrmt1qxZY126dAmuL+p9qxZ673ak4sWLu/uV4+qHH35w27n77rutR48ermEuckas0O2oZ8EXX3xhjz32mNuHbdu22euvv35CKgMyfnUDECO1npcqVSp4W63h5cqVsx07dti6deusb9++VrRo0bDkdF4QPnv2bFu6dGmibdarV891s1cXeW37xRdftOXLl7s3bag8efLYbbfd5t7EkTTGZ+PGje6DQB9KWk+31a0fAAAAQOwef/xx6927t/Xq1euYyaYrV65sW7dutRYtWtiECRPs6NGjyW77+eefd7/n9btdSbAffPDBmPbpmmuucb/v9Vcxw8iRI91Y/8geu2mNYB6ZRqVKlVxNmserxdOYdc39riR3SkYXKm/evNa4cWNXwxbN6aefbv369XNz0KtSQHPHd+vWzY4cORK23h133OGCfo2nj9Yq37lzZ9f6rw+QX375xa6//npX+wcAAACc7G688Ub3mzm0272C82i+//57GzBgQEzJptVKLmpEU2v/sbzyyiuulT2lNBzg4Ycfdg13agQcP3681a5dm2AeiNW0adNcQJ0UBfSRdu/e7Vr0k6Kx7bGMbx88eLAr0ehNnN5vZAAAAMCv1J2+TZs2wdt79+5Ncl0Naw01bNgwmzx5sgui1fr+9ddfu9upEbntWKmHb2iCbLXSlylTxtIbwTwAAAAAIG4UvK9atSrmdaMlm27QoIHVqVPH5amaMmWKG9qamv0IpQA9srEwWtK9yCTa6oGbJUv6p6cjmPe5Ez1vJJDR5yMFAADAyWXP/5JNq4wePdomTpzokk2r+/uhQ4csa9asqdquxtsrs34oTTmdURDMAwAAAAB8qeMxkk0ru73Gr//0008ua35KklArH1eBAgVc/itVEtSvX9/1ANBQ3YyAYB4AAAAAMqnM3gNyzzGSTSs7vRLmtW7d2tavX++65MdKs1i1bdvWnnrqKXv66addMuyXXnrJTTedEWgAACm1I2iaMdW2KNN5aFbFjIhu9sgMMvuXDAAAQHrSPOl9+vRxAedff/0V793Bcb5mscaj6T8q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AJzoYD4hIcFKlSplZ599tmXJEj70fvr06andLAAAAAAASI9gXuPjP/74YytevLgL6kMFAgHLli3VdQQAAAAAAOAYUhV1v/322zZ37ly74YYbbOPGjS6ABwAAAAAAGTiYv+CCC6xJkya2atWqtN8jAAAAAACQ9vPMazo6jZcHAAAAAAA+aZkfNGiQvfzyy1aoUCFbvHixHT58OOx+LQMAAAAAABkomB8zZoz7+/777weXady8kuGRAA8AAAAAgPSVqqi7RIkSab8nAAAAAAAg/YL5tWvXpuZhAAAAAAAgDaS6P/z5559vHTp0sIsvvtjd/u233+zVV1+1P//8My32CwAAAAAApGU2+3r16rngvUqVKrZo0SJXqlatakuXLrU6deqkZpMAAAAAACA9W+aff/55GzhwoHXt2jVseb9+/ax///5WsWLF1GwWAAAAAACkV8u8uta/9957iZYru/0ll1wS83Zq1Khh48aNs/Xr17ss+A0bNjzmY2rWrGnz5s2zAwcO2B9//GHNmzdPtE7btm1t9erVtn//fps9e7ZVrlw55n0CAAAAACBTBvNbt261yy+/PNFyLduyZUvM28mVK5ctXLjQ2rVrF9P65513no0fP96mTp3qnuuVV16xIUOGuG7/nqZNm9qAAQOsV69eVqFCBbf9iRMnWoECBWLeLwAAAAAAMl03+8GDB9u7777rkuDNnDnTLbvqqqvsySefdIF0rCZMmOBKrB566CHX4v7444+728uXL7fq1atbx44dbdKkSW5Zp06d3P4NGzYs+JgbbrjBWrVq5YYAAAAAAABwUgbzffr0sT179thjjz3mxsnLhg0brGfPnvbaa69ZeqlWrZpNmTIlbJla3dVCL9mzZ3fj9b19EnXf12P02KTkyJHDcubMGbydJ0+edNl/AAAAAADi1s1eFECfe+65dvrpp7ui/9MzkJdChQrZ5s2bw5bptp7/lFNOsfz581u2bNmirqPHJkWJ/Hbv3h0sGsMPAAAAAECmCuY1dr1UqVLu/3///dcV0bLixYub36glP2/evMFStGjReO8SAAAAAABpG8xrPPqVV16ZaLnmmvfGqqeHTZs2WcGCBcOW6fauXbtcdvtt27bZkSNHoq6jxybl0KFDbthAaAEAAAAAIFMF8+XLl7effvop0XJNAxcty31amTVrltWuXTtsWd26dd1yOXz4sJu2LnSdhIQEd9tbBwAAAACAkzKYV1K5aEniNHY9a9asKZqarly5cq5IiRIl3P8afy99+/a14cOHB9d/++23XQZ9ZaW/6KKLrE2bNm4quoEDBwbXUTb91q1bW7Nmzax06dL21ltvuecZOnRoag4VAAAAAIDMkc3+xx9/dEnj7rzzTvvvv//csixZsrhlM2bMiHk7lSpVsh9++CF42wvK1VW/ZcuWVrhwYStWrFjw/jVr1rhp5rRe+/bt7e+//7b7778/OC2djBo1ys0p37t3b5f0bsGCBVa/fn3bsmVLag4VAAAAAIAMJ0EN7Sl90MUXX+wC+p07d9r06dPdsho1arjkcddee60tXbrU/Ey9DpTVXseT0cfP39jl03jvAnDcRubpEO9dAI5Lrm5J52UBAABIj3g0Vd3sly1bZmXLlnWt4GeffbZ7shEjRrhu7X4P5AEAAAAAyJTd7GXjxo3WrVu3tN0bAAAAAABwTKlqmZfq1avbBx984LLaFylSxC2755577KqrrkrtJgEAAAAAQHoF840aNbKJEyfa/v37rUKFCpYzZ85gNvunnnoqNZsEAAAAAADpGcx3797dHnroIXvggQfc3O4etdIruAcAAAAAABksmNcc78pmH2nXrl2WL1++tNgvAAAAAACQlsH8pk2brFSpUlHH0f/555+p2SQAAAAAAEjPYH7w4MH26quvWpUqVSwQCLgEeHfddZe99NJL9tZbb6VmkwAAAAAAID2npnv++ectS5Ys9t1339lpp53mutwfPHjQBfOvv/56ajYJAAAAAADSe575vn372osvvui62+fOndt+++0327t3b2o3BwAAAAAA0nueeVEm+2XLltny5cutTp06Vrp06ePZHAAAAAAASK9gfuTIkdauXTv3/ymnnGK//PKLjRo1yhYtWuTmoAcAAAAAABksmL/66qtt+vTp7v9bb73VjZ/XlHSPPvqom4MeAAAAAABksGD+9NNPtx07drj/69evb2PGjLH9+/fb+PHj7YILLkjrfQQAAAAAAMcbzK9bt86qVavmMtkrmJ80aZJbfsYZZ9iBAwdSs0kAAAAAAJCe2exfeeUV++ijj+zff/+1v/76y3744Ydg9/vFixenZpMAAAAAACA9g/m33nrL5syZY8WKFbPJkydbIBBwy//880/GzAMAAAAAkFHnmf/1119dCfXNN9+kxT4BAAAAAIC0GDP/5JNPumnoYlGlShW7/vrrY900AAAAAABIj2D+kksusbVr19obb7zhkt7lz58/eF/WrFmtTJky1qZNG/vpp5/cPPR79uxJyX4AAAAAAIC07mbfvHlzK1u2rD388MP28ccfW968ee3o0aN28OBBl9Ve5s+fb0OGDLFhw4a55QAAAAAAIM5j5hctWmQPPPCAPfjggy6wL168uJ166qm2bds2W7BggW3fvj0ddhEAAAAAABx3Ajxlr1+4cKErAAAAAAAgg46ZBwAAAAAAGQPBPAAAAAAAPkMwDwAAAACAzxDMAwAAAABwMgXzJUuWtHr16tkpp5ySdnsEAAAAAADSPpg/88wzbfLkybZixQr75ptvrHDhwm75e++9Zy+99FJqNgkAAAAAANIzmB84cKAdOXLEihUrZvv27QsuHzlypNWvXz81mwQAAAAAAOk5z7y61l933XW2fv36sOV//PGHFS9ePDWbBAAAAAAA6dkynytXrrAW+dDu9wcPHkzNJgEAAAAAQHoG89OnT7dmzZoFbwcCAUtISLDOnTvb1KlTU7NJAAAAAACQnt3sFbR/9913VqlSJcuRI4e98MILdumll7qW+auuuio1mwQAAAAAAOnZMr906VK78MILbcaMGfbll1+6bveff/65lS9f3v7888/UbBIAAAAAAKRny7zs3r3b+vbtm9qHAwAAAACAEx3M58yZ08qWLWtnn322ZckS3sD/1VdfpXazAAAAAAAgPYJ5TUs3YsQIy58/f6L7lAwvW7ZU1xEAAAAAAID0GDM/aNAg++yzz6xw4cKWNWvWsEIgDwAAAABABgzmCxYsaAMGDLAtW7ak/R4BAAAAAIC0D+ZHjx5ttWrVSs1DAQAAAADAcUpVn/iHH37YdbOvUaOGLV682A4fPpyoGz4AAAAAAMhAwfydd95p9erVswMHDrgWeiW98+h/gnkAAAAAADJYMP/cc89Zjx497Pnnnw8L5AEAAAAAQAYdM58jRw4bOXIkgTwAAAAAAH4J5ocPH26333572u8NAAAAAABIn272mk++c+fOdt1119miRYsSJcB77LHHUrNZAAAAAACQXsF8mTJlbP78+e7/yy67LOw+ut4DAAAAAJABu9lfe+21SZbatWuneHtt27a11atX2/79+2327NlWuXLlJNedOnWqqzCILF9//XVwnaFD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BPMAAAAnqaZNm9qAAQOsV69eVqFCBVu4cKFNnDjRChQokORjqlWrZp988om99957Vr58eRe0qlx66aVh6yl4L1SoULCEto7PnDkz7D6VwYMH259//umC6LTy6KOPBisWPPPmzbOWLVvaxRdfbNddd50lJCTYpEmTLEuW8J/F77//vo0cOTLm54p1u8n56KOPrHr16nbJJZfE/BgAJ68EM/v/n25w8uTJY7t377a8efPanj17LCO7scv/r2kG/Gpkng7x3gXguOTqtineuwCkilrif/nlF3vkkUfcbQWg69ats0GDBiXZQqxW7ly5crlWb8+sWbNswYIF1qZNG3dbAXS+fPns1ltvjWk/smXLZuvXr3fP++yzz7plxYoVs9dff90Ftzly5LA1a9bYE088EdbCn5z8+fPb5s2brUyZMvbbb78luZ7uV++CkiVLusqEUD169LBbbrnFVVqkVOR2n376aXvooYfc8h07drh1vv76azvttNOsdu3awQqH7777zn766Sd75plnUvycAE6ueJSWeQAAgJOQxmhrfPaUKVOCyxRQ6rZa35Oi+0IfI2rNj3xMrVq1XDC9fPlye/PNN+3MM89Mcps333yznXXWWa4SwPPGG29Yzpw57eqrr3YB8JNPPmn//vtvzMenSgB1eddwgKQokFZruoJtVWKklWjbfe6551yFxJAhQ4LDG6688kpr3rx5WM+Bn3/+2WrUqJFm+wIg8yKYBwAAOAmp5Vot4gq4Q+m2ur0nRfcd6zETJkywZs2auRZnBeEaI68W9aS6nN93332uQkCt8x61zKuFesmSJW5M//jx42369OkxH1/x4sXdfoUGyh71IFBr1969e914/7p167ox68crue3+999/ds8997hz0q9fP3vxxRetXbt2iSoRNmzY4PYd8FsSR6+XTqNGjeK9GycNgnkAAACkKY01/+qrr1wg/uWXX9qNN95oVapUca31kYoWLerGmGsMfqjXXnvNunfvbjNmzLCePXu61vmUOPXUU10Cu6TGpqvrvFr9V6xYYaNGjXK9AI7XsbarSonHH3/cunTpYuPGjXO5ByIpqaBa9hHfJI4aYqH71RtEwyImT57sruFoNAxEiRJVcVSuXDlLS9GSOIb65ptv3PM2bNgwbLkSSaoHzT///OP2XxVsZcuWTfa5WrdubVOnTrVdu3a5bZ5++ukp3l8Nk3n++efdkB2kP4J5AACAk9C2bdvsyJEjLlAIpdubNiWdB0L3pfQxCrS2bt1qpUqVSnSfuqNv377dBbehFNyff/759sEHH7hAXonxHn744RQd3xlnnBH1Po1FVeZ5tfQrqCtdunTM4/uTE8t2FejrvJ933nmWNWvWRNvQcASdK8Q3iaMqY3S96drTkA0NkVBCQ/VoifTCCy+4HhXpIVoSR0+HDh2iLldOCwXva9eutapVq7r9V48RnRf1xkmKKpH0uL59+6Z6f9UDR+O9IytHkD4I5gEAAE5C6v6tDOzq9u1Ra5puq6tsUnRf6GNE3cmTe4xa3zUmfuPGjVGD+REjRrgAN9Lff/9t77zzjjVu3Nhefvll13IYK7WUquu/EvElR8eskhYt88fargJPdUFWDwUNI1BSvEiXXXZZzNPhwaxTp05uJoRhw4a5lnQlGVSuhFatWiX5mPbt27ugVVMBKqeDkg3++uuvYZVFCvaVjFAVUUqgqOdRS3Vk63b9+vWtXr16rsdFJL3GqqRSy7ha+NVTJSVBrioOrr32WtfLJZJ6ADz22GNRj1OVSHq/6bhUKaH9V2WH3g/JDeHQlIxKfKneDdHce++9rlIgtFJOuS103tUTxhtOot4Cd9xxR8zHidQjmAcAADhJqUVTAbLGtysAeOutt1yrXmgiuuHDh4e11OkHvwIYBTeaR13dkdWlV5nnRY9XS6VaBBU4KBhRV3u1WKtlMJTuU+u7lxQu1MCBA12QpBZstZ5ec801ySazi6SAWK3zV111VXBZiRIlXBd3teCee+65roX2s88+c12tFYB4lIFewZKCHwUp+l9FSQOlSJEibl+87tyxbFcVGjq/yiGgXACqxHjqqafceQql5HdqAUb8kziGPs8DDzxgO3fudC3/nrPPPttVJCjIVQVCpPRK4qhr8uOPP3Y5FyLzV8jvv//urn3lotC+n3LKKe5/BfXqYZBa6iWj61nDSdSr5Prrr7f777/f7r77bnete0jieOIk3c8CAAAAmZrGdKs7cu/evV3gqunlFKhv2bIlrHVRrW0etcDfddddbmysgvw//vjDTd+2dOlSd//Ro0dd66WytKtVXN2PFZyqFfrQoUNhz68AQ4Gtgo9IChYUDJ1zzjmu+7paUjt27BjzsWmfVSmhQEPJ80Rj6BVkqHuyuuArEPrxxx9dVvnQru2qXAgd36/zIqpY+Ouvv1yApMoPb2x7LNtVy7GCHK/SQ+dEwf2HH35ol19+uUuad8UVV7jW39GjR8d8nCez5JI46vU5niSOcsMNN7ix6nqd1atEPVA0JMSj1/Ttt992PVyitXjrvTNmzBjXIi9q5U+JpJI4qqJr5syZiYameFRhoOtXQwe83h96nyo3hd6fx+PBBx90OQaU00K9TJTPQr0aQuk9r0ot9UyJNgwAaYdgHgAA4CSmgFklKWoRj6RgM6mAU4GtKgRioUA7ubHCx0tBjyoZFFRp/LACMgVoxxLtmEMpoA9N8BXLdhUIRuvureJRZYCy3CeVuA8nlpLBqaJFlQbqwaLKL/WkUAXNI4884saGa2aCpCjgVYWNepioJ4AC+8WLFx9XEkclxFOPFvVWSYpa4pUPQBVlyoKvijENA1CllnqTHM/1pd4JqoRTZZS2r2R3kdRKr+dUrwSu5fRFN3sAAABkSmrVVOChYD6jU2u/Aj1VQCBjJHFUF/dVq1bZnDlzXHdyPZeuJ1FArW75Bw8edPknNIxElKhRLfbplcRRz6thIAqq9bze1IeqKFDlg6jnjHqRaCiHnlP7r2UaDhKZ9T41vCSOhQsXdsNqoiVxVO8AAvn0R8s8AADACXBjl+hTSyF9qVOxUuDdWD32ICpeFh41q9Ph/wLBjOjr5+/IsEkclZchNImjN5whuSSOyv8QaxJHyZIlSzChoXqOaOpEj/IoqLX69ttvd8FzZBJHFQ1LUQt/cvuWVBJHBe+ilvDIHBPqxq8hKF6iPA0L0DCT0C7u3m0dw/FQBYbG/quHgJLl6VhatGgRtg5JHE8cgnkAAAAAvk3iqCSNaoFWTgINVYiWxHH9+vUu4aAoiJ82bZpL4qiu58q8riSOSnLnBcPdunVzY9I1hELd7JVsTkkMldhQ1q1bF7YfXmI7teTruUS9LDRVmzLKq4X9eJI4enkf1NskWtI7DSPxkttNnjzZDdfQ8JlBgwa5AF4JGtWa7rXeq/JB2fqV/PKXX34J9k5Q5YGXrV69CZS9XtvWfPW5c+d2vQw0fEA5LFRRoceqEkE9AzwkcTxx6GYPAAAAwJc0jl3jwZXEUYkKNcY9WhJHdQmPTOKo4F3Z6Zs0aZIoiaMS6ClAVSCuYFVTvSlIVUb4WHlJHBXAK/jVttq2bZuqJI4poYSSajlXIkod6/Tp013wrvPiDSWITOIomtZP59Br+dfjdPvmm28OVoIoUaNXKaIeAfpfvQ60fdFfJX4MrUxB+lHmDlIMRlAyC2VNzZs3r6uNysjosofMYGSeDvHeBeC45OqW9NhMwMN3Nvwuo3WzPxmotVyVDJr2UC3kGZ2GAagXgrLeI/3jUVrmAQAAACAD8lMSR1GPCG86PKQ/xswDAAAAOKa9z4XPw44T5X8J9Rr44fx/bH3aq704Y+5rrkzWk46WeQAAAAAAfIZgHgAAAAAAnyGYBwAAAADAZzJEMK8pGlavXm379++32bNnW+XKlZNdX9NHaIoHrb9o0SJr0KBBonV69eplGzZssH379rm5Fr35EgEAAAAA8Lu4B/NNmza1AQMGuOBbUy5orseJEydagQIFoq5frVo1++STT+y9996z8uXL29ixY1259NJLg+t07tzZHn30UTdXYtWqVd18iNpmzpw5T+CRAQAAAACQSYP5Tp062eDBg23YsGGutV0BuFrTW7VqFXX99u3b24QJE+yll16y5cuX2zPPPGO//vqrPfzww8F1OnToYM8++6yNGzfOFi9ebM2aNbMiRYrYLbfccgKPDAAAAACATDg1Xfbs2a1ixYrWr1+/4LJAIGBTpkxxLfDRaLla8kOp1d0L1EuUKGGFCxd22/Ds3r3b5syZ4x47cuTIRNvMkSNHWKt9njx5wv5mZKfmZHZBZAI5csd7D4Dj4ofvC8Qf39nwPb6v4XN5fPJ9Het+xvVbJX/+/JYtWzbbvHlz2HLdLl26dNTHFCpUKOr6Wu7d7y1Lap1IXbt2tZ49eyZavn79+hQeEYDUaRzvHQCOy+7H470HAHAi8H0Nf9vts+9rBfV79uxJ8n6qiM1cz4DI1v4zzzzTduzYEbd9Ak4W+pBSxVnRokWT/bACAADxw/c1cOLfc0ronpy4BvPbtm2zI0eOWMGCBcOW6/amTZuiPkbLk1vf+xu5Dd1esGBB1G0eOnTIlVB8SAEnlt5zvO8AAMjY+L4GToxY3mdxTYB3+PBhmzdvntWuXTu4LCEhwd2eNWtW1Mdoeej6Urdu3eD6muJu48aNYeuoVkNZ7ZPaJgAAAAAAfhOIZ2natGlg//79gWbNmgVKly4dePvttwM7duwInH322e7+4cOHB/r27Rtcv1q1aoFDhw4FOnXqFLjooosCPXr0CBw8eDBw6aWXBtfp3Lmz28ZNN90UuOyyywJffPFFYNWqVYGcOXPG9VgpFErikidPnoDob7z3hUKhUCgUSvTC9zWFYhmxxH0HAu3atQusWbMmcODAgcDs2bMDVapUCd43derUwNChQ8PWb9KkSWD58uVu/cWLFwcaNGiQaJu9evUKbNy40VUUTJ48OXDBBRfE/TgpFErikiNHDlcpp7/x3hcKhUKhUCjRC9/XFIpluJLwv38AAAAAAIBPxHXMPAAAAAAASDmCeQAAAAAAfIZgHgAAAAAAnyGYBwAAAADAZwjmAQAAgAyuePHiFggErFy5cvHeFQAZBME8gGMaOnSo+wHx5JNPhi1v2LChW57e9Bxe2blzp82YMcOuueaadH9eAABO9HetV7Zt22bffvutlSlT5oTvyxlnnGGvvfaaLV++3Pbt22d//fWXvfrqq5Y3b95Ub7NHjx7BYzt8+LBt3brVpk2bZu3bt7ccOXKErXveeefZRx99ZOvXr7f9+/fbunXrbOzYsXbRRRcF19F29DsEOJkRzAOIib5MFczny5cvLs/fokULK1SokF111VXuB87XX39tJUqUiMu+AACQHhS867tOpXbt2nbkyBH3fXeiFSlSxJXHH3/cLrvsMvcdXL9+fXvvvfeSfEzNmjVt9erVyW53yZIl7tiKFSvmKuU/++wz69q1q82cOdNy587t1smWLZtNnjzZTj/9dGvUqJEL4G+//XZbvHhx3H6DABlZ3Ce7p1AoGbsMHTo0MG7cuMBvv/0W6N+/f3B5w4YNA6L/e/ToEZg/f37Y49q3bx9YvXp12Ha++OKLQNeuXQObNm0K/PPPP4Gnn346kDVr1sALL7wQ2L59e2DdunWBFi1ahG1H9Fze7cKFC7tlDzzwQODee+8NbNu2LZAjR46wx+h5RowYEfdzR6FQKBRKLMX7jgxddtVVV7nvu/z58weKFy/u/i9Xrlzw/quvvjowZ86cwIEDBwIbNmwI9OvXz32nevc3btw4sGjRosC+ffvcd+XkyZMDp512WvD+li1bBpYsWRJ8/KBBg5LcvyZNmrj1QrcfWmrWrBn2nR9Zov1OULnooovcdvv06eNu6/ikWLFiyZ6vyN8GFIqdhIWWeQAxOXr0qD311FP2yCOPWNGiRVO9nWuvvdbV9l999dXWqVMn6927t2t1+Oeff6xq1ar29ttv2zvvvJPsc6iXgKhbnmr1s2bNajfffHPw/gIFCtgNN9xg77//fqr3EwCAeMqVK5fdc8899scff9j27dsT3a/v0m+++cZ++eUXN46+TZs2dt9991n37t3d/WoB/+STT9x34cUXX2y1atWyzz//3BISEtz9Dz30kL3xxhv27rvvuq78+h5duXJlkvujlvLdu3e73wNp6ffff3c9EtQKL+p+r+do0qSJZclCqAIcS9xrFCgUin9aC2bOnBkYMmRIqlvmdTshISG4bNmyZYFp06YFb2fJkiWwZ8+ewO233x619v3UU08NvP7664HDhw8HypQp45a98cYbgfHjxwfX79ixY2DlypVxP28UCoVCocRa9B2p7zZ9B6rI+vXrA+XLl3f3R7bMP/vss+47NHQbbdq0Cezevdt9z+pxybVw//3338HW8GOVs846K7BmzRr3nEmtk9qWeRX1KNi7d2/wdtu2bQP//vtvYNeuXYHvvvsu0L1790CJEiXCHkPLPMUotMwDSBmNm2/evLmVLl06VY9funRpWNK8zZs3u3Fwnv/++8+1QJx99tlhj1Prwp49e1xp3Lixa33wHjd48GCrV6+ea6UQje0bNmxYKo8QAID4mDp1ql1++eWuVK5c2SZOnOharTXGPJJa22fNmhW27KeffrI8efLYOeecYwsXLrQpU6a478pRo0bZ/fffHxxzrh5s6gH33XffHXOftL3x48fbb7/9Zj179gy7z/teVvH2M3TZW2+9FdNxq7dA6G+DN9980/UsuPvuu90x3nbbbe73Q506dWLaHnCyIJgHkCLTp093Py769esXtlxBuNd1z5M9e/ZEj1cG21BeVtvIZZFd6zp27Oh+3OjLvXDhwjZixIjgfQsWLHA/Wpo1a2YVKlSwSy+9lGAeAOA7e/futVWrVrkyd+5cF4Cru33r1q1TvC19L9etW9caNGjgAnENk1OXdmWK94arHYuS0k2YMMEF5rfeeqtLyBfKq3hQ0b5u2LAhbNkzzzwT0/OoYiIyed6///7rhuFp2ICGEej3hzeEAMD/IZgHkGJdunSxm266yapVqxZcpjFuCrRD6Ys8rWzatMn9uFEm+2iGDBniWuRbtmzpWiL+/vvvNHtuAADiQZXbCspPPfXURPctW7Ys7HtYNOOLxrWHfgcqU7xa1MuXL2+HDh1yQbkCZQXPypifXIv8pEmT3GM0nv7gwYOJ1vEqHlQ0jZyC/dBl+m1wLMpWr0z5Y8aMSXY9TZOnig0A/1+2kP8BICaaWkbzvz766KPBZT/88IPrtte5c2cbPXq0+2JWa4B+VJwIH3/8sb300kuu9UIt9AAA+E3OnDmtYMGCwbneH374Ydc6/tVXXyVaV13RO3ToYIMGDbLXX3/dBcW9evWyAQMGuEqAKlWquGBdAfmWLVtckll9T6sSQBTgK+ms7lMXeQXvqgzQtrxA/rTTTnNJ+DS/vDfHvAJ0VTCkhqad0/Gp991ZZ53lkvKptV097F588UW3jlrhdRwffPCB61GgygRNe9eqVSvr379/2PY0Ra3WD6WEgfv27UvV/gF+FPeB+xQKxX/T5SgRj6aS8RLgqTz44IOBv/76yyXuGTZsmJuCLtrUdKHbmTp1amDgwIFhy/QYJc9LaZKb4cOHR52mjkKhUCiUjF70HRlKyd807VyjRo3c/Smdmq506dKBb7/9NrB58+bA/v37A8uXLw+0a9cu7Dk1xauS6B08eNAl23v11VeDyeySov1IbQI8jxL96fv6xx9/dN/3od/bSrb3yiuvuCn1lMxP52HhwoWBTp06hSXQTYqm84v3a0mh2AkqCf/7BwB8T93rlSCnffv28d4VAAAAIF0RzAPwPWXnVVc9de+/5JJLbMWKFfHeJQAAACBdMWYegO/Nnz/fjS3UtHkE8gAAADgZ0DIPAAAAAIDPMDUdAAAAAAA+QzAPAAAAAIDPEMwDAAAAAOAzBPMAAAAAAPgMwTwAAAAAAD5DMA8AAAAAgM8QzAMAAAAA4DME8wAAAAAAmL/8P8YQqbkdUDTRAAAAAElFTkSuQmCC" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 7 + ] }, { "cell_type": "code", + "execution_count": 7, "id": "a1e8dbea24ecc319", "metadata": { - "trusted": true, "ExecuteTime": { - "end_time": "2026-03-04T09:18:07.558194Z", - "start_time": "2026-03-04T09:18:07.539078Z" - } + "end_time": "2026-03-09T11:41:38.489362Z", + "start_time": "2026-03-09T11:41:38.460526Z" + }, + "trusted": true }, - "source": [], "outputs": [], - "execution_count": 7 + "source": [] } ], "metadata": { From eee6997684c5381d5f685ec23fa8ee7eb55a9193 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 9 Mar 2026 12:48:31 +0100 Subject: [PATCH 06/69] Fixing a mistake and reverting notebook to a previous commit --- .../tutorials/03.lazyarray-udf-kernels.ipynb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb index 9b7c3645..8dc90eca 100644 --- a/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb +++ b/doc/getting_started/tutorials/03.lazyarray-udf-kernels.ipynb @@ -27,11 +27,11 @@ "source": [ "### Choosing the Right Interface\n", "\n", - "| Goal | Recommended API |\n", - "|--------------------------------------------------------------| --- |\n", - "| Elementwise formulas using built-in functions/operators | `blosc2.lazyexpr(...)` |\n", - "| Arbitrary Python logic (including numba) over blocks/chunks | `blosc2.lazyudf(...)` |\n", - "| DSL subset with early syntax checks and optional miniexpr JIT | `@blosc2.dsl_kernel` + `blosc2.lazyudf(...)` |\n" + "| Goal | Recommended API |\n", + "|--------------------------------------------------------------|------------------------------------------|\n", + "| Elementwise formulas using built-in functions/operators | blosc2.lazyexpr(...) |\n", + "| Arbitrary Python logic (including numba) over blocks/chunks | blosc2.lazyudf(...) |\n", + "| DSL subset with early syntax checks and optional miniexpr JIT | @blosc2.dsl_kernel + blosc2.lazyudf(...) |\n" ] }, { From 68998af25605c5e4167a625c6bdc3d4afa469092 Mon Sep 17 00:00:00 2001 From: "Eugeniy E. Mikhailov" Date: Fri, 13 Mar 2026 12:53:31 -0400 Subject: [PATCH 07/69] fixing NDArray save method to use its own compression parameters The 'NDArray.save' method does not path 'cparams' to the parent class 'copy' consequently array is reprocessed with the default 'cparams'. To observe the bug run the script below. ================================================================= import os import blosc2 import numpy as np a = np.arange(10_000_000) a = a * a cparams = blosc2.CParams( codec=blosc2.Codec.ZSTD, clevel=9, filters=[blosc2.Filter.BITSHUFFLE], ) ba = blosc2.asarray(a, cparams=cparams) print(f"Blosc2 memory: size {ba.cbytes}\t cratio: {ba.cratio}") outdir = "cache/" prefix = "save" fname = outdir + prefix + ".b2nd" ba.save(fname, mode="w") fsize = os.path.getsize(fname) print(f"Blosc2 array save:\t saved file size = {fsize}\t cratio: {a.nbytes/fsize}") ================================================================= You should see ~~~~~ Blosc2 memory: size 4370284 cratio: 18.74477722729232 Blosc2 array save: saved file size = 12370369 cratio: 6.467066584675041 ~~~~~ ba.cbytes ---> 4370284 ba.cratio ---> 18.3 I.e. the array in memory has 'cbytes=4370284', however the saved file has 12370369 bytes, which gives 'cratio' of about 6.5. Also if the array is loaded back from the file, it is easy to see that 'cparams' are different from the original array and changed to the default one. After the patch, the memory 'cbytes' are closely matching the saved file size as well as 'cparams' ~~~~~ Blosc2 memory: size 4370284 cratio: 18.74477722729232 Blosc2 array save: saved file size = 4370051 cratio: 18.30642251085856 ~~~~~ --- src/blosc2/ndarray.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blosc2/ndarray.py b/src/blosc2/ndarray.py index c8681f58..bc396622 100644 --- a/src/blosc2/ndarray.py +++ b/src/blosc2/ndarray.py @@ -4703,7 +4703,7 @@ def save(self, urlpath: str, contiguous=True, **kwargs: Any) -> None: # Add the contiguous parameter kwargs["contiguous"] = contiguous - super().copy(self.dtype, **kwargs) + super().copy(self.dtype, cparams=asdict(self.cparams), **kwargs) def resize(self, newshape: tuple | list) -> None: """Change the shape of the array by growing or shrinking one or more dimensions. From 302987e8e1bc47a505a559af639558ab04baef76 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 15:08:19 +0100 Subject: [PATCH 08/69] First implementation of a VLArray store --- CMakeLists.txt | 3 +- src/blosc2/__init__.py | 3 + src/blosc2/blosc2_ext.pyx | 10 ++ src/blosc2/core.py | 10 +- src/blosc2/schunk.py | 27 +++++ src/blosc2/vlarray.py | 224 ++++++++++++++++++++++++++++++++++++ tests/test_schunk_update.py | 35 ++++++ tests/test_vlarray.py | 112 ++++++++++++++++++ 8 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 src/blosc2/vlarray.py create mode 100644 tests/test_vlarray.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d921567..ae63b85d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,8 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 1386ef42f58b61c876edf714a2af84bd7b59dc5d # v2.23.1 + GIT_TAG ba55a6be9293faf9740f03b5953b82f1c955879e # variable-length chunks support in schunks + # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) include_directories("${blosc2_SOURCE_DIR}/include") diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 24449554..e0e8f9ac 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -530,6 +530,7 @@ def _raise(exc): from .embed_store import EmbedStore, estore_from_cframe from .dict_store import DictStore from .tree_store import TreeStore +from .vlarray import VLArray, vlarray_from_cframe from .c2array import c2context, C2Array, URLPath @@ -739,6 +740,7 @@ def _raise(exc): "TreeStore", "Tuner", "URLPath", + "VLArray", # Version "__version__", # Utils @@ -934,6 +936,7 @@ def _raise(exc): "validate_expr", "var", "vecdot", + "vlarray_from_cframe", "where", "zeros", "zeros_like", diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index c56104ab..604be0bd 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -1565,6 +1565,16 @@ cdef class SChunk: raise RuntimeError("Could not delete the desired chunk") return rc + def append_chunk(self, chunk): + cdef const uint8_t[:] typed_view_chunk + mem_view_chunk = memoryview(chunk) + typed_view_chunk = mem_view_chunk.cast('B') + _check_comp_length('chunk', len(typed_view_chunk)) + rc = blosc2_schunk_append_chunk(self.schunk, &typed_view_chunk[0], True) + if rc < 0: + raise RuntimeError("Could not append the desired chunk") + return rc + def insert_chunk(self, nchunk, chunk): cdef const uint8_t[:] typed_view_chunk mem_view_chunk = memoryview(chunk) diff --git a/src/blosc2/core.py b/src/blosc2/core.py index 4ec139c4..5526a7f2 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1918,8 +1918,9 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True -) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk: - """Create a :ref:`EmbedStore `, :ref:`NDArray ` or :ref:`SChunk ` instance +) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.VLArray: + """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk ` + or :ref:`VLArray ` instance from a contiguous frame buffer. Parameters @@ -1936,7 +1937,8 @@ def from_cframe( Returns ------- - out: :ref:`EmbedStore `, :ref:`NDArray ` or :ref:`SChunk ` + out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk ` + or :ref:`VLArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1950,6 +1952,8 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) + if "vlarray" in schunk.meta: + return blosc2.vlarray_from_cframe(cframe, copy=copy) if "b2nd" in schunk.meta: return ndarray_from_cframe(cframe, copy=copy) return schunk_from_cframe(cframe, copy=copy) diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index cd2dbe9d..3e001445 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -801,6 +801,27 @@ def insert_data(self, nchunk: int, data: object, copy: bool) -> int: blosc2_ext.check_access_mode(self.urlpath, self.mode) return super().insert_data(nchunk, data, copy) + def append_chunk(self, chunk: bytes) -> int: + """Append a compressed chunk to the end of the SChunk. + + Parameters + ---------- + chunk: bytes object + The compressed chunk to append. + + Returns + ------- + out: int + The number of chunks in the SChunk. + + Raises + ------ + RuntimeError + If the chunk could not be appended. + """ + blosc2_ext.check_access_mode(self.urlpath, self.mode) + return super().append_chunk(chunk) + def update_chunk(self, nchunk: int, chunk: bytes) -> int: """Update an existing chunk in the SChunk. @@ -1603,6 +1624,11 @@ def _process_opened_object(res): elif not proxy_src["caterva2_env"]: raise RuntimeError("Could not find the source when opening a Proxy") + if "vlarray" in meta: + from blosc2.vlarray import VLArray + + return VLArray(_from_schunk=getattr(res, "schunk", res)) + if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta: return blosc2._open_lazyarray(res) else: @@ -1614,6 +1640,7 @@ def open( ) -> ( blosc2.SChunk | blosc2.NDArray + | blosc2.VLArray | blosc2.C2Array | blosc2.LazyArray | blosc2.Proxy diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py new file mode 100644 index 00000000..4d172137 --- /dev/null +++ b/src/blosc2/vlarray.py @@ -0,0 +1,224 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import copy +import pathlib +from typing import TYPE_CHECKING, Any + +from msgpack import packb, unpackb + +import blosc2 +from blosc2 import blosc2_ext + +if TYPE_CHECKING: + from collections.abc import Iterator + + from blosc2.schunk import SChunk + +_VLARRAY_META = {"version": 1, "serializer": "msgpack"} + + +def _check_serialized_size(buffer: bytes) -> None: + if len(buffer) > blosc2.MAX_BUFFERSIZE: + raise ValueError(f"Serialized objects cannot be larger than {blosc2.MAX_BUFFERSIZE} bytes") + + +class VLArray: + """A variable-length array backed by an :class:`blosc2.SChunk`.""" + + @staticmethod + def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: + if cparams is None: + cparams = blosc2.CParams() + elif isinstance(cparams, blosc2.CParams): + cparams = copy.deepcopy(cparams) + else: + cparams = dict(cparams) + + if isinstance(cparams, blosc2.CParams): + cparams.typesize = 1 + else: + cparams["typesize"] = 1 + return cparams + + @staticmethod + def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any]) -> blosc2.Storage: + if storage is not None: + storage_keys = set(blosc2.Storage.__annotations__) + storage_kwargs = storage_keys.intersection(kwargs) + if storage_kwargs: + unexpected = ", ".join(sorted(storage_kwargs)) + raise AttributeError( + f"Cannot pass both `storage` and other kwargs already included in Storage: {unexpected}" + ) + if isinstance(storage, blosc2.Storage): + return copy.deepcopy(storage) + return blosc2.Storage(**storage) + + storage_kwargs = { + name: kwargs.pop(name) for name in list(blosc2.Storage.__annotations__) if name in kwargs + } + return blosc2.Storage(**storage_kwargs) + + @staticmethod + def _validate_storage(storage: blosc2.Storage) -> None: + if storage.mmap_mode not in (None, "r"): + raise ValueError("For VLArray containers, mmap_mode must be None or 'r'") + if storage.mmap_mode == "r" and storage.mode != "r": + raise ValueError("For VLArray containers, mmap_mode='r' requires mode='r'") + + def _attach_schunk(self, schunk: SChunk) -> None: + self.schunk = schunk + self.urlpath = schunk.urlpath + self.mode = schunk.mode + self.mmap_mode = getattr(schunk, "mmap_mode", None) + self._validate_tag() + + def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: + urlpath = storage.urlpath + if urlpath is None or storage.mode not in ("r", "a") or not pathlib.Path(urlpath).exists(): + return False + + schunk = blosc2.blosc2_ext.open(urlpath, mode=storage.mode, offset=0, mmap_mode=storage.mmap_mode) + self._attach_schunk(schunk) + return True + + def __init__( + self, + chunksize: int | None = None, + _from_schunk: SChunk | None = None, + **kwargs: Any, + ) -> None: + if _from_schunk is not None: + if chunksize is not None: + raise ValueError("Cannot pass `chunksize` together with `_from_schunk`") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise ValueError(f"Cannot pass {unexpected} together with `_from_schunk`") + self._attach_schunk(_from_schunk) + return + + cparams = kwargs.pop("cparams", None) + dparams = kwargs.pop("dparams", None) + storage = kwargs.pop("storage", None) + storage = self._coerce_storage(storage, kwargs) + + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise ValueError(f"Unsupported VLArray keyword argument(s): {unexpected}") + + self._validate_storage(storage) + cparams = self._set_typesize_one(cparams) + + if dparams is None: + dparams = blosc2.DParams() + + if self._maybe_open_existing(storage): + return + + fixed_meta = dict(storage.meta or {}) + fixed_meta["vlarray"] = dict(_VLARRAY_META) + storage.meta = fixed_meta + if chunksize is None: + chunksize = -1 + schunk = blosc2.SChunk( + chunksize=chunksize, data=None, cparams=cparams, dparams=dparams, storage=storage + ) + self._attach_schunk(schunk) + + def _validate_tag(self) -> None: + if "vlarray" not in self.schunk.meta: + raise ValueError("The supplied SChunk is not tagged as a VLArray") + + def _check_writable(self) -> None: + if self.mode == "r": + raise ValueError("Cannot modify a VLArray opened in read-only mode") + + def _normalize_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("VLArray indices must be integers") + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError("VLArray index out of range") + return index + + def _serialize(self, value: Any) -> bytes: + payload = packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) + _check_serialized_size(payload) + return payload + + def _compress(self, payload: bytes) -> bytes: + return blosc2.compress2(payload, cparams=self.schunk.cparams) + + def append(self, value: Any) -> int: + self._check_writable() + chunk = self._compress(self._serialize(value)) + return self.schunk.append_chunk(chunk) + + def __getitem__(self, index: int) -> Any: + if isinstance(index, slice): + raise NotImplementedError("Slicing is not supported for VLArray") + index = self._normalize_index(index) + payload = self.schunk.decompress_chunk(index) + return unpackb(payload, list_hook=blosc2_ext.decode_tuple) + + def __setitem__(self, index: int, value: Any) -> None: + if isinstance(index, slice): + raise NotImplementedError("Slicing is not supported for VLArray") + self._check_writable() + index = self._normalize_index(index) + chunk = self._compress(self._serialize(value)) + self.schunk.update_chunk(index, chunk) + + def __len__(self) -> int: + return self.schunk.nchunks + + def __iter__(self) -> Iterator[Any]: + for i in range(len(self)): + yield self[i] + + @property + def meta(self): + return self.schunk.meta + + @property + def vlmeta(self): + return self.schunk.vlmeta + + @property + def cparams(self): + return self.schunk.cparams + + @property + def dparams(self): + return self.schunk.dparams + + @property + def chunksize(self) -> int: + return self.schunk.chunksize + + def to_cframe(self) -> bytes: + return self.schunk.to_cframe() + + def __enter__(self) -> VLArray: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + return False + + def __repr__(self) -> str: + return f"VLArray(len={len(self)}, urlpath={self.urlpath!r})" + + +def vlarray_from_cframe(cframe: bytes, copy: bool = False) -> VLArray: + """Deserialize a CFrame buffer into a :class:`VLArray`.""" + + schunk = blosc2.schunk_from_cframe(cframe, copy=copy) + return VLArray(_from_schunk=schunk) diff --git a/tests/test_schunk_update.py b/tests/test_schunk_update.py index 2f3a8b7b..4115c6b0 100644 --- a/tests/test_schunk_update.py +++ b/tests/test_schunk_update.py @@ -108,3 +108,38 @@ def test_update(contiguous, urlpath, nchunks, nupdates, copy, create_chunk, gil) for i in range(nchunks): schunk.decompress_chunk(i) blosc2.remove_urlpath(urlpath) + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_variable_append_chunk.b2frame"), + (False, "test_variable_append_chunk_s.b2frame"), + ], +) +def test_append_chunk_variable_sizes(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + schunk = blosc2.SChunk(chunksize=-1, contiguous=contiguous, urlpath=urlpath, cparams={"typesize": 1}) + payloads = [b"a" * 13, b"b" * 29, b"c" * 41] + + for i, payload in enumerate(payloads, start=1): + chunk = blosc2.compress2(payload, typesize=1) + assert schunk.append_chunk(chunk) == i + assert schunk.decompress_chunk(i - 1) == payload + + assert schunk.chunksize == 0 + + replacement = b"z" * 17 + schunk.update_chunk(1, blosc2.compress2(replacement, typesize=1)) + expected = [payloads[0], replacement, payloads[2]] + assert [schunk.decompress_chunk(i) for i in range(schunk.nchunks)] == expected + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert reopened.chunksize == 0 + assert [reopened.decompress_chunk(i) for i in range(reopened.nchunks)] == expected + + blosc2.remove_urlpath(urlpath) diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py new file mode 100644 index 00000000..447d33d7 --- /dev/null +++ b/tests/test_vlarray.py @@ -0,0 +1,112 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import pytest + +import blosc2 + +VALUES = [ + b"bytes\x00payload", + "plain text", + 42, + 3.5, + True, + None, + [1, "two", b"three"], + (1, 2, "three"), + {"nested": [1, 2], "tuple": (3, 4)}, +] + + +def _storage(contiguous, urlpath, mode="w"): + return blosc2.Storage(contiguous=contiguous, urlpath=urlpath, mode=mode) + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_vlarray.b2frame"), + (False, "test_vlarray_s.b2frame"), + ], +) +def test_vlarray_roundtrip(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) + assert vlarray.meta["vlarray"]["serializer"] == "msgpack" + + for i, value in enumerate(VALUES, start=1): + assert vlarray.append(value) == i + + assert len(vlarray) == len(VALUES) + assert list(vlarray) == VALUES + assert vlarray[-1] == VALUES[-1] + + expected = list(VALUES) + expected[1] = {"updated": ("tuple", 7)} + expected[-1] = "tiny" + vlarray[1] = expected[1] + vlarray[-1] = expected[-1] + assert list(vlarray) == expected + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert isinstance(reopened, blosc2.VLArray) + assert list(reopened) == expected + with pytest.raises(ValueError): + reopened.append("nope") + with pytest.raises(ValueError): + reopened[0] = "nope" + + reopened_rw = blosc2.open(urlpath, mode="a") + reopened_rw[0] = "changed" + expected[0] = "changed" + assert list(reopened_rw) == expected + + if contiguous: + reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") + assert isinstance(reopened_mmap, blosc2.VLArray) + assert list(reopened_mmap) == expected + + blosc2.remove_urlpath(urlpath) + + +def test_vlarray_from_cframe(): + vlarray = blosc2.VLArray() + for value in VALUES[:4]: + vlarray.append(value) + + restored = blosc2.from_cframe(vlarray.to_cframe()) + assert isinstance(restored, blosc2.VLArray) + assert list(restored) == VALUES[:4] + + restored2 = blosc2.vlarray_from_cframe(vlarray.to_cframe()) + assert isinstance(restored2, blosc2.VLArray) + assert list(restored2) == VALUES[:4] + + +def test_vlarray_constructor_kwargs(): + urlpath = "test_vlarray_kwargs.b2frame" + blosc2.remove_urlpath(urlpath) + + vlarray = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) + for value in VALUES[:3]: + vlarray.append(value) + + reopened = blosc2.VLArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + assert list(reopened) == VALUES[:3] + + blosc2.remove_urlpath(urlpath) + + +def test_vlarray_size_guard(monkeypatch): + vlarray = blosc2.VLArray() + monkeypatch.setattr(blosc2, "MAX_BUFFERSIZE", 4) + with pytest.raises(ValueError, match="Serialized objects cannot be larger"): + vlarray.append("payload") From aedf2b3243b8874ed5b28f28aed5faa5fc66ae70 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 17:38:22 +0100 Subject: [PATCH 09/69] Add several list-oriented methods (insert, delete, pop...); docs are here too --- doc/reference/classes.rst | 2 + doc/reference/misc.rst | 2 + src/blosc2/vlarray.py | 72 ++++++++++++++++++++++++++++++++ tests/test_vlarray.py | 87 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/doc/reference/classes.rst b/doc/reference/classes.rst index cca8c7e0..84af533c 100644 --- a/doc/reference/classes.rst +++ b/doc/reference/classes.rst @@ -16,6 +16,7 @@ Main Classes DictStore TreeStore EmbedStore + VLArray Proxy ProxySource ProxyNDSource @@ -33,6 +34,7 @@ Main Classes dict_store tree_store embed_store + vlarray proxy proxysource proxyndsource diff --git a/doc/reference/misc.rst b/doc/reference/misc.rst index 50cb0c1b..b6e0ddee 100644 --- a/doc/reference/misc.rst +++ b/doc/reference/misc.rst @@ -134,6 +134,8 @@ This page documents the miscellaneous members of the ``blosc2`` module that do n TreeStore, DictStore, EmbedStore, + VLArray, + vlarray_from_cframe, abs, acos, acosh, diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py index 4d172137..2677b32a 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/vlarray.py @@ -89,6 +89,16 @@ def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: self._attach_schunk(schunk) return True + def _make_storage(self) -> blosc2.Storage: + meta = {name: self.meta[name] for name in self.meta} + return blosc2.Storage( + contiguous=self.schunk.contiguous, + urlpath=self.urlpath, + mode=self.mode, + mmap_mode=self.mmap_mode, + meta=meta, + ) + def __init__( self, chunksize: int | None = None, @@ -149,6 +159,17 @@ def _normalize_index(self, index: int) -> int: raise IndexError("VLArray index out of range") return index + def _normalize_insert_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("VLArray indices must be integers") + if index < 0: + index += len(self) + if index < 0: + return 0 + if index > len(self): + return len(self) + return index + def _serialize(self, value: Any) -> bytes: payload = packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) _check_serialized_size(payload) @@ -158,10 +179,58 @@ def _compress(self, payload: bytes) -> bytes: return blosc2.compress2(payload, cparams=self.schunk.cparams) def append(self, value: Any) -> int: + """Append one value and return the new number of entries.""" self._check_writable() chunk = self._compress(self._serialize(value)) return self.schunk.append_chunk(chunk) + def insert(self, index: int, value: Any) -> int: + """Insert one value at ``index`` and return the new number of entries.""" + self._check_writable() + index = self._normalize_insert_index(index) + chunk = self._compress(self._serialize(value)) + return self.schunk.insert_chunk(index, chunk) + + def delete(self, index: int) -> int: + """Delete the value at ``index`` and return the new number of entries.""" + self._check_writable() + if isinstance(index, slice): + raise NotImplementedError("Slicing is not supported for VLArray") + index = self._normalize_index(index) + return self.schunk.delete_chunk(index) + + def pop(self, index: int = -1) -> Any: + """Remove and return the value at ``index``.""" + self._check_writable() + if isinstance(index, slice): + raise NotImplementedError("Slicing is not supported for VLArray") + index = self._normalize_index(index) + value = self[index] + self.schunk.delete_chunk(index) + return value + + def extend(self, values: object) -> None: + """Append all values from an iterable.""" + self._check_writable() + for value in values: + chunk = self._compress(self._serialize(value)) + self.schunk.append_chunk(chunk) + + def clear(self) -> None: + """Remove all entries from the container.""" + self._check_writable() + storage = self._make_storage() + if storage.urlpath is not None: + blosc2.remove_urlpath(storage.urlpath) + schunk = blosc2.SChunk( + chunksize=-1, + data=None, + cparams=copy.deepcopy(self.cparams), + dparams=copy.deepcopy(self.dparams), + storage=storage, + ) + self._attach_schunk(schunk) + def __getitem__(self, index: int) -> Any: if isinstance(index, slice): raise NotImplementedError("Slicing is not supported for VLArray") @@ -177,6 +246,9 @@ def __setitem__(self, index: int, value: Any) -> None: chunk = self._compress(self._serialize(value)) self.schunk.update_chunk(index, chunk) + def __delitem__(self, index: int) -> None: + self.delete(index) + def __len__(self) -> int: return self.schunk.nchunks diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index 447d33d7..bb643473 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -53,6 +53,16 @@ def test_vlarray_roundtrip(contiguous, urlpath): expected[-1] = "tiny" vlarray[1] = expected[1] vlarray[-1] = expected[-1] + assert vlarray.insert(0, "head") == len(expected) + 1 + expected.insert(0, "head") + assert vlarray.insert(-1, {"between": 5}) == len(expected) + 1 + expected.insert(-1, {"between": 5}) + assert vlarray.insert(999, "tail") == len(expected) + 1 + expected.insert(999, "tail") + assert vlarray.delete(2) == len(expected) - 1 + del expected[2] + del vlarray[-2] + del expected[-2] assert list(vlarray) == expected if urlpath is not None: @@ -63,6 +73,18 @@ def test_vlarray_roundtrip(contiguous, urlpath): reopened.append("nope") with pytest.raises(ValueError): reopened[0] = "nope" + with pytest.raises(ValueError): + reopened.insert(0, "nope") + with pytest.raises(ValueError): + reopened.delete(0) + with pytest.raises(ValueError): + del reopened[0] + with pytest.raises(ValueError): + reopened.extend(["nope"]) + with pytest.raises(ValueError): + reopened.pop() + with pytest.raises(ValueError): + reopened.clear() reopened_rw = blosc2.open(urlpath, mode="a") reopened_rw[0] = "changed" @@ -79,16 +101,20 @@ def test_vlarray_roundtrip(contiguous, urlpath): def test_vlarray_from_cframe(): vlarray = blosc2.VLArray() - for value in VALUES[:4]: - vlarray.append(value) + vlarray.extend(VALUES) + vlarray.insert(1, {"inserted": True}) + del vlarray[3] + expected = list(VALUES) + expected.insert(1, {"inserted": True}) + del expected[3] restored = blosc2.from_cframe(vlarray.to_cframe()) assert isinstance(restored, blosc2.VLArray) - assert list(restored) == VALUES[:4] + assert list(restored) == expected restored2 = blosc2.vlarray_from_cframe(vlarray.to_cframe()) assert isinstance(restored2, blosc2.VLArray) - assert list(restored2) == VALUES[:4] + assert list(restored2) == expected def test_vlarray_constructor_kwargs(): @@ -96,11 +122,11 @@ def test_vlarray_constructor_kwargs(): blosc2.remove_urlpath(urlpath) vlarray = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) - for value in VALUES[:3]: + for value in VALUES: vlarray.append(value) reopened = blosc2.VLArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") - assert list(reopened) == VALUES[:3] + assert list(reopened) == VALUES blosc2.remove_urlpath(urlpath) @@ -110,3 +136,52 @@ def test_vlarray_size_guard(monkeypatch): monkeypatch.setattr(blosc2, "MAX_BUFFERSIZE", 4) with pytest.raises(ValueError, match="Serialized objects cannot be larger"): vlarray.append("payload") + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_vlarray_list_ops.b2frame"), + (False, "test_vlarray_list_ops_s.b2frame"), + ], +) +def test_vlarray_list_like_ops(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) + vlarray.extend([1, 2, 3]) + assert list(vlarray) == [1, 2, 3] + assert vlarray.pop() == 3 + assert vlarray.pop(0) == 1 + assert list(vlarray) == [2] + + vlarray.clear() + assert len(vlarray) == 0 + assert list(vlarray) == [] + + vlarray.extend(["a", "b"]) + assert list(vlarray) == ["a", "b"] + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert list(reopened) == ["a", "b"] + + blosc2.remove_urlpath(urlpath) + + +def test_vlarray_insert_delete_errors(): + vlarray = blosc2.VLArray() + vlarray.append("value") + + with pytest.raises(TypeError): + vlarray.insert("0", "bad") + with pytest.raises(IndexError): + vlarray.delete(3) + with pytest.raises(NotImplementedError): + vlarray.delete(slice(0, 1)) + with pytest.raises(IndexError): + blosc2.VLArray().pop() + with pytest.raises(NotImplementedError): + vlarray.pop(slice(0, 1)) From 004dfb59f2e3868918b63ca51efd045cdd13525b Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 18:00:11 +0100 Subject: [PATCH 10/69] Add example and tutorial --- doc/getting_started/tutorials.rst | 1 + .../tutorials/11.vlarray.ipynb | 325 ++++++++++++++++++ examples/vlarray.py | 69 ++++ src/blosc2/vlarray.py | 52 ++- tests/test_vlarray.py | 86 ++++- 5 files changed, 528 insertions(+), 5 deletions(-) create mode 100644 doc/getting_started/tutorials/11.vlarray.ipynb create mode 100644 examples/vlarray.py diff --git a/doc/getting_started/tutorials.rst b/doc/getting_started/tutorials.rst index 35f347d4..563ba8ea 100644 --- a/doc/getting_started/tutorials.rst +++ b/doc/getting_started/tutorials.rst @@ -16,3 +16,4 @@ Tutorials tutorials/08.schunk-slicing_and_beyond tutorials/09.ucodecs-ufilters tutorials/10.prefilters + tutorials/11.vlarray diff --git a/doc/getting_started/tutorials/11.vlarray.ipynb b/doc/getting_started/tutorials/11.vlarray.ipynb new file mode 100644 index 00000000..e78733fb --- /dev/null +++ b/doc/getting_started/tutorials/11.vlarray.ipynb @@ -0,0 +1,325 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Working with VLArray\n", + "\n", + "A `VLArray` is a list-like container for variable-length Python values backed by a single `SChunk`. Each entry is stored in its own compressed chunk, and values are serialized with msgpack before reaching storage.\n", + "\n", + "This makes `VLArray` a good fit for heterogeneous, variable-length payloads such as small dictionaries, strings, tuples, byte blobs, or nested list/dict structures." + ], + "id": "ceb4789a488cc07f" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.563663Z", + "start_time": "2026-03-14T16:57:57.294290Z" + } + }, + "source": [ + "import blosc2\n", + "\n", + "\n", + "def show(label, value):\n", + " print(f\"{label}: {value}\")\n", + "\n", + "\n", + "urlpath = \"vlarray_tutorial.b2frame\"\n", + "copy_path = \"vlarray_tutorial_copy.b2frame\"\n", + "blosc2.remove_urlpath(urlpath)\n", + "blosc2.remove_urlpath(copy_path)" + ], + "id": "f264f2e4bcb57029", + "outputs": [], + "execution_count": 1 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Creating and populating a VLArray\n", + "\n", + "Entries can be appended one by one or in batches with `extend()`. The container accepts the msgpack-safe Python types supported by the implementation: `bytes`, `str`, `int`, `float`, `bool`, `None`, `list`, `tuple`, and `dict`." + ], + "id": "24ceae332dfa437" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.609603Z", + "start_time": "2026-03-14T16:57:57.569987Z" + } + }, + "source": [ + "vla = blosc2.VLArray(urlpath=urlpath, mode=\"w\")\n", + "vla.append({\"name\": \"alpha\", \"count\": 1})\n", + "vla.extend([b\"bytes\", (\"a\", 2), [\"x\", \"y\"], 42, None])\n", + "vla.insert(1, \"between\")\n", + "\n", + "show(\"Initial entries\", list(vla))\n", + "show(\"Length\", len(vla))" + ], + "id": "10e4e9ce600cda9d", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Initial entries: [{'name': 'alpha', 'count': 1}, 'between', b'bytes', ('a', 2), ['x', 'y'], 42, None]\n", + "Length: 7\n" + ] + } + ], + "execution_count": 2 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Indexing and slicing\n", + "\n", + "Indexing behaves like a Python list. Negative indexes are supported, and slice reads return a plain Python list." + ], + "id": "2f2dbe81b7653d8f" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.677796Z", + "start_time": "2026-03-14T16:57:57.623048Z" + } + }, + "source": [ + "show(\"Last entry\", vla[-1])\n", + "show(\"Slice [1:6:2]\", vla[1:6:2])\n", + "show(\"Reverse slice\", vla[::-2])" + ], + "id": "82ea38dca631efb9", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Last entry: None\n", + "Slice [1:6:2]: ['between', ('a', 2), 42]\n", + "Reverse slice: [None, ['x', 'y'], b'bytes', {'name': 'alpha', 'count': 1}]\n" + ] + } + ], + "execution_count": 3 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Updating, inserting, and deleting\n", + "\n", + "Single entries can be overwritten by index. Slice assignment follows Python list rules: slices with `step == 1` may resize the container, while extended slices require matching lengths." + ], + "id": "a871bb9b21d6f36c" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.727569Z", + "start_time": "2026-03-14T16:57:57.678936Z" + } + }, + "source": [ + "vla[2:5] = [\"replaced\", {\"nested\": True}]\n", + "show(\"After slice replacement\", list(vla))\n", + "\n", + "vla[::2] = [\"even-0\", \"even-1\", \"even-2\"]\n", + "show(\"After extended-slice update\", list(vla))\n", + "\n", + "del vla[1::3]\n", + "show(\"After slice deletion\", list(vla))\n", + "\n", + "removed = vla.pop()\n", + "show(\"Popped entry\", removed)\n", + "show(\"After pop\", list(vla))" + ], + "id": "e22e4f90499ae02", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "After slice replacement: [{'name': 'alpha', 'count': 1}, 'between', 'replaced', {'nested': True}, 42, None]\n", + "After extended-slice update: ['even-0', 'between', 'even-1', {'nested': True}, 'even-2', None]\n", + "After slice deletion: ['even-0', 'even-1', {'nested': True}, None]\n", + "Popped entry: None\n", + "After pop: ['even-0', 'even-1', {'nested': True}]\n" + ] + } + ], + "execution_count": 4 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Copying with new storage or compression parameters\n", + "\n", + "The `copy()` method can duplicate the container into a different storage layout or with different compression settings." + ], + "id": "f41af458cb5faa9f" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.747309Z", + "start_time": "2026-03-14T16:57:57.730015Z" + } + }, + "source": [ + "vla_copy = vla.copy(\n", + " urlpath=copy_path,\n", + " contiguous=False,\n", + " cparams={\"codec\": blosc2.Codec.LZ4, \"clevel\": 5},\n", + ")\n", + "\n", + "show(\"Copied entries\", list(vla_copy))\n", + "show(\"Copy storage is contiguous\", vla_copy.schunk.contiguous)\n", + "show(\"Copy codec\", vla_copy.cparams.codec)" + ], + "id": "6e752260e010272e", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Copied entries: ['even-0', 'even-1', {'nested': True}]\n", + "Copy storage is contiguous: False\n", + "Copy codec: Codec.LZ4\n" + ] + } + ], + "execution_count": 5 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Round-tripping through cframes and reopening from disk\n", + "\n", + "Tagged persistent stores automatically reopen as `VLArray`, and a serialized cframe buffer does too." + ], + "id": "bb576497d4b6f537" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.759998Z", + "start_time": "2026-03-14T16:57:57.748296Z" + } + }, + "source": [ + "cframe = vla.to_cframe()\n", + "restored = blosc2.from_cframe(cframe)\n", + "show(\"from_cframe type\", type(restored).__name__)\n", + "show(\"from_cframe entries\", list(restored))\n", + "\n", + "reopened = blosc2.open(urlpath, mode=\"r\", mmap_mode=\"r\")\n", + "show(\"Reopened type\", type(reopened).__name__)\n", + "show(\"Reopened entries\", list(reopened))" + ], + "id": "42d59dccf6ea9c44", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "from_cframe type: VLArray\n", + "from_cframe entries: ['even-0', 'even-1', {'nested': True}]\n", + "Reopened type: VLArray\n", + "Reopened entries: ['even-0', 'even-1', {'nested': True}]\n" + ] + } + ], + "execution_count": 6 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clearing and reusing a container\n", + "\n", + "Calling `clear()` resets the backing storage so the container remains ready for new variable-length entries." + ], + "id": "53778312cc1a03bc" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.778160Z", + "start_time": "2026-03-14T16:57:57.761236Z" + } + }, + "source": [ + "scratch = vla.copy()\n", + "scratch.clear()\n", + "scratch.extend([\"fresh\", 123, {\"done\": True}])\n", + "show(\"After clear + extend on in-memory copy\", list(scratch))\n", + "\n", + "blosc2.remove_urlpath(urlpath)\n", + "blosc2.remove_urlpath(copy_path)" + ], + "id": "55b9ea793a41f38a", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "After clear + extend on in-memory copy: ['fresh', 123, {'done': True}]\n" + ] + } + ], + "execution_count": 7 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-14T16:57:57.789994Z", + "start_time": "2026-03-14T16:57:57.779434Z" + } + }, + "cell_type": "code", + "source": "", + "id": "34e77790ab2a0f94", + "outputs": [], + "execution_count": 7 + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/vlarray.py b/examples/vlarray.py new file mode 100644 index 00000000..0e988c83 --- /dev/null +++ b/examples/vlarray.py @@ -0,0 +1,69 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import blosc2 + + +def show(label, value): + print(f"{label}: {value}") + + +urlpath = "example_vlarray.b2frame" +copy_path = "example_vlarray_copy.b2frame" +blosc2.remove_urlpath(urlpath) +blosc2.remove_urlpath(copy_path) + +# Create a persistent VLArray and store heterogeneous Python values. +vla = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) +vla.append({"name": "alpha", "count": 1}) +vla.extend([b"bytes", ("a", 2), ["x", "y"], 42, None]) +vla.insert(1, "between") + +show("Initial entries", list(vla)) +show("Negative index", vla[-1]) +show("Slice [1:6:2]", vla[1:6:2]) + +# Slice assignment with step == 1 can resize the container. +vla[2:5] = ["replaced", {"nested": True}] +show("After slice replacement", list(vla)) + +# Extended slices require matching lengths. +vla[::2] = ["even-0", "even-1", "even-2"] +show("After extended-slice update", list(vla)) + +# Delete by index, by slice, or with pop(). +del vla[1::3] +show("After slice deletion", list(vla)) +removed = vla.pop() +show("Popped entry", removed) +show("After pop", list(vla)) + +# Copy into a different backing store and with different compression parameters. +vla_copy = vla.copy(urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) +show("Copied entries", list(vla_copy)) +show("Copy storage is contiguous", vla_copy.schunk.contiguous) +show("Copy codec", vla_copy.cparams.codec) + +# Round-trip through a cframe buffer. +cframe = vla.to_cframe() +restored = blosc2.from_cframe(cframe) +show("from_cframe type", type(restored).__name__) +show("from_cframe entries", list(restored)) + +# Reopen from disk; tagged stores come back as VLArray. +reopened = blosc2.open(urlpath, mode="r", mmap_mode="r") +show("Reopened type", type(reopened).__name__) +show("Reopened entries", list(reopened)) + +# Clear and reuse an in-memory copy. +scratch = vla.copy() +scratch.clear() +scratch.extend(["fresh", 123, {"done": True}]) +show("After clear + extend on in-memory copy", list(scratch)) + +blosc2.remove_urlpath(urlpath) +blosc2.remove_urlpath(copy_path) diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py index 2677b32a..556b31ed 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/vlarray.py @@ -170,6 +170,12 @@ def _normalize_insert_index(self, index: int) -> int: return len(self) return index + def _slice_indices(self, index: slice) -> list[int]: + return list(range(*index.indices(len(self)))) + + def _copy_meta(self) -> dict[str, Any]: + return {name: self.meta[name] for name in self.meta} + def _serialize(self, value: Any) -> bytes: payload = packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) _check_serialized_size(payload) @@ -195,7 +201,9 @@ def delete(self, index: int) -> int: """Delete the value at ``index`` and return the new number of entries.""" self._check_writable() if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for VLArray") + for idx in reversed(self._slice_indices(index)): + self.schunk.delete_chunk(idx) + return len(self) index = self._normalize_index(index) return self.schunk.delete_chunk(index) @@ -233,14 +241,33 @@ def clear(self) -> None: def __getitem__(self, index: int) -> Any: if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for VLArray") + return [self[i] for i in self._slice_indices(index)] index = self._normalize_index(index) payload = self.schunk.decompress_chunk(index) return unpackb(payload, list_hook=blosc2_ext.decode_tuple) def __setitem__(self, index: int, value: Any) -> None: if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for VLArray") + self._check_writable() + indices = self._slice_indices(index) + values = list(value) + step = 1 if index.step is None else index.step + if step == 1: + start = self._normalize_insert_index(0 if index.start is None else index.start) + for idx in reversed(indices): + self.schunk.delete_chunk(idx) + for offset, item in enumerate(values): + chunk = self._compress(self._serialize(item)) + self.schunk.insert_chunk(start + offset, chunk) + return + if len(values) != len(indices): + raise ValueError( + f"attempt to assign sequence of size {len(values)} to extended slice of size {len(indices)}" + ) + for idx, item in zip(indices, values, strict=True): + chunk = self._compress(self._serialize(item)) + self.schunk.update_chunk(idx, chunk) + return self._check_writable() index = self._normalize_index(index) chunk = self._compress(self._serialize(value)) @@ -279,6 +306,25 @@ def chunksize(self) -> int: def to_cframe(self) -> bytes: return self.schunk.to_cframe() + def copy(self, **kwargs: Any) -> VLArray: + """Create a copy of the container with optional constructor overrides.""" + if "meta" in kwargs: + raise ValueError("meta should not be passed to copy") + + kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) + kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) + kwargs["chunksize"] = kwargs.get("chunksize", -1) + + if "storage" not in kwargs: + kwargs["meta"] = self._copy_meta() + kwargs["contiguous"] = kwargs.get("contiguous", self.schunk.contiguous) + if "urlpath" in kwargs and "mode" not in kwargs: + kwargs["mode"] = "w" + + out = VLArray(**kwargs) + out.extend(self) + return out + def __enter__(self) -> VLArray: return self diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index bb643473..6f67c500 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -171,6 +171,90 @@ def test_vlarray_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_vlarray_slices.b2frame"), + (False, "test_vlarray_slices_s.b2frame"), + ], +) +def test_vlarray_slices(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + expected = list(range(8)) + vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) + vlarray.extend(expected) + + assert vlarray[1:6:2] == expected[1:6:2] + assert vlarray[::-2] == expected[::-2] + + vlarray[2:5] = ["a", "b"] + expected[2:5] = ["a", "b"] + assert list(vlarray) == expected + + vlarray[1:6:2] = [100, 101, 102] + expected[1:6:2] = [100, 101, 102] + assert list(vlarray) == expected + + del vlarray[::3] + del expected[::3] + assert list(vlarray) == expected + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert reopened[::2] == expected[::2] + with pytest.raises(ValueError): + reopened[1:3] = [9] + with pytest.raises(ValueError): + del reopened[::2] + + blosc2.remove_urlpath(urlpath) + + +def test_vlarray_slice_errors(): + vlarray = blosc2.VLArray() + vlarray.extend([0, 1, 2, 3]) + + with pytest.raises(ValueError, match="extended slice"): + vlarray[::2] = [9] + with pytest.raises(TypeError): + vlarray[1:2] = 3 + with pytest.raises(ValueError): + _ = vlarray[::0] + + +def test_vlarray_copy(): + urlpath = "test_vlarray_copy.b2frame" + copy_path = "test_vlarray_copy_out.b2frame" + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + original = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) + original.extend(VALUES) + original.insert(1, {"copy": True}) + + copied = original.copy( + urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5} + ) + assert list(copied) == list(original) + assert copied.urlpath == copy_path + assert copied.schunk.contiguous is False + assert copied.cparams.codec == blosc2.Codec.LZ4 + assert copied.cparams.clevel == 5 + + inmem = original.copy() + assert list(inmem) == list(original) + assert inmem.urlpath is None + + with pytest.raises(ValueError, match="meta should not be passed to copy"): + original.copy(meta={}) + + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + def test_vlarray_insert_delete_errors(): vlarray = blosc2.VLArray() vlarray.append("value") @@ -179,8 +263,6 @@ def test_vlarray_insert_delete_errors(): vlarray.insert("0", "bad") with pytest.raises(IndexError): vlarray.delete(3) - with pytest.raises(NotImplementedError): - vlarray.delete(slice(0, 1)) with pytest.raises(IndexError): blosc2.VLArray().pop() with pytest.raises(NotImplementedError): From 510ad85b8f85f95a591b16c9cc6669e9f639fc3b Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 18:08:53 +0100 Subject: [PATCH 11/69] Update to current C-Blosc2 main --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ae63b85d..b748c794 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG ba55a6be9293faf9740f03b5953b82f1c955879e # variable-length chunks support in schunks + GIT_TAG 25197eb96d05318c939b3252a6b373ccd6ae49fe # variable-length chunks support in schunks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 5926a46e7a1c021b4ac0cc2862f65e828777c07a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 19:11:11 +0100 Subject: [PATCH 12/69] Add support for empty lists in VLArray store --- src/blosc2/_msgpack_utils.py | 26 ++++++++++++++++++++++++++ src/blosc2/schunk.py | 18 +++++------------- src/blosc2/vlarray.py | 10 ++++------ tests/test_vlarray.py | 7 +++++++ tests/test_vlmeta.py | 9 +++++++++ 5 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 src/blosc2/_msgpack_utils.py diff --git a/src/blosc2/_msgpack_utils.py b/src/blosc2/_msgpack_utils.py new file mode 100644 index 00000000..fd179b84 --- /dev/null +++ b/src/blosc2/_msgpack_utils.py @@ -0,0 +1,26 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +from msgpack import packb, unpackb + +from blosc2 import blosc2_ext + + +def msgpack_packb(value): + return packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) + + +def decode_tuple_list_hook(obj): + if obj and obj[0] == "__tuple__": + return tuple(obj[1:]) + return obj + + +def msgpack_unpackb(payload): + return unpackb(payload, list_hook=decode_tuple_list_hook) diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 3e001445..5421ae41 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -16,10 +16,10 @@ from typing import Any, NamedTuple import numpy as np -from msgpack import packb, unpackb import blosc2 from blosc2 import SpecialValue, blosc2_ext +from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter @@ -46,12 +46,7 @@ def __setitem__(self, name, content): return raise NotImplementedError("Slicing is not supported, unless [:]") cparams = {"typesize": 1} - content = packb( - content, - default=blosc2_ext.encode_tuple, - strict_types=True, - use_bin_type=True, - ) + content = msgpack_packb(content) super().set_vlmeta(name, content, **cparams) def __getitem__(self, name): @@ -60,7 +55,7 @@ def __getitem__(self, name): # Return all the vlmetalayers return self.getall() raise NotImplementedError("Slicing is not supported, unless [:]") - return unpackb(super().get_vlmeta(name), list_hook=blosc2_ext.decode_tuple) + return msgpack_unpackb(super().get_vlmeta(name)) def __delitem__(self, name): blosc2_ext.check_access_mode(self.urlpath, self.mode) @@ -120,7 +115,7 @@ def __setitem__(self, key: str, value: bytes) -> None: ..warning: Note that the *length* of the metalayer cannot change, otherwise an exception will be raised. """ - value = packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) + value = msgpack_packb(value) blosc2_ext.meta__setitem__(self.schunk, key, value) def __getitem__(self, item: str | slice) -> bytes | dict[str, bytes]: @@ -144,10 +139,7 @@ def __getitem__(self, item: str | slice) -> bytes | dict[str, bytes]: return self.getall() raise NotImplementedError("Slicing is not supported, unless [:]") if self.__contains__(item): - return unpackb( - blosc2_ext.meta__getitem__(self.schunk, item), - list_hook=blosc2_ext.decode_tuple, - ) + return msgpack_unpackb(blosc2_ext.meta__getitem__(self.schunk, item)) else: raise KeyError(f"{item} not found") diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py index 556b31ed..d7d885e2 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/vlarray.py @@ -11,10 +11,8 @@ import pathlib from typing import TYPE_CHECKING, Any -from msgpack import packb, unpackb - import blosc2 -from blosc2 import blosc2_ext +from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb if TYPE_CHECKING: from collections.abc import Iterator @@ -177,7 +175,7 @@ def _copy_meta(self) -> dict[str, Any]: return {name: self.meta[name] for name in self.meta} def _serialize(self, value: Any) -> bytes: - payload = packb(value, default=blosc2_ext.encode_tuple, strict_types=True, use_bin_type=True) + payload = msgpack_packb(value) _check_serialized_size(payload) return payload @@ -244,7 +242,7 @@ def __getitem__(self, index: int) -> Any: return [self[i] for i in self._slice_indices(index)] index = self._normalize_index(index) payload = self.schunk.decompress_chunk(index) - return unpackb(payload, list_hook=blosc2_ext.decode_tuple) + return msgpack_unpackb(payload) def __setitem__(self, index: int, value: Any) -> None: if isinstance(index, slice): @@ -335,7 +333,7 @@ def __repr__(self) -> str: return f"VLArray(len={len(self)}, urlpath={self.urlpath!r})" -def vlarray_from_cframe(cframe: bytes, copy: bool = False) -> VLArray: +def vlarray_from_cframe(cframe: bytes, copy: bool = True) -> VLArray: """Deserialize a CFrame buffer into a :class:`VLArray`.""" schunk = blosc2.schunk_from_cframe(cframe, copy=copy) diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index 6f67c500..84692d24 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -255,6 +255,13 @@ def test_vlarray_copy(): blosc2.remove_urlpath(copy_path) +def test_vlarray_empty_list_roundtrip(): + values = [[], {"a": []}, [[], ["nested"]], None, ("tuple", []), {"rows": [[], []]}] + vlarray = blosc2.VLArray() + vlarray.extend(values) + assert list(vlarray) == values + + def test_vlarray_insert_delete_errors(): vlarray = blosc2.VLArray() vlarray.append("value") diff --git a/tests/test_vlmeta.py b/tests/test_vlmeta.py index 8269f43c..ec5f0784 100644 --- a/tests/test_vlmeta.py +++ b/tests/test_vlmeta.py @@ -118,3 +118,12 @@ def clear(schunk): schunk.vlmeta.clear() assert schunk.vlmeta.__len__() == 0 + + +def test_vlmeta_empty_list_roundtrip(): + schunk = blosc2.SChunk() + schunk.vlmeta["empty"] = [] + schunk.vlmeta["nested"] = {"rows": [[], ["x"]]} + + assert schunk.vlmeta["empty"] == [] + assert schunk.vlmeta["nested"] == {"rows": [[], ["x"]]} From 2ba2532addd2a1850ca62975b4954099b40792b7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sat, 14 Mar 2026 19:14:16 +0100 Subject: [PATCH 13/69] Better support for VLArrays in dict and tree stores --- src/blosc2/dict_store.py | 47 ++++++++++++++++++++++++++---------- src/blosc2/embed_store.py | 21 +++++++++------- src/blosc2/tree_store.py | 16 +++++++------ tests/test_dict_store.py | 43 +++++++++++++++++++++++++++++++++ tests/test_tree_store.py | 50 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 28 deletions(-) diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 9ac3cf46..1cb6dd3c 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -5,19 +5,23 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +from __future__ import annotations + import os import shutil import tempfile import zipfile -from collections.abc import Iterator, Set -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np import blosc2 from blosc2.c2array import C2Array from blosc2.embed_store import EmbedStore -from blosc2.schunk import SChunk +from blosc2.schunk import SChunk, _process_opened_object + +if TYPE_CHECKING: + from collections.abc import Iterator, Set class DictStore: @@ -244,7 +248,25 @@ def estore(self) -> EmbedStore: """Access the underlying EmbedStore.""" return self._estore - def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: + @staticmethod + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray) -> int: + if isinstance(value, blosc2.VLArray): + return value.schunk.nbytes + return value.nbytes + + @staticmethod + def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray) -> bool: + return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray)) and bool( + getattr(value, "urlpath", None) + ) + + @staticmethod + def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray) -> str: + if isinstance(value, blosc2.NDArray): + return ".b2nd" + return ".b2f" + + def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: """Add a node to the DictStore.""" if isinstance(value, np.ndarray): value = blosc2.asarray(value, cparams=self.cparams, dparams=self.dparams) @@ -252,12 +274,10 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: if isinstance(value, C2Array): self._estore[key] = value return - exceeds_threshold = self.threshold is not None and value.nbytes >= self.threshold - # Consider both NDArray and SChunk external files (have urlpath) - external_file = isinstance(value, (blosc2.NDArray, SChunk)) and getattr(value, "urlpath", None) + exceeds_threshold = self.threshold is not None and self._value_nbytes(value) >= self.threshold + external_file = self._is_external_value(value) if exceeds_threshold or (external_file and self.threshold is None): - # Choose extension based on type - ext = ".b2f" if isinstance(value, SChunk) else ".b2nd" + ext = self._external_ext(value) # Convert key to a proper file path within the tree directory rel_key = key.lstrip("/") dest_path = os.path.join(self.working_dir, rel_key + ext) @@ -272,7 +292,7 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: if hasattr(value, "save"): value.save(urlpath=dest_path) else: - # An SChunk does not have a save() method + # SChunk and VLArray can both be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -290,20 +310,21 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: value = blosc2.from_cframe(value.to_cframe()) self._estore[key] = value - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | C2Array: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: filepath = self.map_tree[key] if filepath in self.offsets: offset = self.offsets[filepath]["offset"] - return blosc2.blosc2_ext.open( + opened = blosc2.blosc2_ext.open( self.b2z_path, mode="r", offset=offset, mmap_mode=self.mmap_mode, dparams=self.dparams, ) + return _process_opened_object(opened) else: urlpath = os.path.join(self.working_dir, filepath) if os.path.exists(urlpath): @@ -319,7 +340,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | C2Array: # Fall back to EmbedStore return self._estore[key] - def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | C2Array | Any: + def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | blosc2.VLArray | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index 84b1b200..b03d892e 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -5,15 +5,20 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +from __future__ import annotations + import copy -from collections.abc import Iterator, KeysView -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np import blosc2 from blosc2.c2array import C2Array -from blosc2.schunk import SChunk + +if TYPE_CHECKING: + from collections.abc import Iterator, KeysView + + from blosc2.schunk import SChunk PROFILE = False # Set to True to enable PROFILE prints in EmbedStore @@ -168,7 +173,7 @@ def _ensure_capacity(self, needed_bytes: int) -> None: new_size = max(required_size, int(self._store.shape[0] * 1.5)) self._store.resize((new_size,)) - def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: + def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: """Add a node to the embed store.""" if self.mode == "r": raise ValueError("Cannot set items in read-only mode.") @@ -191,7 +196,7 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -207,7 +212,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk: # Use from_cframe so we can deserialize either an NDArray or an SChunk return blosc2.from_cframe(serialized_data, copy=True) - def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | Any: + def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | blosc2.VLArray | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -234,12 +239,12 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk]]: + def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 6aad8165..a96c11a4 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -5,6 +5,8 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +from __future__ import annotations + import contextlib import os from collections.abc import Iterator, MutableMapping @@ -14,11 +16,11 @@ import blosc2 from blosc2.dict_store import DictStore -from blosc2.schunk import SChunk if TYPE_CHECKING: from blosc2.c2array import C2Array from blosc2.ndarray import NDArray + from blosc2.schunk import SChunk class vlmetaProxy(MutableMapping): @@ -29,7 +31,7 @@ class vlmetaProxy(MutableMapping): - Delegates iteration and length to the underlying vlmeta object. """ - def __init__(self, tstore: "TreeStore", inner_vlmeta): + def __init__(self, tstore: TreeStore, inner_vlmeta): self._tstore = tstore self._inner = inner_vlmeta @@ -224,7 +226,7 @@ def _validate_key(self, key: str) -> str: return key - def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: + def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: """Add a node with hierarchical key validation. Parameters @@ -266,7 +268,7 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk) -> None: full_key = self._translate_key_to_full(key) super().__setitem__(full_key, value) - def __getitem__(self, key: str) -> "NDArray | C2Array | SChunk | TreeStore": + def __getitem__(self, key: str) -> NDArray | C2Array | SChunk | blosc2.VLArray | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -280,7 +282,7 @@ def __getitem__(self, key: str) -> "NDArray | C2Array | SChunk | TreeStore": Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. @@ -416,7 +418,7 @@ def __iter__(self) -> Iterator[str]: """Iterate over keys, excluding vlmeta keys.""" return iter(self.keys()) - def items(self) -> Iterator[tuple[str, "NDArray | C2Array | SChunk | TreeStore"]]: + def items(self) -> Iterator[tuple[str, NDArray | C2Array | SChunk | TreeStore]]: """Return key-value pairs in the current subtree view.""" for key in self.keys(): yield key, self[key] @@ -575,7 +577,7 @@ def walk(self, path: str = "/", topdown: bool = True) -> Iterator[tuple[str, lis # Yield current level after children (post-order) yield path, children_dirs, leaf_nodes - def get_subtree(self, path: str) -> "TreeStore": + def get_subtree(self, path: str) -> TreeStore: """Create a subtree view with the specified path as root. Parameters diff --git a/tests/test_dict_store.py b/tests/test_dict_store.py index f18b006d..74122424 100644 --- a/tests/test_dict_store.py +++ b/tests/test_dict_store.py @@ -223,6 +223,49 @@ def test_external_schunk_file_and_reopen(): os.remove(path) +def test_store_and_retrieve_vlarray_in_dict(tmp_path): + path = tmp_path / "test_dstore_vlarray_embed.b2z" + values = [{"name": "alpha", "count": 1}, None, ("tuple", 2), [1, "two", b"three"]] + + vlarray = blosc2.VLArray() + vlarray.extend(values) + + with DictStore(str(path), mode="w") as dstore: + dstore["/vlarray"] = vlarray + value = dstore["/vlarray"] + assert isinstance(value, blosc2.VLArray) + assert list(value) == values + + with DictStore(str(path), mode="r") as dstore_read: + value = dstore_read["/vlarray"] + assert isinstance(value, blosc2.VLArray) + assert list(value) == values + + +def test_external_vlarray_file_and_reopen(tmp_path): + ext_path = tmp_path / "ext_vlarray.b2frame" + path = tmp_path / "test_dstore_vlarray_external.b2z" + values = ["alpha", {"nested": True}, None, (1, 2, 3)] + + vlarray = blosc2.VLArray(urlpath=str(ext_path), mode="w", contiguous=True) + vlarray.extend(values) + vlarray.vlmeta["description"] = "External VLArray" + + with DictStore(str(path), mode="w", threshold=None) as dstore: + dstore["/dir1/vlarray_ext"] = vlarray + assert "/dir1/vlarray_ext" in dstore.map_tree + assert dstore.map_tree["/dir1/vlarray_ext"].endswith(".b2f") + + with zipfile.ZipFile(path, "r") as zf: + assert "dir1/vlarray_ext.b2f" in zf.namelist() + + with DictStore(str(path), mode="r") as dstore_read: + value = dstore_read["/dir1/vlarray_ext"] + assert isinstance(value, blosc2.VLArray) + assert list(value) == values + assert value.vlmeta["description"] == "External VLArray" + + def _digest_value(value): """Return a bytes digest of a stored value.""" if isinstance(value, blosc2.SChunk): diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index 49e26d71..5da45f64 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -604,6 +604,56 @@ def test_schunk_support(): os.remove("test_schunk.b2z") +def test_vlarray_support(): + """Test that TreeStore supports embedded VLArray objects.""" + values = [{"name": "alpha", "count": 1}, None, ("tuple", 2), [1, "two", b"three"]] + with TreeStore("test_vlarray.b2z", mode="w") as tstore: + vlarray = blosc2.VLArray() + vlarray.extend(values) + tstore["/data/vlarray1"] = vlarray + + retrieved = tstore["/data/vlarray1"] + assert isinstance(retrieved, blosc2.VLArray) + assert list(retrieved) == values + + data_subtree = tstore["/data"] + assert isinstance(data_subtree, TreeStore) + assert set(data_subtree.keys()) == {"/vlarray1"} + + with TreeStore("test_vlarray.b2z", mode="r") as tstore: + retrieved = tstore["/data/vlarray1"] + assert isinstance(retrieved, blosc2.VLArray) + assert list(retrieved) == values + + os.remove("test_vlarray.b2z") + + +def test_external_vlarray_support(): + """Test that TreeStore supports external VLArray objects.""" + ext_path = "ext_vlarray.b2frame" + values = ["alpha", {"nested": True}, None, (1, 2, 3)] + if os.path.exists(ext_path): + os.remove(ext_path) + + vlarray = blosc2.VLArray(urlpath=ext_path, mode="w", contiguous=True) + vlarray.extend(values) + vlarray.vlmeta["description"] = "External VLArray for TreeStore" + + with TreeStore("test_vlarray_external.b2z", mode="w", threshold=None) as tstore: + tstore["/data/vlarray_ext"] = vlarray + assert "/data/vlarray_ext" in tstore + + with TreeStore("test_vlarray_external.b2z", mode="r") as tstore: + retrieved = tstore["/data/vlarray_ext"] + assert isinstance(retrieved, blosc2.VLArray) + assert list(retrieved) == values + assert retrieved.vlmeta["description"] == "External VLArray for TreeStore" + + if os.path.exists(ext_path): + os.remove(ext_path) + os.remove("test_vlarray_external.b2z") + + def test_walk_topdown_argument_ordering(): """Ensure walk supports topdown argument mimicking os.walk order semantics.""" with TreeStore("test_walk_topdown.b2z", mode="w") as tstore: From 3d031966dd922ae62e96d1b9cf017aaf8f0daaaa Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sun, 15 Mar 2026 08:48:20 +0100 Subject: [PATCH 14/69] Add test for empty tuples too --- tests/test_vlarray.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index 84692d24..3a25fa78 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -262,6 +262,13 @@ def test_vlarray_empty_list_roundtrip(): assert list(vlarray) == values +def test_vlarray_empty_tuple_roundtrip(): + values = [(), {"a": ()}, [(), ("nested",)], None, ("tuple", ()), {"rows": [[], ()]}] + vlarray = blosc2.VLArray() + vlarray.extend(values) + assert list(vlarray) == values + + def test_vlarray_insert_delete_errors(): vlarray = blosc2.VLArray() vlarray.append("value") From d1bc0f43fd113bfd809360b68fa318c1cb8f1c10 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 16 Mar 2026 17:48:00 +0100 Subject: [PATCH 15/69] Initial implementation for BatchArray --- CMakeLists.txt | 2 +- src/blosc2/__init__.py | 9 + src/blosc2/batch_array.py | 431 ++++++++++++++++++++++++++++++++++++++ src/blosc2/blosc2_ext.pyx | 127 +++++++++++ src/blosc2/core.py | 12 +- src/blosc2/dict_store.py | 24 ++- src/blosc2/embed_store.py | 14 +- src/blosc2/schunk.py | 6 + src/blosc2/tree_store.py | 10 +- tests/test_batch_array.py | 369 ++++++++++++++++++++++++++++++++ 10 files changed, 981 insertions(+), 23 deletions(-) create mode 100644 src/blosc2/batch_array.py create mode 100644 tests/test_batch_array.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b748c794..ed72f8a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 25197eb96d05318c939b3252a6b373ccd6ae49fe # variable-length chunks support in schunks + GIT_TAG 6bed0534d61652cb1e62a3e7be7283f333dfaaf7 # variable-length chunks support in schunks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index e0e8f9ac..66455881 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -37,6 +37,11 @@ from .version import __array_api_version__, __version__ +_PACKAGE_DIR = str(Path(__file__).resolve().parent) +if _PACKAGE_DIR in __path__: + __path__.remove(_PACKAGE_DIR) +__path__.insert(0, _PACKAGE_DIR) + def _configure_libtcc_runtime_path(): """Best-effort configuration so miniexpr can find bundled libtcc at runtime.""" @@ -530,6 +535,7 @@ def _raise(exc): from .embed_store import EmbedStore, estore_from_cframe from .dict_store import DictStore from .tree_store import TreeStore +from .batch_array import Batch, BatchArray, batcharray_from_cframe from .vlarray import VLArray, vlarray_from_cframe from .c2array import c2context, C2Array, URLPath @@ -714,6 +720,8 @@ def _raise(exc): # Classes "C2Array", "CParams", + "Batch", + "BatchArray", # Enums "Codec", "DParams", @@ -936,6 +944,7 @@ def _raise(exc): "validate_expr", "var", "vecdot", + "batcharray_from_cframe", "vlarray_from_cframe", "where", "zeros", diff --git a/src/blosc2/batch_array.py b/src/blosc2/batch_array.py new file mode 100644 index 00000000..0376d398 --- /dev/null +++ b/src/blosc2/batch_array.py @@ -0,0 +1,431 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import copy +import pathlib +from collections.abc import Iterator, Sequence +from dataclasses import asdict +from typing import Any + +import blosc2 +from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb + +_BATCHARRAY_META = {"version": 1, "serializer": "msgpack", "format": "vlblocks"} + + +def _check_serialized_size(buffer: bytes) -> None: + if len(buffer) > blosc2.MAX_BUFFERSIZE: + raise ValueError(f"Serialized objects cannot be larger than {blosc2.MAX_BUFFERSIZE} bytes") + + +class Batch(Sequence[Any]): + """A lazy sequence of Python objects stored in one BatchArray chunk.""" + + def __init__(self, parent: BatchArray, nchunk: int, lazychunk: bytes) -> None: + self._parent = parent + self._nchunk = nchunk + self._lazychunk = lazychunk + self._payloads: list[bytes] | None = None + self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazychunk) + + def _normalize_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("Batch indices must be integers") + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError("Batch index out of range") + return index + + def _decode_payloads(self) -> list[bytes]: + if self._payloads is None: + self._payloads = self._parent._decode_payloads(self._nchunk) + return self._payloads + + def __getitem__(self, index: int | slice) -> Any | list[Any]: + payloads = self._decode_payloads() + if isinstance(index, slice): + return [msgpack_unpackb(payload) for payload in payloads[index]] + index = self._normalize_index(index) + return msgpack_unpackb(payloads[index]) + + def __len__(self) -> int: + return self._nblocks + + def __iter__(self) -> Iterator[Any]: + for i in range(len(self)): + yield self[i] + + @property + def lazychunk(self) -> bytes: + return self._lazychunk + + @property + def nbytes(self) -> int: + return self._nbytes + + @property + def cbytes(self) -> int: + return self._cbytes + + @property + def cratio(self) -> float: + return self._nbytes / self._cbytes + + def __repr__(self) -> str: + return f"Batch(len={len(self)}, nbytes={self.nbytes}, cbytes={self.cbytes})" + + +class BatchArray: + """A batched variable-length array backed by an :class:`blosc2.SChunk`.""" + + @staticmethod + def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: + if cparams is None: + cparams = blosc2.CParams() + elif isinstance(cparams, blosc2.CParams): + cparams = copy.deepcopy(cparams) + else: + cparams = dict(cparams) + + if isinstance(cparams, blosc2.CParams): + cparams.typesize = 1 + else: + cparams["typesize"] = 1 + return cparams + + @staticmethod + def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any]) -> blosc2.Storage: + if storage is not None: + storage_keys = set(blosc2.Storage.__annotations__) + storage_kwargs = storage_keys.intersection(kwargs) + if storage_kwargs: + unexpected = ", ".join(sorted(storage_kwargs)) + raise AttributeError( + f"Cannot pass both `storage` and other kwargs already included in Storage: {unexpected}" + ) + if isinstance(storage, blosc2.Storage): + return copy.deepcopy(storage) + return blosc2.Storage(**storage) + + storage_kwargs = { + name: kwargs.pop(name) for name in list(blosc2.Storage.__annotations__) if name in kwargs + } + return blosc2.Storage(**storage_kwargs) + + @staticmethod + def _validate_storage(storage: blosc2.Storage) -> None: + if storage.mmap_mode not in (None, "r"): + raise ValueError("For BatchArray containers, mmap_mode must be None or 'r'") + if storage.mmap_mode == "r" and storage.mode != "r": + raise ValueError("For BatchArray containers, mmap_mode='r' requires mode='r'") + + def _attach_schunk(self, schunk: blosc2.SChunk) -> None: + self.schunk = schunk + self.urlpath = schunk.urlpath + self.mode = schunk.mode + self.mmap_mode = getattr(schunk, "mmap_mode", None) + self._validate_tag() + + def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: + urlpath = storage.urlpath + if urlpath is None or storage.mode not in ("r", "a") or not pathlib.Path(urlpath).exists(): + return False + + schunk = blosc2.blosc2_ext.open(urlpath, mode=storage.mode, offset=0, mmap_mode=storage.mmap_mode) + self._attach_schunk(schunk) + return True + + def _make_storage(self) -> blosc2.Storage: + meta = {name: self.meta[name] for name in self.meta} + return blosc2.Storage( + contiguous=self.schunk.contiguous, + urlpath=self.urlpath, + mode=self.mode, + mmap_mode=self.mmap_mode, + meta=meta, + ) + + def __init__( + self, + chunksize: int | None = None, + _from_schunk: blosc2.SChunk | None = None, + **kwargs: Any, + ) -> None: + if _from_schunk is not None: + if chunksize is not None: + raise ValueError("Cannot pass `chunksize` together with `_from_schunk`") + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise ValueError(f"Cannot pass {unexpected} together with `_from_schunk`") + self._attach_schunk(_from_schunk) + return + + cparams = kwargs.pop("cparams", None) + dparams = kwargs.pop("dparams", None) + storage = kwargs.pop("storage", None) + storage = self._coerce_storage(storage, kwargs) + + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise ValueError(f"Unsupported BatchArray keyword argument(s): {unexpected}") + + self._validate_storage(storage) + cparams = self._set_typesize_one(cparams) + + if dparams is None: + dparams = blosc2.DParams() + + if self._maybe_open_existing(storage): + return + + fixed_meta = dict(storage.meta or {}) + fixed_meta["batcharray"] = dict(_BATCHARRAY_META) + storage.meta = fixed_meta + if chunksize is None: + chunksize = -1 + schunk = blosc2.SChunk( + chunksize=chunksize, data=None, cparams=cparams, dparams=dparams, storage=storage + ) + self._attach_schunk(schunk) + + def _validate_tag(self) -> None: + if "batcharray" not in self.schunk.meta: + raise ValueError("The supplied SChunk is not tagged as a BatchArray") + + def _check_writable(self) -> None: + if self.mode == "r": + raise ValueError("Cannot modify a BatchArray opened in read-only mode") + + def _normalize_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("BatchArray indices must be integers") + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError("BatchArray index out of range") + return index + + def _normalize_insert_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("BatchArray indices must be integers") + if index < 0: + index += len(self) + if index < 0: + return 0 + if index > len(self): + return len(self) + return index + + def _slice_indices(self, index: slice) -> list[int]: + return list(range(*index.indices(len(self)))) + + def _copy_meta(self) -> dict[str, Any]: + return {name: self.meta[name] for name in self.meta} + + def _normalize_batch(self, value: object) -> list[Any]: + if isinstance(value, (str, bytes, bytearray, memoryview)): + raise TypeError("BatchArray entries must be sequences of Python objects") + if not isinstance(value, Sequence): + raise TypeError("BatchArray entries must be sequences of Python objects") + values = list(value) + if len(values) == 0: + raise ValueError("BatchArray entries cannot be empty") + return values + + def _serialize_batch(self, value: object) -> list[bytes]: + payloads = [] + for item in self._normalize_batch(value): + payload = msgpack_packb(item) + _check_serialized_size(payload) + payloads.append(payload) + return payloads + + def _vl_cparams_kwargs(self) -> dict[str, Any]: + return asdict(self.schunk.cparams) + + def _vl_dparams_kwargs(self) -> dict[str, Any]: + return asdict(self.schunk.dparams) + + def _compress_batch(self, payloads: list[bytes]) -> bytes: + return blosc2.blosc2_ext.vlcompress(payloads, **self._vl_cparams_kwargs()) + + def _decode_payloads(self, nchunk: int) -> list[bytes]: + return blosc2.blosc2_ext.vldecompress(self.schunk.get_chunk(nchunk), **self._vl_dparams_kwargs()) + + def _get_batch(self, index: int) -> Batch: + return Batch(self, index, self.schunk.get_lazychunk(index)) + + def append(self, value: object) -> int: + """Append one batch and return the new number of entries.""" + self._check_writable() + chunk = self._compress_batch(self._serialize_batch(value)) + return self.schunk.append_chunk(chunk) + + def insert(self, index: int, value: object) -> int: + """Insert one batch at ``index`` and return the new number of entries.""" + self._check_writable() + index = self._normalize_insert_index(index) + chunk = self._compress_batch(self._serialize_batch(value)) + return self.schunk.insert_chunk(index, chunk) + + def delete(self, index: int | slice) -> int: + """Delete the batch at ``index`` and return the new number of entries.""" + self._check_writable() + if isinstance(index, slice): + for idx in reversed(self._slice_indices(index)): + self.schunk.delete_chunk(idx) + return len(self) + index = self._normalize_index(index) + return self.schunk.delete_chunk(index) + + def pop(self, index: int = -1) -> list[Any]: + """Remove and return the batch at ``index``.""" + self._check_writable() + if isinstance(index, slice): + raise NotImplementedError("Slicing is not supported for BatchArray") + index = self._normalize_index(index) + value = self[index][:] + self.schunk.delete_chunk(index) + return value + + def extend(self, values: object) -> None: + """Append all batches from an iterable.""" + self._check_writable() + for value in values: + chunk = self._compress_batch(self._serialize_batch(value)) + self.schunk.append_chunk(chunk) + + def clear(self) -> None: + """Remove all entries from the container.""" + self._check_writable() + storage = self._make_storage() + if storage.urlpath is not None: + blosc2.remove_urlpath(storage.urlpath) + schunk = blosc2.SChunk( + chunksize=-1, + data=None, + cparams=copy.deepcopy(self.cparams), + dparams=copy.deepcopy(self.dparams), + storage=storage, + ) + self._attach_schunk(schunk) + + def __getitem__(self, index: int | slice) -> Batch | list[Batch]: + if isinstance(index, slice): + return [self[i] for i in self._slice_indices(index)] + index = self._normalize_index(index) + return self._get_batch(index) + + def __setitem__(self, index: int | slice, value: object) -> None: + if isinstance(index, slice): + self._check_writable() + indices = self._slice_indices(index) + values = list(value) + step = 1 if index.step is None else index.step + if step == 1: + start = self._normalize_insert_index(0 if index.start is None else index.start) + for idx in reversed(indices): + self.schunk.delete_chunk(idx) + for offset, item in enumerate(values): + chunk = self._compress_batch(self._serialize_batch(item)) + self.schunk.insert_chunk(start + offset, chunk) + return + if len(values) != len(indices): + raise ValueError( + f"attempt to assign sequence of size {len(values)} to extended slice of size {len(indices)}" + ) + for idx, item in zip(indices, values, strict=True): + chunk = self._compress_batch(self._serialize_batch(item)) + self.schunk.update_chunk(idx, chunk) + return + self._check_writable() + index = self._normalize_index(index) + chunk = self._compress_batch(self._serialize_batch(value)) + self.schunk.update_chunk(index, chunk) + + def __delitem__(self, index: int | slice) -> None: + self.delete(index) + + def __len__(self) -> int: + return self.schunk.nchunks + + def __iter__(self) -> Iterator[Batch]: + for i in range(len(self)): + yield self[i] + + @property + def meta(self): + return self.schunk.meta + + @property + def vlmeta(self): + return self.schunk.vlmeta + + @property + def cparams(self): + return self.schunk.cparams + + @property + def dparams(self): + return self.schunk.dparams + + @property + def chunksize(self) -> int: + return self.schunk.chunksize + + @property + def nbytes(self) -> int: + return self.schunk.nbytes + + @property + def cbytes(self) -> int: + return self.schunk.cbytes + + @property + def cratio(self) -> float: + return self.schunk.cratio + + def to_cframe(self) -> bytes: + return self.schunk.to_cframe() + + def copy(self, **kwargs: Any) -> BatchArray: + """Create a copy of the container with optional constructor overrides.""" + if "meta" in kwargs: + raise ValueError("meta should not be passed to copy") + + kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) + kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) + kwargs["chunksize"] = kwargs.get("chunksize", -1) + + if "storage" not in kwargs: + kwargs["meta"] = self._copy_meta() + kwargs["contiguous"] = kwargs.get("contiguous", self.schunk.contiguous) + if "urlpath" in kwargs and "mode" not in kwargs: + kwargs["mode"] = "w" + + out = BatchArray(**kwargs) + out.extend(self) + return out + + def __enter__(self) -> BatchArray: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + return False + + def __repr__(self) -> str: + return f"BatchArray(len={len(self)}, urlpath={self.urlpath!r})" + + +def batcharray_from_cframe(cframe: bytes, copy: bool = True) -> BatchArray: + """Deserialize a CFrame buffer into a :class:`BatchArray`.""" + + schunk = blosc2.schunk_from_cframe(cframe, copy=copy) + return BatchArray(_from_schunk=schunk) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 604be0bd..edd41837 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -279,9 +279,17 @@ cdef extern from "blosc2.h": blosc2_context * context, const void * src, int32_t srcsize, void * dest, int32_t destsize) nogil + int blosc2_vlcompress_ctx( + blosc2_context * context, const void * const * srcs, const int32_t * srcsizes, + int32_t nblocks, void * dest, int32_t destsize) nogil + int blosc2_decompress_ctx(blosc2_context * context, const void * src, int32_t srcsize, void * dest, int32_t destsize) nogil + int blosc2_vldecompress_ctx(blosc2_context* context, const void* src, + int32_t srcsize, void** dests, + int32_t* destsizes, int32_t maxblocks) + int blosc2_getitem_ctx(blosc2_context* context, const void* src, int32_t srcsize, int start, int nitems, void* dest, int32_t destsize) nogil @@ -1095,6 +1103,7 @@ def compress2(src, **kwargs): return dest[:size] cdef create_dparams_from_kwargs(blosc2_dparams *dparams, kwargs, blosc2_cparams* cparams=NULL): + memcpy(dparams, &BLOSC2_DPARAMS_DEFAULTS, sizeof(BLOSC2_DPARAMS_DEFAULTS)) dparams.nthreads = kwargs.get('nthreads', blosc2.nthreads) dparams.schunk = NULL dparams.postfilter = NULL @@ -1154,6 +1163,124 @@ def decompress2(src, dst=None, **kwargs): raise ValueError("Error while decompressing, check the src data and/or the dparams") +def vlcompress(srcs, **kwargs): + cdef blosc2_cparams cparams + create_cparams_from_kwargs(&cparams, kwargs) + + cdef Py_ssize_t nblocks = len(srcs) + if nblocks <= 0: + raise ValueError("At least one block is required") + + cdef blosc2_context *cctx = NULL + cdef Py_buffer *buffers = calloc(nblocks, sizeof(Py_buffer)) + cdef const void **src_ptrs = malloc(nblocks * sizeof(void *)) + cdef int32_t *srcsizes = malloc(nblocks * sizeof(int32_t)) + cdef Py_ssize_t acquired = 0 + cdef Py_ssize_t i + cdef int64_t total_nbytes = 0 + cdef int32_t len_dest + cdef int size + cdef Py_ssize_t release_i + cdef void *_dest + if buffers == NULL or src_ptrs == NULL or srcsizes == NULL: + free(buffers) + free(src_ptrs) + free(srcsizes) + raise MemoryError() + + try: + for i in range(nblocks): + PyObject_GetBuffer(srcs[i], &buffers[i], PyBUF_SIMPLE) + acquired += 1 + if buffers[i].len <= 0: + raise ValueError("Each VL block must have at least one byte") + src_ptrs[i] = buffers[i].buf + srcsizes[i] = buffers[i].len + total_nbytes += buffers[i].len + + # VL blocks can carry enough per-block framing that the simple + # total_nbytes + global_overhead estimate is too small for many tiny + # buffers. Budget one max-overhead chunk per block as a conservative + # upper bound for the temporary destination. + len_dest = (total_nbytes + BLOSC2_MAX_OVERHEAD * (nblocks + 1) + 64) + dest = PyBytes_FromStringAndSize(NULL, len_dest) + if dest is None: + raise MemoryError() + _dest = dest + cctx = blosc2_create_cctx(cparams) + if cctx == NULL: + raise RuntimeError("Could not create the compression context") + if RELEASEGIL: + with nogil: + size = blosc2_vlcompress_ctx(cctx, src_ptrs, srcsizes, nblocks, _dest, len_dest) + else: + size = blosc2_vlcompress_ctx(cctx, src_ptrs, srcsizes, nblocks, _dest, len_dest) + finally: + if cctx != NULL: + blosc2_free_ctx(cctx) + for release_i in range(acquired): + PyBuffer_Release(&buffers[release_i]) + free(buffers) + free(src_ptrs) + free(srcsizes) + + if size < 0: + raise RuntimeError("Could not compress the data") + elif size == 0: + del dest + raise RuntimeError("The result could not fit ") + return dest[:size] + + +def vldecompress(src, **kwargs): + cdef blosc2_dparams dparams + create_dparams_from_kwargs(&dparams, kwargs) + + cdef blosc2_context *dctx = blosc2_create_dctx(dparams) + if dctx == NULL: + raise RuntimeError("Could not create decompression context") + + cdef const uint8_t[:] typed_view_src + mem_view_src = memoryview(src) + typed_view_src = mem_view_src.cast('B') + _check_comp_length('src', typed_view_src.nbytes) + cdef int32_t nbytes + cdef int32_t cbytes + cdef int32_t nblocks + blosc2_cbuffer_sizes(&typed_view_src[0], &nbytes, &cbytes, &nblocks) + if nblocks <= 0: + blosc2_free_ctx(dctx) + raise ValueError("Chunk does not contain VL blocks") + + cdef void **dests = calloc(nblocks, sizeof(void *)) + cdef int32_t *destsizes = malloc(nblocks * sizeof(int32_t)) + cdef int32_t rc + cdef int32_t i + cdef list out = [] + if dests == NULL or destsizes == NULL: + blosc2_free_ctx(dctx) + free(dests) + free(destsizes) + raise MemoryError() + + try: + rc = blosc2_vldecompress_ctx(dctx, &typed_view_src[0], cbytes, dests, destsizes, nblocks) + if rc < 0: + raise RuntimeError("Could not decompress the data") + for i in range(rc): + out.append(PyBytes_FromStringAndSize(dests[i], destsizes[i])) + free(dests[i]) + dests[i] = NULL + return out + finally: + for i in range(nblocks): + if dests[i] != NULL: + free(dests[i]) + free(dests) + free(destsizes) + blosc2_free_ctx(dctx) + + cdef create_storage(blosc2_storage *storage, kwargs): contiguous = kwargs.get('contiguous', blosc2.storage_dflts['contiguous']) storage.contiguous = contiguous diff --git a/src/blosc2/core.py b/src/blosc2/core.py index 5526a7f2..fc8749ad 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1918,9 +1918,9 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True -) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.VLArray: - """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk ` - or :ref:`VLArray ` instance +) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.BatchArray | blosc2.VLArray: + """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, + :ref:`BatchArray ` or :ref:`VLArray ` instance from a contiguous frame buffer. Parameters @@ -1937,8 +1937,8 @@ def from_cframe( Returns ------- - out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk ` - or :ref:`VLArray ` + out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, + :ref:`BatchArray ` or :ref:`VLArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1952,6 +1952,8 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) + if "batcharray" in schunk.meta: + return blosc2.batcharray_from_cframe(cframe, copy=copy) if "vlarray" in schunk.meta: return blosc2.vlarray_from_cframe(cframe, copy=copy) if "b2nd" in schunk.meta: diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 1cb6dd3c..65bab76e 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -249,24 +249,26 @@ def estore(self) -> EmbedStore: return self._estore @staticmethod - def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray) -> int: - if isinstance(value, blosc2.VLArray): + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> int: + if isinstance(value, (blosc2.VLArray, blosc2.BatchArray)): return value.schunk.nbytes return value.nbytes @staticmethod - def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray) -> bool: - return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray)) and bool( + def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> bool: + return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.BatchArray)) and bool( getattr(value, "urlpath", None) ) @staticmethod - def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray) -> str: + def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" return ".b2f" - def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: + def __setitem__( + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + ) -> None: """Add a node to the DictStore.""" if isinstance(value, np.ndarray): value = blosc2.asarray(value, cparams=self.cparams, dparams=self.dparams) @@ -292,7 +294,7 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) - if hasattr(value, "save"): value.save(urlpath=dest_path) else: - # SChunk and VLArray can both be persisted via their cframe. + # SChunk, VLArray and BatchArray can all be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -310,7 +312,9 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) - value = blosc2.from_cframe(value.to_cframe()) self._estore[key] = value - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | C2Array: + def __getitem__( + self, key: str + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: @@ -340,7 +344,9 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | C2 # Fall back to EmbedStore return self._estore[key] - def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | blosc2.VLArray | C2Array | Any: + def get( + self, key: str, default: Any = None + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index b03d892e..e1c8b0d2 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -173,7 +173,9 @@ def _ensure_capacity(self, needed_bytes: int) -> None: new_size = max(required_size, int(self._store.shape[0] * 1.5)) self._store.resize((new_size,)) - def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: + def __setitem__( + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + ) -> None: """Add a node to the embed store.""" if self.mode == "r": raise ValueError("Cannot set items in read-only mode.") @@ -196,7 +198,7 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) - self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -212,7 +214,9 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray: # Use from_cframe so we can deserialize either an NDArray or an SChunk return blosc2.from_cframe(serialized_data, copy=True) - def get(self, key: str, default: Any = None) -> blosc2.NDArray | SChunk | blosc2.VLArray | Any: + def get( + self, key: str, default: Any = None + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -239,12 +243,12 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray]]: + def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 5421ae41..1acfc043 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -1621,6 +1621,11 @@ def _process_opened_object(res): return VLArray(_from_schunk=getattr(res, "schunk", res)) + if "batcharray" in meta: + from blosc2.batch_array import BatchArray + + return BatchArray(_from_schunk=getattr(res, "schunk", res)) + if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta: return blosc2._open_lazyarray(res) else: @@ -1632,6 +1637,7 @@ def open( ) -> ( blosc2.SChunk | blosc2.NDArray + | blosc2.BatchArray | blosc2.VLArray | blosc2.C2Array | blosc2.LazyArray diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index a96c11a4..82b8b3de 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -226,7 +226,9 @@ def _validate_key(self, key: str) -> str: return key - def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) -> None: + def __setitem__( + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + ) -> None: """Add a node with hierarchical key validation. Parameters @@ -268,7 +270,9 @@ def __setitem__(self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray) - full_key = self._translate_key_to_full(key) super().__setitem__(full_key, value) - def __getitem__(self, key: str) -> NDArray | C2Array | SChunk | blosc2.VLArray | TreeStore: + def __getitem__( + self, key: str + ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.BatchArray | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -282,7 +286,7 @@ def __getitem__(self, key: str) -> NDArray | C2Array | SChunk | blosc2.VLArray | Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.BatchArray or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. diff --git a/tests/test_batch_array.py b/tests/test_batch_array.py new file mode 100644 index 00000000..9ddd498e --- /dev/null +++ b/tests/test_batch_array.py @@ -0,0 +1,369 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import pytest + +import blosc2 +from blosc2._msgpack_utils import msgpack_packb + +BATCHES = [ + [b"bytes\x00payload", "plain text", 42], + [{"nested": [1, 2]}, None], + [(1, 2, "three"), 3.5, True, {"rows": [[], ["nested"]]}], +] + + +def _make_payload(seed, size): + base = bytes((seed + i) % 251 for i in range(251)) + reps = size // len(base) + 1 + return (base * reps)[:size] + + +def _storage(contiguous, urlpath, mode="w"): + return blosc2.Storage(contiguous=contiguous, urlpath=urlpath, mode=mode) + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_batcharray.b2frame"), + (False, "test_batcharray_s.b2frame"), + ], +) +def test_batcharray_roundtrip(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) + assert barray.meta["batcharray"]["serializer"] == "msgpack" + + for i, batch in enumerate(BATCHES, start=1): + assert barray.append(batch) == i + + assert len(barray) == len(BATCHES) + assert [batch[:] for batch in barray] == BATCHES + + batch0 = barray[0] + assert isinstance(batch0, blosc2.Batch) + assert len(batch0) == len(BATCHES[0]) + assert batch0[1] == BATCHES[0][1] + assert batch0[:] == BATCHES[0] + assert isinstance(batch0.lazychunk, bytes) + assert batch0.nbytes > 0 + assert batch0.cbytes > 0 + assert batch0.cratio > 0 + + expected = list(BATCHES) + expected[1] = ["updated", {"tuple": (7, 8)}] + expected[-1] = ["tiny"] + barray[1] = expected[1] + barray[-1] = expected[-1] + assert barray.insert(0, ["head", 0]) == len(expected) + 1 + expected.insert(0, ["head", 0]) + assert barray.insert(-1, ["between", {"k": 5}]) == len(expected) + 1 + expected.insert(-1, ["between", {"k": 5}]) + assert barray.insert(999, ["tail"]) == len(expected) + 1 + expected.insert(999, ["tail"]) + assert barray.delete(2) == len(expected) - 1 + del expected[2] + del barray[-2] + del expected[-2] + assert [batch[:] for batch in barray] == expected + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert isinstance(reopened, blosc2.BatchArray) + assert [batch[:] for batch in reopened] == expected + with pytest.raises(ValueError): + reopened.append(["nope"]) + with pytest.raises(ValueError): + reopened[0] = ["nope"] + with pytest.raises(ValueError): + reopened.insert(0, ["nope"]) + with pytest.raises(ValueError): + reopened.delete(0) + with pytest.raises(ValueError): + del reopened[0] + with pytest.raises(ValueError): + reopened.extend([["nope"]]) + with pytest.raises(ValueError): + reopened.pop() + with pytest.raises(ValueError): + reopened.clear() + + reopened_rw = blosc2.open(urlpath, mode="a") + reopened_rw[0] = ["changed"] + expected[0] = ["changed"] + assert [batch[:] for batch in reopened_rw] == expected + + if contiguous: + reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") + assert isinstance(reopened_mmap, blosc2.BatchArray) + assert [batch[:] for batch in reopened_mmap] == expected + + blosc2.remove_urlpath(urlpath) + + +def test_batcharray_from_cframe(): + barray = blosc2.BatchArray() + barray.extend(BATCHES) + barray.insert(1, ["inserted", True]) + del barray[3] + expected = list(BATCHES) + expected.insert(1, ["inserted", True]) + del expected[3] + + restored = blosc2.from_cframe(barray.to_cframe()) + assert isinstance(restored, blosc2.BatchArray) + assert [batch[:] for batch in restored] == expected + + restored2 = blosc2.batcharray_from_cframe(barray.to_cframe()) + assert isinstance(restored2, blosc2.BatchArray) + assert [batch[:] for batch in restored2] == expected + + +def test_vlcompress_small_blocks_roundtrip(): + values = [ + {"value": None}, + {"value": []}, + {"value": []}, + {"value": ["en:salt"]}, + {"value": []}, + {"value": ["en:sugar", "en:flour"]}, + {"value": None}, + {"value": []}, + {"value": ["en:water", "en:yeast", "en:oil"]}, + {"value": []}, + {"value": []}, + {"value": ["en:acid", "en:color", "en:preservative", "en:spice"]}, + {"value": None}, + {"value": []}, + {"value": ["en:a", "en:b", "en:c", "en:d", "en:e", "en:f"]}, + {"value": []}, + {"value": []}, + {"value": None}, + {"value": ["en:x"]}, + {"value": []}, + ] + payloads = [msgpack_packb(value) for value in values] + + chunk = blosc2.blosc2_ext.vlcompress( + payloads, + codec=blosc2.Codec.ZSTD, + clevel=5, + typesize=1, + nthreads=1, + ) + out = blosc2.blosc2_ext.vldecompress(chunk, nthreads=1) + + assert out == payloads + + +def test_batcharray_constructor_kwargs(): + urlpath = "test_batcharray_kwargs.b2frame" + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchArray(urlpath=urlpath, mode="w", contiguous=True) + barray.extend(BATCHES) + + reopened = blosc2.BatchArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + assert [batch[:] for batch in reopened] == BATCHES + + blosc2.remove_urlpath(urlpath) + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_batcharray_list_ops.b2frame"), + (False, "test_batcharray_list_ops_s.b2frame"), + ], +) +def test_batcharray_list_like_ops(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) + barray.extend([[1, 2], [3], [4, 5, 6]]) + assert [batch[:] for batch in barray] == [[1, 2], [3], [4, 5, 6]] + assert barray.pop() == [4, 5, 6] + assert barray.pop(0) == [1, 2] + assert [batch[:] for batch in barray] == [[3]] + + barray.clear() + assert len(barray) == 0 + assert [batch[:] for batch in barray] == [] + + barray.extend([["a"], ["b", "c"]]) + assert [batch[:] for batch in barray] == [["a"], ["b", "c"]] + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert [batch[:] for batch in reopened] == [["a"], ["b", "c"]] + + blosc2.remove_urlpath(urlpath) + + +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_batcharray_slices.b2frame"), + (False, "test_batcharray_slices_s.b2frame"), + ], +) +def test_batcharray_slices(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + expected = [[i, i + 100] for i in range(8)] + barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) + barray.extend(expected) + + assert [batch[:] for batch in barray[1:6:2]] == expected[1:6:2] + assert [batch[:] for batch in barray[::-2]] == expected[::-2] + + barray[2:5] = [["a"], ["b", "c"]] + expected[2:5] = [["a"], ["b", "c"]] + assert [batch[:] for batch in barray] == expected + + barray[1:6:2] = [[100], [101], [102]] + expected[1:6:2] = [[100], [101], [102]] + assert [batch[:] for batch in barray] == expected + + del barray[::3] + del expected[::3] + assert [batch[:] for batch in barray] == expected + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert [batch[:] for batch in reopened[::2]] == expected[::2] + with pytest.raises(ValueError): + reopened[1:3] = [[9]] + with pytest.raises(ValueError): + del reopened[::2] + + blosc2.remove_urlpath(urlpath) + + +def test_batcharray_slice_errors(): + barray = blosc2.BatchArray() + barray.extend([[0], [1], [2], [3]]) + + with pytest.raises(ValueError, match="extended slice"): + barray[::2] = [[9]] + with pytest.raises(TypeError): + barray[1:2] = 3 + with pytest.raises(ValueError): + _ = barray[::0] + + +def test_batcharray_copy(): + urlpath = "test_batcharray_copy.b2frame" + copy_path = "test_batcharray_copy_out.b2frame" + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + original = blosc2.BatchArray(urlpath=urlpath, mode="w", contiguous=True) + original.extend(BATCHES) + original.insert(1, ["copy", True]) + + copied = original.copy( + urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5} + ) + assert [batch[:] for batch in copied] == [batch[:] for batch in original] + assert copied.urlpath == copy_path + assert copied.schunk.contiguous is False + assert copied.cparams.codec == blosc2.Codec.LZ4 + assert copied.cparams.clevel == 5 + + inmem = original.copy() + assert [batch[:] for batch in inmem] == [batch[:] for batch in original] + assert inmem.urlpath is None + + with pytest.raises(ValueError, match="meta should not be passed to copy"): + original.copy(meta={}) + + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + +@pytest.mark.parametrize(("contiguous", "nthreads"), [(False, 2), (True, 4)]) +def test_batcharray_multithreaded_inner_vl(contiguous, nthreads): + batches = [] + for batch_id in range(24): + batch = [] + for obj_id, size in enumerate( + (13, 1024 + batch_id * 17, 70_000 + batch_id * 13, 250_000 + batch_id * 101) + ): + batch.append( + { + "batch": batch_id, + "obj": obj_id, + "size": size, + "payload": _make_payload(batch_id + obj_id, size), + } + ) + batches.append(batch) + + barray = blosc2.BatchArray( + storage=blosc2.Storage(contiguous=contiguous), + cparams=blosc2.CParams(typesize=1, nthreads=nthreads, codec=blosc2.Codec.ZSTD, clevel=5), + dparams=blosc2.DParams(nthreads=nthreads), + ) + barray.extend(batches) + + assert [batch[:] for batch in barray] == batches + assert [barray[i][:] for i in range(len(barray))] == batches + + +def test_batcharray_validation_errors(): + barray = blosc2.BatchArray() + + with pytest.raises(TypeError): + barray.append("value") + with pytest.raises(ValueError): + barray.append([]) + with pytest.raises(TypeError): + barray.insert("0", ["bad"]) + with pytest.raises(IndexError): + barray.delete(3) + with pytest.raises(IndexError): + blosc2.BatchArray().pop() + barray.extend([[1]]) + with pytest.raises(NotImplementedError): + barray.pop(slice(0, 1)) + + +def test_batcharray_in_embed_store(): + estore = blosc2.EmbedStore() + barray = blosc2.BatchArray() + barray.extend(BATCHES) + + estore["/batch"] = barray + restored = estore["/batch"] + assert isinstance(restored, blosc2.BatchArray) + assert [batch[:] for batch in restored] == BATCHES + + +def test_batcharray_in_dict_store(): + path = "test_batcharray_store.b2z" + blosc2.remove_urlpath(path) + + with blosc2.DictStore(path, mode="w", threshold=1) as dstore: + barray = blosc2.BatchArray() + barray.extend(BATCHES) + dstore["/batch"] = barray + + with blosc2.DictStore(path, mode="r") as dstore: + restored = dstore["/batch"] + assert isinstance(restored, blosc2.BatchArray) + assert [batch[:] for batch in restored] == BATCHES + + blosc2.remove_urlpath(path) From 5de386ce9b60dcf46b3ffa1e80d3c52574bd7d6f Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Mon, 16 Mar 2026 22:59:00 +0100 Subject: [PATCH 16/69] Adding ND support --- src/blosc2/blosc2_ext.pyx | 171 +++++++++++++++++++++++++++----------- src/blosc2/linalg.py | 37 +++++++-- 2 files changed, 150 insertions(+), 58 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 0f470805..2843bbcf 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -674,6 +674,13 @@ ctypedef struct me_udata: int64_t blocks_in_chunk[B2ND_MAX_DIM] me_expr* miniexpr_handle +ctypedef struct mm_udata: + b2nd_array_t** inputs + b2nd_array_t* array + int64_t chunks_strides[3][B2ND_MAX_DIM] + int64_t blocks_strides[3][B2ND_MAX_DIM] + int64_t el_strides[3][B2ND_MAX_DIM] + MAX_TYPESIZE = BLOSC2_MAXTYPESIZE MAX_BUFFERSIZE = BLOSC2_MAX_BUFFERSIZE MAX_BLOCKSIZE = BLOSC2_MAXBLOCKSIZE @@ -2161,12 +2168,12 @@ cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: C[rowC + c] += (a * B[rowB + c]) return 0 -cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: +cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: # Declare all C variables at the beginning cdef b2nd_array_t* out_arr cdef b2nd_array_t* ndarr cdef c_bool first_run - cdef int rc, M, K, N + cdef int rc, p, q, r cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) cdef int32_t chunk_nbytes[2] @@ -2175,42 +2182,56 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int blocknitems[2] cdef int startA, startB, expected_blocknitems cdef blosc2_context* dctx - cdef int base, i, j, nchunkA, nchunkB, nblockA, nblockB, chunk_startA, chunk_startB, block_base, block_i, block_j, block_startA, block_startB, idx, chunk_idx, block_ncols, block_nrows, nblocks_per_2d - + cdef int i, j, block_i, block_j, ncols, block_ncols, Bblock_ncols, Bncols + cdef int nchunkA = 0, nchunkB = 0, nblockA = 0, nblockB = 0, offsetA = 0, offsetB = 0, offset = 0 out_arr = udata.array cdef int ndim = out_arr.ndim - cdef int ncols = udata.chunks_in_array[ndim - 1] - cdef int nrows = udata.chunks_in_array[ndim - 2] - cdef int nchunks_per_2d = ncols * nrows - - block_ncols = udata.blocks_in_chunk[ndim - 1] - block_nrows = udata.blocks_in_chunk[ndim - 2] - nblocks_per_2d = block_ncols * block_nrows - - # nchunk = base * nchunks_per2d + i * ncols + j - base = nchunk // nchunks_per_2d - i = (nchunk % nchunks_per_2d) // ncols - j = nchunk % ncols - nchunkA = chunk_startA = nchunk - j - nchunkB = chunk_startB = nchunk - i * ncols - - # nblock = block_base * nblocks_per_2d + block_i * block_ncols + block_j - block_base = nblock // nblocks_per_2d - block_i = (nblock % nblocks_per_2d) // block_ncols - block_j = nblock % block_ncols - block_startA = nblock - block_j - block_startB = nblock - block_i * block_ncols + cdef int nchunk_ = nchunk + cdef int coord, batch, batch_, batches = 1 + for i in range(ndim - 2): + batches *= out_arr.shape[i] + + # nchunk = sum(strides[i]*chunkcoords[i]) + for i in range(ndim - 2): + coord = nchunk_ // udata.chunks_strides[0][i] + nchunk_ = nchunk_ % udata.chunks_strides[0][i] + nchunkA += coord * udata.chunks_strides[1][i] + nchunkB += coord * udata.chunks_strides[2][i] + + ncols = udata.chunks_strides[0][ndim - 2] + Bncols = udata.chunks_strides[2][ndim - 2] + + i = nchunk_ // ncols # ncols * i + j + j = nchunk_ % ncols + nchunkA = chunk_startA = nchunkA + i * ncols + nchunkB = chunk_startB = nchunkB + j + + # nblock = sum(strides[i]*blockcoords[i]) + cdef int nblock_ = nblock + for i in range(ndim - 2): + coord = nblock_ // udata.blocks_strides[0][i] + nblock_ = nblock_ % udata.blocks_strides[0][i] + nblockA += coord * udata.blocks_strides[1][i] + nblockB += coord * udata.blocks_strides[2][i] + + block_ncols = udata.blocks_strides[0][ndim - 2] + Bblock_ncols = udata.blocks_strides[2][ndim - 2] + + block_i = nblock_ // block_ncols + block_j = nblock_ % block_ncols + block_startA = nblockA = nblockA + i * block_ncols + block_startB = nblockB = nblockB + j + + # batches = sum(strides[i]*elcoords[i]) dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True while True: # chunk loop - printf("chunks: %i, %i\n", nchunkA, nchunkB) - nblockA = block_startA - nblockB = block_startB for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB ndarr = udata.inputs[i] + ndim = ndarr.ndim src[i] = ndarr.sc.data[chunk_idx] rc = blosc2_cbuffer_sizes(src[i], &chunk_nbytes[i], &chunk_cbytes[i], &block_nbytes[i]) if rc < 0: @@ -2219,10 +2240,10 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: invalid block size") if first_run: if i == 0: - K = ndarr.blockshape[ndim - 1] - M = ndarr.blockshape[ndim - 2] + q = ndarr.blockshape[ndim - 1] + p = ndarr.blockshape[ndim - 2] else: # i = 1 - N = ndarr.blockshape[ndim - 1] + r = ndarr.blockshape[ndim - 1] input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") @@ -2233,8 +2254,9 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: inconsistent block element counts across inputs") first_run = False + nblockA = block_startA + nblockB = block_startB while True: # block loop - printf("blocks: %i, %i\n", nblockA, nblockB) startA = nblockA * blocknitems[0] startB = nblockB * blocknitems[1] rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], @@ -2245,28 +2267,37 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param input_buffers[1], block_nbytes[1]) if rc < 0: raise ValueError("matmul: error decompressing the B chunk") - if typecode == 0: - if typesize == 4: - rc = matmul_block_kernel[float](input_buffers[0], input_buffers[1], params_output, M, K, N) - else: - rc = matmul_block_kernel[double](input_buffers[0], input_buffers[1], params_output, M, K, N) - elif typecode == 1: - if typesize == 4: - rc = matmul_block_kernel[int32_t](input_buffers[0], input_buffers[1], params_output, M, K, N) + batch = 0 + while batch < batches: + batch_ = batch + for i in range(ndim - 2): + coord = batch // udata.el_strides[0][i] + batch_ = batch_ % udata.el_strides[0][i] + offsetA += coord * udata.el_strides[1][i] + offsetB += coord * udata.el_strides[2][i] + offset += coord * udata.el_strides[0][i] + if typecode == 0: + if typesize == 4: + rc = matmul_block_kernel[float](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + else: + rc = matmul_block_kernel[double](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + elif typecode == 1: + if typesize == 4: + rc = matmul_block_kernel[int32_t](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + else: + rc = matmul_block_kernel[int64_t](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) else: - rc = matmul_block_kernel[int64_t](input_buffers[0], input_buffers[1], params_output, M, K, N) - else: - with gil: - raise ValueError("Unsupported dtype") + with gil: + raise ValueError("Unsupported dtype") + batch += 1 nblockA += 1 - nblockB += block_ncols + nblockB += Bblock_ncols if (nblockA % block_ncols == 0): break nchunkA += 1 - nchunkB += ncols + nchunkB += Bncols if (nchunkA % ncols == 0): break - printf("finished block %i for chunk %i\n", nblock, nchunk) blosc2_free_ctx(dctx) @@ -2358,7 +2389,7 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): cdef int matmul_prefilter(blosc2_prefilter_params *params): cdef int typecode - cdef me_udata* udata = params.user_data + cdef mm_udata* udata = params.user_data cdef b2nd_array_t* out_arr = udata.array cdef char dtype_kind = out_arr.dtype[1] if dtype_kind == 'f': @@ -3215,6 +3246,48 @@ cdef class NDArray: return udata + cdef mm_udata *_fill_mm_udata(self, inputs): + cdef mm_udata *udata = malloc(sizeof(mm_udata)) + cdef int cstrides, bstrides, estrides + cdef b2nd_array_t* inp + cdef b2nd_array_t** inputs_ = malloc(2 * sizeof(b2nd_array_t*)) + for i in range(2): + operand = inputs['x1'] if i == 0 else inputs['x2'] + inputs_[i] = operand.c_array + inputs_[i].chunk_cache.nchunk = -1 + inputs_[i].chunk_cache.data = NULL + udata.inputs = inputs_ + udata.array = self.array + + # Save these in udf_udata to avoid computing them for each block + for i in range(3): + udata.chunks_strides[i][self.array.ndim - 1] = 1 + udata.blocks_strides[i][self.array.ndim - 1] = 1 + udata.el_strides[i][self.array.ndim - 1] = 1 + for idx in range(2, self.array.ndim + 1): + i = self.array.ndim - idx + udata.chunks_strides[0][i] = udata.chunks_strides[0][i + 1] * udata.array.extshape[i + 1] // udata.array.chunkshape[i + 1] + udata.blocks_strides[0][i] = udata.blocks_strides[0][i + 1] * udata.array.extchunkshape[i + 1] // udata.array.blockshape[i + 1] + + for j in range(2): + inp = inputs_[j] + cstrides = bstrides = estrides = 1 + for idx in range(2, self.array.ndim + 1): + i = inp.ndim - idx + if inp.shape[i + 1] == 1 or i < 0: + udata.chunks_strides[j][i] = 0 + udata.blocks_strides[j][i] = 0 + udata.el_strides[j][i] = 0 + else: + bstrides *= inp.extchunkshape[i + 1] // inp.blockshape[i + 1] + cstrides *= inp.extshape[i + 1] // inp.chunkshape[i + 1] + estrides *= inp.blockshape[i + 1] + udata.chunks_strides[j][i] = cstrides + udata.blocks_strides[j][i] = bstrides + udata.el_strides[j][i] = estrides + + return udata + def _set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): # Set prefilter for miniexpr cdef blosc2_cparams* cparams = self.array.sc.storage.cparams @@ -3305,7 +3378,7 @@ cdef class NDArray: cdef blosc2_cparams* cparams = self.array.sc.storage.cparams cparams.prefilter = matmul_prefilter - cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, aux_reduc=None) + cdef mm_udata* udata = self._fill_mm_udata(inputs) cdef b2nd_array_t* out_arr = udata.array cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) preparams.user_data = udata diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 3b258ea7..dc0b64b3 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -113,21 +113,40 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) # multithreaded matmul - # TODO: handle a) type promotion, b) non-square blocks, c) and >2D + # TODO: handle a) type promotion, b) padding, c) (improved) >2D ops = (x1, x2, result) - shape, chunks, blocks = result.shape, result.chunks, result.blocks + blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) use_miniexpr = True if all_ndarray: - # can maybe relax this to just have A.blocks[-1] == B.blocks[-2] - # Require aligned NDArray operands with identical chunk/block grid, and square matrices/chunks/blocks - same_shape = all(op.shape[-1] == op.shape[-2] and op.shape == shape for op in ops) - same_chunks = all(op.shape[-1] == op.shape[-2] and op.chunks == chunks for op in ops) - same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) - if not (same_shape and same_chunks and same_blocks): - use_miniexpr = False if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False + + # TODO: In fact the following can be relaxed too, just need to load across block boundaries + # Might want to restrict loading across chunk boundaries, in which case would require: + # x1.chunks[-2] % result.blocks[-2] == 0 + # x2.chunks[-1] % result.blocks[-1] == 0 + # x2.chunks[-2] % x1.blocks[-1] == 0 + # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] + # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] + + # Require that blocks are matmul compatible and broadcastable directly to result + # (M, K) x (K, N) = (M, N) + # so can load block-by-block for inputs and calculate block of output + # Also need to avoid loading across chunk boundaries + chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + same_blocks = x2.blocks[-2] == x1.blocks[-1] + same_blocks &= x2.blocks[-1] == result.blocks[-1] + same_blocks &= result.blocks[-2] == x1.blocks[-2] + try: + result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + except ValueError: + use_miniexpr = False + if not (same_blocks and chunks_aligned and result_blocks[:-2] == blocks[:-2]): + use_miniexpr = False + else: use_miniexpr = False From 37837d9f611c4c9d97669225e25296e21428dcb2 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 07:02:33 +0100 Subject: [PATCH 17/69] Add .info to BatchArray and VLArray; fancier .info for others too --- src/blosc2/batch_array.py | 46 +++++++++++++++++++++++++- src/blosc2/c2array.py | 6 ++-- src/blosc2/info.py | 16 +++++++++ src/blosc2/ndarray.py | 6 ++-- src/blosc2/schunk.py | 6 ++-- src/blosc2/vlarray.py | 62 ++++++++++++++++++++++++++++++++++- tests/ndarray/test_ndarray.py | 12 +++++++ tests/test_batch_array.py | 28 ++++++++++++++++ tests/test_schunk.py | 13 ++++++++ tests/test_vlarray.py | 27 +++++++++++++++ 10 files changed, 211 insertions(+), 11 deletions(-) diff --git a/src/blosc2/batch_array.py b/src/blosc2/batch_array.py index 0376d398..5dc8cb52 100644 --- a/src/blosc2/batch_array.py +++ b/src/blosc2/batch_array.py @@ -15,6 +15,7 @@ import blosc2 from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb +from blosc2.info import InfoReporter, format_nbytes_info _BATCHARRAY_META = {"version": 1, "serializer": "msgpack", "format": "vlblocks"} @@ -128,7 +129,6 @@ def _validate_storage(storage: blosc2.Storage) -> None: def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.schunk = schunk - self.urlpath = schunk.urlpath self.mode = schunk.mode self.mmap_mode = getattr(schunk, "mmap_mode", None) self._validate_tag() @@ -262,6 +262,13 @@ def _decode_payloads(self, nchunk: int) -> list[bytes]: def _get_batch(self, index: int) -> Batch: return Batch(self, index, self.schunk.get_lazychunk(index)) + def _batch_lengths(self) -> list[int]: + lengths = [] + for i in range(len(self)): + _, _, nblocks = blosc2.get_cbuffer_sizes(self.schunk.get_lazychunk(i)) + lengths.append(nblocks) + return lengths + def append(self, value: object) -> int: """Append one batch and return the new number of entries.""" self._check_writable() @@ -380,6 +387,10 @@ def dparams(self): def chunksize(self) -> int: return self.schunk.chunksize + @property + def typesize(self) -> int: + return self.schunk.typesize + @property def nbytes(self) -> int: return self.schunk.nbytes @@ -392,6 +403,39 @@ def cbytes(self) -> int: def cratio(self) -> float: return self.schunk.cratio + @property + def urlpath(self) -> str | None: + return self.schunk.urlpath + + @property + def contiguous(self) -> bool: + return self.schunk.contiguous + + @property + def info(self) -> InfoReporter: + """Print information about this BatchArray.""" + return InfoReporter(self) + + @property + def info_items(self) -> list: + """A list of tuples with summary information about this BatchArray.""" + batch_lengths = self._batch_lengths() + nitems = sum(batch_lengths) + avg_batch_len = nitems / len(batch_lengths) if batch_lengths else 0.0 + return [ + ("type", f"{self.__class__.__name__}"), + ("nbatches", len(self)), + ("nitems", nitems), + ("batch_len_min", min(batch_lengths) if batch_lengths else 0), + ("batch_len_max", max(batch_lengths) if batch_lengths else 0), + ("batch_len_avg", f"{avg_batch_len:.2f}"), + ("nbytes", format_nbytes_info(self.nbytes)), + ("cbytes", format_nbytes_info(self.cbytes)), + ("cratio", f"{self.cratio:.2f}"), + ("cparams", self.cparams), + ("dparams", self.dparams), + ] + def to_cframe(self) -> bytes: return self.schunk.to_cframe() diff --git a/src/blosc2/c2array.py b/src/blosc2/c2array.py index e8556ba4..11f7f6cb 100644 --- a/src/blosc2/c2array.py +++ b/src/blosc2/c2array.py @@ -18,7 +18,7 @@ import requests import blosc2 -from blosc2.info import InfoReporter +from blosc2.info import InfoReporter, format_nbytes_info _subscriber_data = { "urlbase": os.environ.get("BLOSC_C2URLBASE"), @@ -424,8 +424,8 @@ def info_items(self) -> list: items += [("chunks", self.chunks)] items += [("blocks", self.blocks)] items += [("dtype", self.dtype)] - items += [("nbytes", self.nbytes)] - items += [("cbytes", self.cbytes)] + items += [("nbytes", format_nbytes_info(self.nbytes))] + items += [("cbytes", format_nbytes_info(self.cbytes))] items += [("cratio", f"{self.cratio:.2f}")] items += [("cparams", self.cparams)] # items += [("dparams", self.dparams)] diff --git a/src/blosc2/info.py b/src/blosc2/info.py index 4ac629da..ef1e3011 100644 --- a/src/blosc2/info.py +++ b/src/blosc2/info.py @@ -10,6 +10,22 @@ from textwrap import TextWrapper +def format_nbytes_human(nbytes: int) -> str: + units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB") + value = float(nbytes) + for unit in units: + if value < 1024.0 or unit == units[-1]: + if unit == "B": + return f"{nbytes} B" + return f"{value:.2f} {unit}" + value /= 1024.0 + return None + + +def format_nbytes_info(nbytes: int) -> str: + return f"{nbytes} ({format_nbytes_human(nbytes)})" + + def info_text_report_(items: list) -> str: with io.StringIO() as buf: print(items, file=buf) diff --git a/src/blosc2/ndarray.py b/src/blosc2/ndarray.py index bc396622..4c35cef6 100644 --- a/src/blosc2/ndarray.py +++ b/src/blosc2/ndarray.py @@ -29,7 +29,7 @@ import blosc2 from blosc2 import SpecialValue, blosc2_ext, compute_chunks_blocks -from blosc2.info import InfoReporter +from blosc2.info import InfoReporter, format_nbytes_info from blosc2.schunk import SChunk from .linalg import matmul @@ -3838,8 +3838,8 @@ def info_items(self) -> list: items += [("chunks", self.chunks)] items += [("blocks", self.blocks)] items += [("dtype", self.dtype)] - items += [("nbytes", self.nbytes)] - items += [("cbytes", self.cbytes)] + items += [("nbytes", format_nbytes_info(self.nbytes))] + items += [("cbytes", format_nbytes_info(self.cbytes))] items += [("cratio", f"{self.cratio:.2f}")] items += [("cparams", self.cparams)] items += [("dparams", self.dparams)] diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 1acfc043..a422928a 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -20,7 +20,7 @@ import blosc2 from blosc2 import SpecialValue, blosc2_ext from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb -from blosc2.info import InfoReporter +from blosc2.info import InfoReporter, format_nbytes_info class vlmeta(MutableMapping, blosc2_ext.vlmeta): @@ -491,8 +491,8 @@ def info_items(self) -> list: items += [("chunksize", self.chunksize)] items += [("blocksize", self.blocksize)] items += [("typesize", self.typesize)] - items += [("nbytes", self.nbytes)] - items += [("cbytes", self.cbytes)] + items += [("nbytes", format_nbytes_info(self.nbytes))] + items += [("cbytes", format_nbytes_info(self.cbytes))] items += [("cratio", f"{self.cratio:.2f}")] items += [("cparams", self.cparams)] items += [("dparams", self.dparams)] diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py index d7d885e2..7f2d6445 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/vlarray.py @@ -13,6 +13,7 @@ import blosc2 from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb +from blosc2.info import InfoReporter, format_nbytes_info if TYPE_CHECKING: from collections.abc import Iterator @@ -73,7 +74,6 @@ def _validate_storage(storage: blosc2.Storage) -> None: def _attach_schunk(self, schunk: SChunk) -> None: self.schunk = schunk - self.urlpath = schunk.urlpath self.mode = schunk.mode self.mmap_mode = getattr(schunk, "mmap_mode", None) self._validate_tag() @@ -174,6 +174,15 @@ def _slice_indices(self, index: slice) -> list[int]: def _copy_meta(self) -> dict[str, Any]: return {name: self.meta[name] for name in self.meta} + def _item_size_stats(self) -> tuple[list[int], list[int]]: + item_nbytes = [] + chunk_cbytes = [] + for i in range(len(self)): + nbytes, cbytes, _ = blosc2.get_cbuffer_sizes(self.schunk.get_lazychunk(i)) + item_nbytes.append(nbytes) + chunk_cbytes.append(cbytes) + return item_nbytes, chunk_cbytes + def _serialize(self, value: Any) -> bytes: payload = msgpack_packb(value) _check_serialized_size(payload) @@ -301,6 +310,57 @@ def dparams(self): def chunksize(self) -> int: return self.schunk.chunksize + @property + def typesize(self) -> int: + return self.schunk.typesize + + @property + def nbytes(self) -> int: + return self.schunk.nbytes + + @property + def cbytes(self) -> int: + return self.schunk.cbytes + + @property + def cratio(self) -> float: + return self.schunk.cratio + + @property + def urlpath(self) -> str | None: + return self.schunk.urlpath + + @property + def contiguous(self) -> bool: + return self.schunk.contiguous + + @property + def info(self) -> InfoReporter: + """Print information about this VLArray.""" + return InfoReporter(self) + + @property + def info_items(self) -> list: + """A list of tuples with summary information about this VLArray.""" + item_nbytes, chunk_cbytes = self._item_size_stats() + avg_item_nbytes = sum(item_nbytes) / len(item_nbytes) if item_nbytes else 0.0 + avg_chunk_cbytes = sum(chunk_cbytes) / len(chunk_cbytes) if chunk_cbytes else 0.0 + return [ + ("type", f"{self.__class__.__name__}"), + ("entries", len(self)), + ("item_nbytes_min", min(item_nbytes) if item_nbytes else 0), + ("item_nbytes_max", max(item_nbytes) if item_nbytes else 0), + ("item_nbytes_avg", f"{avg_item_nbytes:.2f}"), + ("chunk_cbytes_min", min(chunk_cbytes) if chunk_cbytes else 0), + ("chunk_cbytes_max", max(chunk_cbytes) if chunk_cbytes else 0), + ("chunk_cbytes_avg", f"{avg_chunk_cbytes:.2f}"), + ("nbytes", format_nbytes_info(self.nbytes)), + ("cbytes", format_nbytes_info(self.cbytes)), + ("cratio", f"{self.cratio:.2f}"), + ("cparams", self.cparams), + ("dparams", self.dparams), + ] + def to_cframe(self) -> bytes: return self.schunk.to_cframe() diff --git a/tests/ndarray/test_ndarray.py b/tests/ndarray/test_ndarray.py index 8c9b45f7..b557bb65 100644 --- a/tests/ndarray/test_ndarray.py +++ b/tests/ndarray/test_ndarray.py @@ -103,6 +103,18 @@ def test_asarray(a): np.testing.assert_allclose(a, b[:]) +def test_ndarray_info_has_human_sizes(): + array = blosc2.asarray(np.arange(16, dtype=np.int32)) + + items = dict(array.info_items) + assert "(" in items["nbytes"] + assert "(" in items["cbytes"] + + text = repr(array.info) + assert "nbytes" in text + assert "cbytes" in text + + @pytest.mark.parametrize( ("shape", "newshape", "chunks", "blocks"), [ diff --git a/tests/test_batch_array.py b/tests/test_batch_array.py index 9ddd498e..7d584a36 100644 --- a/tests/test_batch_array.py +++ b/tests/test_batch_array.py @@ -127,6 +127,34 @@ def test_batcharray_from_cframe(): assert [batch[:] for batch in restored2] == expected +def test_batcharray_info(): + barray = blosc2.BatchArray() + barray.extend(BATCHES) + + assert barray.typesize == 1 + assert barray.contiguous == barray.schunk.contiguous + assert barray.urlpath == barray.schunk.urlpath + + items = dict(barray.info_items) + assert items["type"] == "BatchArray" + assert items["nbatches"] == len(BATCHES) + assert items["nitems"] == sum(len(batch) for batch in BATCHES) + assert items["batch_len_min"] == 2 + assert items["batch_len_max"] == 4 + assert items["batch_len_avg"] == "3.00" + assert "urlpath" not in items + assert "contiguous" not in items + assert "typesize" not in items + assert "(" in items["nbytes"] + assert "(" in items["cbytes"] + assert "B)" in items["nbytes"] or "KiB)" in items["nbytes"] or "MiB)" in items["nbytes"] + + text = repr(barray.info) + assert "type" in text + assert "BatchArray" in text + assert "batch_len_avg" in text + + def test_vlcompress_small_blocks_roundtrip(): values = [ {"value": None}, diff --git a/tests/test_schunk.py b/tests/test_schunk.py index 539e495f..db7c087e 100644 --- a/tests/test_schunk.py +++ b/tests/test_schunk.py @@ -186,6 +186,19 @@ def test_schunk(contiguous, urlpath, mode, mmap_mode, nbytes, cparams, dparams, blosc2.remove_urlpath(urlpath) +def test_schunk_info_has_human_sizes(): + schunk = blosc2.SChunk(chunksize=32) + schunk.append_data(b"a" * 32) + + items = dict(schunk.info_items) + assert "(" in items["nbytes"] + assert "(" in items["cbytes"] + + text = repr(schunk.info) + assert "nbytes" in text + assert "cbytes" in text + + @pytest.mark.parametrize( ("urlpath", "contiguous", "mode", "mmap_mode"), [ diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index 3a25fa78..f9834f45 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -117,6 +117,33 @@ def test_vlarray_from_cframe(): assert list(restored2) == expected +def test_vlarray_info(): + vlarray = blosc2.VLArray() + vlarray.extend(VALUES) + + assert vlarray.typesize == 1 + assert vlarray.contiguous == vlarray.schunk.contiguous + assert vlarray.urlpath == vlarray.schunk.urlpath + + items = dict(vlarray.info_items) + assert items["type"] == "VLArray" + assert items["entries"] == len(VALUES) + assert items["item_nbytes_min"] > 0 + assert items["item_nbytes_max"] >= items["item_nbytes_min"] + assert items["chunk_cbytes_min"] > 0 + assert items["chunk_cbytes_max"] >= items["chunk_cbytes_min"] + assert "urlpath" not in items + assert "contiguous" not in items + assert "typesize" not in items + assert "(" in items["nbytes"] + assert "(" in items["cbytes"] + + text = repr(vlarray.info) + assert "type" in text + assert "VLArray" in text + assert "item_nbytes_avg" in text + + def test_vlarray_constructor_kwargs(): urlpath = "test_vlarray_kwargs.b2frame" blosc2.remove_urlpath(urlpath) From f3ef3619a0bfb7a8a0fcc48cd41b196400a7e22a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 07:04:33 +0100 Subject: [PATCH 18/69] Update to latest c-blosc2 in vlblocks branch --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ed72f8a5..7b70ce5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 6bed0534d61652cb1e62a3e7be7283f333dfaaf7 # variable-length chunks support in schunks + GIT_TAG b6c9357e76913d484918fa3e3fdec61df7510aa9 # variable-length chunks support in schunks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 65ea44b64591599e901f74145ce5046970be79f4 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 08:08:16 +0100 Subject: [PATCH 19/69] Update to latest c-blosc2 in vlblocks branch --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b70ce5e..7b5aa350 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG b6c9357e76913d484918fa3e3fdec61df7510aa9 # variable-length chunks support in schunks + GIT_TAG 98fa458177be2755aac62f46573dc102baa26739 # variable-length chunks support in schunks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 4172c3fa3d7e4b4f8eec6c69a9a6754148c1c52a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 08:11:45 +0100 Subject: [PATCH 20/69] Enable dicts by default in BatchArray and VLArray when using zstd --- src/blosc2/batch_array.py | 10 ++++++++++ src/blosc2/vlarray.py | 10 ++++++++++ tests/test_batch_array.py | 22 ++++++++++++++++++++++ tests/test_vlarray.py | 22 ++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/blosc2/batch_array.py b/src/blosc2/batch_array.py index 5dc8cb52..108428e5 100644 --- a/src/blosc2/batch_array.py +++ b/src/blosc2/batch_array.py @@ -88,17 +88,27 @@ class BatchArray: @staticmethod def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: + auto_use_dict = cparams is None if cparams is None: cparams = blosc2.CParams() elif isinstance(cparams, blosc2.CParams): cparams = copy.deepcopy(cparams) else: cparams = dict(cparams) + auto_use_dict = "use_dict" not in cparams if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 + if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: + # BatchArray stores many small serialized payloads, where Zstd dicts help materially. + cparams.use_dict = True else: cparams["typesize"] = 1 + codec = cparams.get("codec", blosc2.Codec.ZSTD) + clevel = cparams.get("clevel", 5) + if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: + # BatchArray stores many small serialized payloads, where Zstd dicts help materially. + cparams["use_dict"] = True return cparams @staticmethod diff --git a/src/blosc2/vlarray.py b/src/blosc2/vlarray.py index 7f2d6445..18c737a6 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/vlarray.py @@ -33,17 +33,27 @@ class VLArray: @staticmethod def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: + auto_use_dict = cparams is None if cparams is None: cparams = blosc2.CParams() elif isinstance(cparams, blosc2.CParams): cparams = copy.deepcopy(cparams) else: cparams = dict(cparams) + auto_use_dict = "use_dict" not in cparams if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 + if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: + # VLArray stores many small serialized payloads, where Zstd dicts help materially. + cparams.use_dict = True else: cparams["typesize"] = 1 + codec = cparams.get("codec", blosc2.Codec.ZSTD) + clevel = cparams.get("clevel", 5) + if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: + # VLArray stores many small serialized payloads, where Zstd dicts help materially. + cparams["use_dict"] = True return cparams @staticmethod diff --git a/tests/test_batch_array.py b/tests/test_batch_array.py index 7d584a36..48f5fce0 100644 --- a/tests/test_batch_array.py +++ b/tests/test_batch_array.py @@ -155,6 +155,28 @@ def test_batcharray_info(): assert "batch_len_avg" in text +def test_batcharray_zstd_uses_dict_by_default(): + barray = blosc2.BatchArray() + assert barray.cparams.codec == blosc2.Codec.ZSTD + assert barray.cparams.use_dict is True + + +def test_batcharray_respects_explicit_use_dict_and_non_zstd(): + barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) + assert barray.cparams.codec == blosc2.Codec.LZ4 + assert barray.cparams.use_dict is False + + barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + assert barray.cparams.codec == blosc2.Codec.ZSTD + assert barray.cparams.use_dict is False + + barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + assert barray.cparams.use_dict is False + + barray = blosc2.BatchArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + assert barray.cparams.use_dict is False + + def test_vlcompress_small_blocks_roundtrip(): values = [ {"value": None}, diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index f9834f45..2c792e10 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -144,6 +144,28 @@ def test_vlarray_info(): assert "item_nbytes_avg" in text +def test_vlarray_zstd_uses_dict_by_default(): + vlarray = blosc2.VLArray() + assert vlarray.cparams.codec == blosc2.Codec.ZSTD + assert vlarray.cparams.use_dict is True + + +def test_vlarray_respects_explicit_use_dict_and_non_zstd(): + vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) + assert vlarray.cparams.codec == blosc2.Codec.LZ4 + assert vlarray.cparams.use_dict is False + + vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + assert vlarray.cparams.codec == blosc2.Codec.ZSTD + assert vlarray.cparams.use_dict is False + + vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + assert vlarray.cparams.use_dict is False + + vlarray = blosc2.VLArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + assert vlarray.cparams.use_dict is False + + def test_vlarray_constructor_kwargs(): urlpath = "test_vlarray_kwargs.b2frame" blosc2.remove_urlpath(urlpath) From e799739c8b5cd199ce585d6461b998a67f479aa2 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 11:30:21 +0100 Subject: [PATCH 21/69] Update to latest c-blosc2 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b5aa350..db897448 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 98fa458177be2755aac62f46573dc102baa26739 # variable-length chunks support in schunks + GIT_TAG 11144a703a85c2b224e8b77fc1769a2a46881cc0 # variable-length chunks/blocks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From e640474548976dd44611be36757bf8088579dc94 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 13:18:05 +0100 Subject: [PATCH 22/69] Update to latest c-blosc2 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index db897448..98584fe5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 11144a703a85c2b224e8b77fc1769a2a46881cc0 # variable-length chunks/blocks + GIT_TAG 7f6f1204784404099c767371245bd12cd4570c7c # variable-length chunks/blocks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 07ec9d2abc04f533afa01aa250423d8d1a666c61 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 17 Mar 2026 13:52:52 +0100 Subject: [PATCH 23/69] Update the nthreads to the default value in this machine --- src/blosc2/blosc2_ext.pyx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index edd41837..4c574015 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -8,6 +8,7 @@ #cython: language_level=3 import os +import dataclasses import ast import atexit import pathlib @@ -2571,7 +2572,8 @@ def open(urlpath, mode, offset, **kwargs): if mode != "w" and kwargs is not None: check_schunk_params(schunk, kwargs) cparams = kwargs.get("cparams") - # For reading with the default number of threads + # nthreads is not stored in the frame; apply the live global when the caller + # did not supply an explicit cparams — symmetric with the DParams default below. dparams = kwargs.get("dparams", blosc2.DParams()) if is_ndarray: @@ -2579,6 +2581,8 @@ def open(urlpath, mode, offset, **kwargs): _array=PyCapsule_New(array, "b2nd_array_t*", NULL)) if cparams is not None: res.schunk.cparams = cparams if isinstance(cparams, blosc2.CParams) else blosc2.CParams(**cparams) + else: + res.schunk.cparams = dataclasses.replace(res.schunk.cparams, nthreads=blosc2.nthreads) if dparams is not None: res.schunk.dparams = dparams if isinstance(dparams, blosc2.DParams) else blosc2.DParams(**dparams) res.schunk.mode = mode @@ -2587,6 +2591,8 @@ def open(urlpath, mode, offset, **kwargs): mode=mode, **kwargs) if cparams is not None: res.cparams = cparams if isinstance(cparams, blosc2.CParams) else blosc2.CParams(**cparams) + else: + res.cparams = dataclasses.replace(res.cparams, nthreads=blosc2.nthreads) if dparams is not None: res.dparams = dparams if isinstance(dparams, blosc2.DParams) else blosc2.DParams(**dparams) From a0cc22d95e6b0577d442257ee95842eb81a82269 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Tue, 17 Mar 2026 20:22:15 +0100 Subject: [PATCH 24/69] Multithreaded matmul supported for ND --- bench/ndarray/stringops_bench.py | 2 +- src/blosc2/blosc2_ext.pyx | 24 +++++++++++++++--------- src/blosc2/lazyexpr.py | 9 +-------- src/blosc2/linalg.py | 10 +++++++--- src/blosc2/utils.py | 9 +++++++++ 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/bench/ndarray/stringops_bench.py b/bench/ndarray/stringops_bench.py index c983a0f2..5f97aac3 100644 --- a/bench/ndarray/stringops_bench.py +++ b/bench/ndarray/stringops_bench.py @@ -12,7 +12,7 @@ import time import numpy as np import blosc2 -from blosc2.lazyexpr import _toggle_miniexpr +from blosc2.utils import _toggle_miniexpr # nparr = np.random.randint(low=0, high=128, size=(N, 10), dtype=np.uint32) # nparr = nparr.view('S40').astype('U10') diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 2843bbcf..322f4d2b 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2188,8 +2188,10 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int ndim = out_arr.ndim cdef int nchunk_ = nchunk cdef int coord, batch, batch_, batches = 1 + + # batches = sum(strides[i]*elcoords[i]) for i in range(ndim - 2): - batches *= out_arr.shape[i] + batches *= out_arr.blockshape[i] # nchunk = sum(strides[i]*chunkcoords[i]) for i in range(ndim - 2): @@ -2203,8 +2205,8 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param i = nchunk_ // ncols # ncols * i + j j = nchunk_ % ncols - nchunkA = chunk_startA = nchunkA + i * ncols - nchunkB = chunk_startB = nchunkB + j + chunk_startA = nchunkA + i * ncols + chunk_startB = nchunkB + j # nblock = sum(strides[i]*blockcoords[i]) cdef int nblock_ = nblock @@ -2219,14 +2221,14 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param block_i = nblock_ // block_ncols block_j = nblock_ % block_ncols - block_startA = nblockA = nblockA + i * block_ncols - block_startB = nblockB = nblockB + j + block_startA = nblockA + block_i * block_ncols + block_startB = nblockB + block_j - # batches = sum(strides[i]*elcoords[i]) dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True - + nchunkA = chunk_startA + nchunkB = chunk_startB while True: # chunk loop for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB @@ -2268,6 +2270,9 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param if rc < 0: raise ValueError("matmul: error decompressing the B chunk") batch = 0 + offsetA = 0 + offsetB = 0 + offset = 0 while batch < batches: batch_ = batch for i in range(ndim - 2): @@ -3268,9 +3273,10 @@ cdef class NDArray: i = self.array.ndim - idx udata.chunks_strides[0][i] = udata.chunks_strides[0][i + 1] * udata.array.extshape[i + 1] // udata.array.chunkshape[i + 1] udata.blocks_strides[0][i] = udata.blocks_strides[0][i + 1] * udata.array.extchunkshape[i + 1] // udata.array.blockshape[i + 1] + udata.el_strides[0][i] = udata.el_strides[0][i + 1] * udata.array.blockshape[i + 1] - for j in range(2): - inp = inputs_[j] + for j in range(1, 3): + inp = inputs_[j - 1] cstrides = bstrides = estrides = 1 for idx in range(2, self.array.ndim + 1): i = inp.ndim - idx diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 07a78247..15942b4e 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -68,6 +68,7 @@ process_key, reducers, safe_numpy_globals, + try_miniexpr, ) if not blosc2.IS_WASM: @@ -76,14 +77,6 @@ global safe_blosc2_globals safe_blosc2_globals = {} -# Set this to False if miniexpr should not be tried out -try_miniexpr = not blosc2.IS_WASM or getattr(blosc2, "_WASM_MINIEXPR_ENABLED", False) - - -def _toggle_miniexpr(FLAG): - global try_miniexpr - try_miniexpr = FLAG - def ne_evaluate(expression, local_dict=None, **kwargs): """Safely evaluate expressions using numexpr when possible, falling back to numpy.""" diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index dc0b64b3..c050bf11 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -17,7 +17,7 @@ import blosc2 -from .utils import get_intersecting_chunks, nptranspose, npvecdot, slice_to_chunktuple +from .utils import get_intersecting_chunks, nptranspose, npvecdot, slice_to_chunktuple, try_miniexpr if TYPE_CHECKING: from collections.abc import Sequence @@ -113,11 +113,14 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) # multithreaded matmul - # TODO: handle a) type promotion, b) padding, c) (improved) >2D + # TODO: handle a) type promotion, b) padding (explicitly), c) (improved) >2D ops = (x1, x2, result) blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) - use_miniexpr = True + global try_miniexpr + + # Use a local copy so we don't modify the global + use_miniexpr = try_miniexpr if all_ndarray: if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False @@ -165,6 +168,7 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra if prefilter_set: result.schunk.remove_prefilter("miniexpr") else: # couldn't do multithreading + print("multithreading failed :( ") if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s p, q = result.chunks[-2:] r = x2.chunks[-1] diff --git a/src/blosc2/utils.py b/src/blosc2/utils.py index 6a72f2ef..46fc40a1 100644 --- a/src/blosc2/utils.py +++ b/src/blosc2/utils.py @@ -19,6 +19,15 @@ import blosc2 +# Set this to False if miniexpr should not be tried out +try_miniexpr = not blosc2.IS_WASM or getattr(blosc2, "_WASM_MINIEXPR_ENABLED", False) + + +def _toggle_miniexpr(FLAG): + global try_miniexpr + try_miniexpr = FLAG + + # NumPy version and a convenient boolean flag NUMPY_GE_2_0 = np.__version__ >= "2.0" # handle different numpy versions From 3e68dde0722832c9db5778de4a8840813997a273 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Tue, 17 Mar 2026 20:38:26 +0100 Subject: [PATCH 25/69] Add benchmark --- bench/ndarray/multithreaded_matmul_bench.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 bench/ndarray/multithreaded_matmul_bench.py diff --git a/bench/ndarray/multithreaded_matmul_bench.py b/bench/ndarray/multithreaded_matmul_bench.py new file mode 100644 index 00000000..810527c6 --- /dev/null +++ b/bench/ndarray/multithreaded_matmul_bench.py @@ -0,0 +1,48 @@ +import blosc2 +import numpy as np +import time + +N = 10000 +ndim = 2 +ashape = (N,) * ndim +bshape = ashape +dtype = np.float64 + +achunks = (1000, 1000) +bchunks = (achunks[1], achunks[0]) +ablocks = (200, 200) +bblocks = (ablocks[1], ablocks[0]) +outblocks = (ablocks[0], bblocks[1]) +outchunks = (achunks[0], bchunks[1]) +# a = blosc2.linspace(0, 1, dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +# b = blosc2.linspace(0, 1, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +a = blosc2.ones(dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +b = blosc2.full(fill_value=2, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) + +a_np = a[:] +b_np = b[:] +tic = time.time() +np_res = np.matmul(a_np, b_np) +print(f'numpy finished in {time.time()-tic} s') + +tic = time.time() +b2_res = blosc2.matmul(a, b, blocks=outblocks, chunks=outchunks) +print(f'blosc2 multithreaded finished in {time.time()-tic} s') + +tic = time.time() +b2_res = blosc2.matmul(a, b) +print(f'blosc2 normal finished in {time.time()-tic} s') + +achunks = None #(1000, 1000) +bchunks = None #(achunks[1], achunks[0]) +ablocks = None #(200, 200) +bblocks = None #(ablocks[1], ablocks[0]) +outblocks = None #(ablocks[0], bblocks[1]) +outchunks = None #(achunks[0], bchunks[1]) +# a = blosc2.linspace(0, 1, dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +# b = blosc2.linspace(0, 1, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +a = blosc2.ones(dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +b = blosc2.full(fill_value=2, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +tic = time.time() +b2_res = blosc2.matmul(a, b, blocks=outblocks, chunks=outchunks) +print(f'blosc2 normal with default chunks etc. finished in {time.time()-tic} s') From 4fe44e8d18038ac61d452b7f53db0651e01eb44f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 13:41:40 +0100 Subject: [PATCH 26/69] New meanings for .chunksize and .blocksize properties --- CMakeLists.txt | 6 +- src/blosc2/batch_array.py | 183 +++++++++++++++++++++++++++++--------- tests/test_batch_array.py | 81 ++++++++++------- 3 files changed, 194 insertions(+), 76 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 98584fe5..6f053288 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,9 +118,9 @@ else() set(BLOSC_INSTALL ON) include(FetchContent) FetchContent_Declare(blosc2 - GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 7f6f1204784404099c767371245bd12cd4570c7c # variable-length chunks/blocks - # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 + #GIT_REPOSITORY https://github.com/Blosc/c-blosc2 + #GIT_TAG 7f6f1204784404099c767371245bd12cd4570c7c # variable-length chunks/blocks + SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) include_directories("${blosc2_SOURCE_DIR}/include") diff --git a/src/blosc2/batch_array.py b/src/blosc2/batch_array.py index 108428e5..2bd486ad 100644 --- a/src/blosc2/batch_array.py +++ b/src/blosc2/batch_array.py @@ -17,7 +17,8 @@ from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_BATCHARRAY_META = {"version": 1, "serializer": "msgpack", "format": "vlblocks"} +_BATCHARRAY_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} +_BATCHARRAY_LAYOUT_KEY = "batcharray" def _check_serialized_size(buffer: bytes) -> None: @@ -32,7 +33,7 @@ def __init__(self, parent: BatchArray, nchunk: int, lazychunk: bytes) -> None: self._parent = parent self._nchunk = nchunk self._lazychunk = lazychunk - self._payloads: list[bytes] | None = None + self._blocks: list[list[Any]] | None = None self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazychunk) def _normalize_index(self, index: int) -> int: @@ -44,20 +45,28 @@ def _normalize_index(self, index: int) -> int: raise IndexError("Batch index out of range") return index - def _decode_payloads(self) -> list[bytes]: - if self._payloads is None: - self._payloads = self._parent._decode_payloads(self._nchunk) - return self._payloads + def _decode_blocks(self) -> list[list[Any]]: + if self._blocks is None: + self._blocks = self._parent._decode_blocks(self._nchunk) + return self._blocks def __getitem__(self, index: int | slice) -> Any | list[Any]: - payloads = self._decode_payloads() + blocks = self._decode_blocks() if isinstance(index, slice): - return [msgpack_unpackb(payload) for payload in payloads[index]] + flat_items = [item for block in blocks for item in block] + return flat_items[index] index = self._normalize_index(index) - return msgpack_unpackb(payloads[index]) + blocksize = self._parent.blocksize + if blocksize is None: + raise RuntimeError("BatchArray blocksize is not initialized") + block_index, item_index = divmod(index, blocksize) + return blocks[block_index][item_index] def __len__(self) -> int: - return self._nblocks + chunksize = self._parent.chunksize + if chunksize is None: + return self._nblocks + return chunksize def __iter__(self) -> Iterator[Any]: for i in range(len(self)): @@ -142,6 +151,7 @@ def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.mode = schunk.mode self.mmap_mode = getattr(schunk, "mmap_mode", None) self._validate_tag() + self._load_layout() def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: urlpath = storage.urlpath @@ -165,18 +175,21 @@ def _make_storage(self) -> blosc2.Storage: def __init__( self, chunksize: int | None = None, + blocksize: int | None = None, _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: + self._chunksize: int | None = chunksize + self._blocksize: int | None = blocksize + self._layout_format: str | None = None if _from_schunk is not None: - if chunksize is not None: - raise ValueError("Cannot pass `chunksize` together with `_from_schunk`") + if chunksize is not None or blocksize is not None: + raise ValueError("Cannot pass `chunksize` or `blocksize` together with `_from_schunk`") if kwargs: unexpected = ", ".join(sorted(kwargs)) raise ValueError(f"Cannot pass {unexpected} together with `_from_schunk`") self._attach_schunk(_from_schunk) return - cparams = kwargs.pop("cparams", None) dparams = kwargs.pop("dparams", None) storage = kwargs.pop("storage", None) @@ -198,17 +211,48 @@ def __init__( fixed_meta = dict(storage.meta or {}) fixed_meta["batcharray"] = dict(_BATCHARRAY_META) storage.meta = fixed_meta - if chunksize is None: - chunksize = -1 - schunk = blosc2.SChunk( - chunksize=chunksize, data=None, cparams=cparams, dparams=dparams, storage=storage - ) + schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) + if self._chunksize is not None or self._blocksize is not None: + self._store_layout() def _validate_tag(self) -> None: if "batcharray" not in self.schunk.meta: raise ValueError("The supplied SChunk is not tagged as a BatchArray") + def _load_layout(self) -> None: + layout = None + self._layout_format = None + if _BATCHARRAY_LAYOUT_KEY in self.vlmeta: + layout = self.vlmeta[_BATCHARRAY_LAYOUT_KEY] + if isinstance(layout, dict): + self._chunksize = layout.get("chunksize") + self._blocksize = layout.get("blocksize") + self._layout_format = layout.get("format", "batched_vlblocks") + return + if len(self) == 0: + return + # Legacy fallback: one object per VL block. + first_nbytes, _, nblocks = blosc2.get_cbuffer_sizes(self.schunk.get_lazychunk(0)) + if nblocks <= 0 or first_nbytes <= 0: + return + self._chunksize = nblocks + self._blocksize = 1 + self._layout_format = "legacy_vlblocks" + self._store_layout() + + def _store_layout(self) -> None: + if self._chunksize is None or self.mode == "r": + return + layout = { + "version": 1, + "chunksize": self._chunksize, + "blocksize": self._blocksize, + "format": self._layout_format or "batched_vlblocks", + "sizing_policy": "l2_cache_prefix", + } + self.vlmeta[_BATCHARRAY_LAYOUT_KEY] = layout + def _check_writable(self) -> None: if self.mode == "r": raise ValueError("Cannot modify a BatchArray opened in read-only mode") @@ -249,13 +293,42 @@ def _normalize_batch(self, value: object) -> list[Any]: raise ValueError("BatchArray entries cannot be empty") return values - def _serialize_batch(self, value: object) -> list[bytes]: - payloads = [] - for item in self._normalize_batch(value): - payload = msgpack_packb(item) - _check_serialized_size(payload) - payloads.append(payload) - return payloads + def _ensure_layout_for_batch(self, batch: list[Any]) -> None: + if self._chunksize is None: + self._chunksize = len(batch) + if len(batch) != self._chunksize: + raise ValueError(f"BatchArray entries must contain exactly {self._chunksize} objects") + if self._blocksize is None: + payload_sizes = [len(msgpack_packb(item)) for item in batch] + self._blocksize = self._guess_blocksize(payload_sizes) + self._store_layout() + + def _guess_blocksize(self, payload_sizes: list[int]) -> int: + if not payload_sizes: + raise ValueError("BatchArray entries cannot be empty") + l2_cache_size = blosc2.cpu_info.get("l2_cache_size") + if not isinstance(l2_cache_size, int) or l2_cache_size <= 0: + return len(payload_sizes) + total = 0 + count = 0 + for payload_size in payload_sizes: + if count > 0 and total + payload_size > l2_cache_size: + break + total += payload_size + count += 1 + if count == 0: + count = 1 + return min(count, len(payload_sizes)) + + def _serialize_batch(self, value: object) -> list[Any]: + batch = self._normalize_batch(value) + self._ensure_layout_for_batch(batch) + return batch + + def _serialize_block(self, items: list[Any]) -> bytes: + payload = msgpack_packb(items) + _check_serialized_size(payload) + return payload def _vl_cparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.cparams) @@ -263,33 +336,44 @@ def _vl_cparams_kwargs(self) -> dict[str, Any]: def _vl_dparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.dparams) - def _compress_batch(self, payloads: list[bytes]) -> bytes: - return blosc2.blosc2_ext.vlcompress(payloads, **self._vl_cparams_kwargs()) + def _compress_batch(self, batch: list[Any]) -> bytes: + if self._blocksize is None: + raise RuntimeError("BatchArray blocksize is not initialized") + blocks = [ + self._serialize_block(batch[i : i + self._blocksize]) + for i in range(0, len(batch), self._blocksize) + ] + return blosc2.blosc2_ext.vlcompress(blocks, **self._vl_cparams_kwargs()) - def _decode_payloads(self, nchunk: int) -> list[bytes]: - return blosc2.blosc2_ext.vldecompress(self.schunk.get_chunk(nchunk), **self._vl_dparams_kwargs()) + def _decode_blocks(self, nchunk: int) -> list[list[Any]]: + block_payloads = blosc2.blosc2_ext.vldecompress( + self.schunk.get_chunk(nchunk), **self._vl_dparams_kwargs() + ) + if self._layout_format == "legacy_vlblocks": + return [[msgpack_unpackb(payload)] for payload in block_payloads] + return [msgpack_unpackb(payload) for payload in block_payloads] def _get_batch(self, index: int) -> Batch: return Batch(self, index, self.schunk.get_lazychunk(index)) def _batch_lengths(self) -> list[int]: - lengths = [] - for i in range(len(self)): - _, _, nblocks = blosc2.get_cbuffer_sizes(self.schunk.get_lazychunk(i)) - lengths.append(nblocks) - return lengths + if self.chunksize is not None: + return [self.chunksize for _ in range(len(self))] + return [len(self[i]) for i in range(len(self))] def append(self, value: object) -> int: """Append one batch and return the new number of entries.""" self._check_writable() - chunk = self._compress_batch(self._serialize_batch(value)) + batch = self._serialize_batch(value) + chunk = self._compress_batch(batch) return self.schunk.append_chunk(chunk) def insert(self, index: int, value: object) -> int: """Insert one batch at ``index`` and return the new number of entries.""" self._check_writable() index = self._normalize_insert_index(index) - chunk = self._compress_batch(self._serialize_batch(value)) + batch = self._serialize_batch(value) + chunk = self._compress_batch(batch) return self.schunk.insert_chunk(index, chunk) def delete(self, index: int | slice) -> int: @@ -316,7 +400,8 @@ def extend(self, values: object) -> None: """Append all batches from an iterable.""" self._check_writable() for value in values: - chunk = self._compress_batch(self._serialize_batch(value)) + batch = self._serialize_batch(value) + chunk = self._compress_batch(batch) self.schunk.append_chunk(chunk) def clear(self) -> None: @@ -333,6 +418,7 @@ def clear(self) -> None: storage=storage, ) self._attach_schunk(schunk) + self._store_layout() def __getitem__(self, index: int | slice) -> Batch | list[Batch]: if isinstance(index, slice): @@ -351,7 +437,8 @@ def __setitem__(self, index: int | slice, value: object) -> None: for idx in reversed(indices): self.schunk.delete_chunk(idx) for offset, item in enumerate(values): - chunk = self._compress_batch(self._serialize_batch(item)) + batch = self._serialize_batch(item) + chunk = self._compress_batch(batch) self.schunk.insert_chunk(start + offset, chunk) return if len(values) != len(indices): @@ -359,12 +446,14 @@ def __setitem__(self, index: int | slice, value: object) -> None: f"attempt to assign sequence of size {len(values)} to extended slice of size {len(indices)}" ) for idx, item in zip(indices, values, strict=True): - chunk = self._compress_batch(self._serialize_batch(item)) + batch = self._serialize_batch(item) + chunk = self._compress_batch(batch) self.schunk.update_chunk(idx, chunk) return self._check_writable() index = self._normalize_index(index) - chunk = self._compress_batch(self._serialize_batch(value)) + batch = self._serialize_batch(value) + chunk = self._compress_batch(batch) self.schunk.update_chunk(index, chunk) def __delitem__(self, index: int | slice) -> None: @@ -395,7 +484,11 @@ def dparams(self): @property def chunksize(self) -> int: - return self.schunk.chunksize + return self._chunksize + + @property + def blocksize(self) -> int: + return self._blocksize @property def typesize(self) -> int: @@ -435,6 +528,8 @@ def info_items(self) -> list: return [ ("type", f"{self.__class__.__name__}"), ("nbatches", len(self)), + ("chunksize", self.chunksize), + ("blocksize", self.blocksize), ("nitems", nitems), ("batch_len_min", min(batch_lengths) if batch_lengths else 0), ("batch_len_max", max(batch_lengths) if batch_lengths else 0), @@ -456,7 +551,8 @@ def copy(self, **kwargs: Any) -> BatchArray: kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) - kwargs["chunksize"] = kwargs.get("chunksize", -1) + kwargs["chunksize"] = kwargs.get("chunksize", self.chunksize) + kwargs["blocksize"] = kwargs.get("blocksize", self.blocksize) if "storage" not in kwargs: kwargs["meta"] = self._copy_meta() @@ -465,6 +561,9 @@ def copy(self, **kwargs: Any) -> BatchArray: kwargs["mode"] = "w" out = BatchArray(**kwargs) + if "storage" not in kwargs and len(self.vlmeta) > 0: + for key, value in self.vlmeta.getall().items(): + out.vlmeta[key] = value out.extend(self) return out diff --git a/tests/test_batch_array.py b/tests/test_batch_array.py index 48f5fce0..9ec692fb 100644 --- a/tests/test_batch_array.py +++ b/tests/test_batch_array.py @@ -12,8 +12,8 @@ BATCHES = [ [b"bytes\x00payload", "plain text", 42], - [{"nested": [1, 2]}, None], - [(1, 2, "three"), 3.5, True, {"rows": [[], ["nested"]]}], + [{"nested": [1, 2]}, None, {"tail": True}], + [(1, 2, "three"), 3.5, True], ] @@ -46,7 +46,12 @@ def test_batcharray_roundtrip(contiguous, urlpath): assert barray.append(batch) == i assert len(barray) == len(BATCHES) + assert barray.chunksize == len(BATCHES[0]) + assert barray.blocksize is not None + assert 1 <= barray.blocksize <= barray.chunksize assert [batch[:] for batch in barray] == BATCHES + with pytest.raises(ValueError): + barray.append([1, 2]) batch0 = barray[0] assert isinstance(batch0, blosc2.Batch) @@ -59,16 +64,16 @@ def test_batcharray_roundtrip(contiguous, urlpath): assert batch0.cratio > 0 expected = list(BATCHES) - expected[1] = ["updated", {"tuple": (7, 8)}] - expected[-1] = ["tiny"] + expected[1] = ["updated", {"tuple": (7, 8)}, 99] + expected[-1] = ["tiny", False, "x"] barray[1] = expected[1] barray[-1] = expected[-1] - assert barray.insert(0, ["head", 0]) == len(expected) + 1 - expected.insert(0, ["head", 0]) - assert barray.insert(-1, ["between", {"k": 5}]) == len(expected) + 1 - expected.insert(-1, ["between", {"k": 5}]) - assert barray.insert(999, ["tail"]) == len(expected) + 1 - expected.insert(999, ["tail"]) + assert barray.insert(0, ["head", 0, "x"]) == len(expected) + 1 + expected.insert(0, ["head", 0, "x"]) + assert barray.insert(-1, ["between", {"k": 5}, None]) == len(expected) + 1 + expected.insert(-1, ["between", {"k": 5}, None]) + assert barray.insert(999, ["tail", 1, 2]) == len(expected) + 1 + expected.insert(999, ["tail", 1, 2]) assert barray.delete(2) == len(expected) - 1 del expected[2] del barray[-2] @@ -78,6 +83,8 @@ def test_batcharray_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchArray) + assert reopened.chunksize == barray.chunksize + assert reopened.blocksize == barray.blocksize assert [batch[:] for batch in reopened] == expected with pytest.raises(ValueError): reopened.append(["nope"]) @@ -97,8 +104,8 @@ def test_batcharray_roundtrip(contiguous, urlpath): reopened.clear() reopened_rw = blosc2.open(urlpath, mode="a") - reopened_rw[0] = ["changed"] - expected[0] = ["changed"] + reopened_rw[0] = ["changed", "batch", 0] + expected[0] = ["changed", "batch", 0] assert [batch[:] for batch in reopened_rw] == expected if contiguous: @@ -112,10 +119,10 @@ def test_batcharray_roundtrip(contiguous, urlpath): def test_batcharray_from_cframe(): barray = blosc2.BatchArray() barray.extend(BATCHES) - barray.insert(1, ["inserted", True]) + barray.insert(1, ["inserted", True, None]) del barray[3] expected = list(BATCHES) - expected.insert(1, ["inserted", True]) + expected.insert(1, ["inserted", True, None]) del expected[3] restored = blosc2.from_cframe(barray.to_cframe()) @@ -138,9 +145,11 @@ def test_batcharray_info(): items = dict(barray.info_items) assert items["type"] == "BatchArray" assert items["nbatches"] == len(BATCHES) + assert items["chunksize"] == len(BATCHES[0]) + assert items["blocksize"] == barray.blocksize assert items["nitems"] == sum(len(batch) for batch in BATCHES) - assert items["batch_len_min"] == 2 - assert items["batch_len_max"] == 4 + assert items["batch_len_min"] == 3 + assert items["batch_len_max"] == 3 assert items["batch_len_avg"] == "3.00" assert "urlpath" not in items assert "contiguous" not in items @@ -161,6 +170,14 @@ def test_batcharray_zstd_uses_dict_by_default(): assert barray.cparams.use_dict is True +def test_batcharray_explicit_chunksize_blocksize(): + barray = blosc2.BatchArray(chunksize=3, blocksize=2) + assert barray.chunksize == 3 + assert barray.blocksize == 2 + barray.append([1, 2, 3]) + assert [batch[:] for batch in barray] == [[1, 2, 3]] + + def test_batcharray_respects_explicit_use_dict_and_non_zstd(): barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 @@ -240,22 +257,22 @@ def test_batcharray_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) - barray.extend([[1, 2], [3], [4, 5, 6]]) - assert [batch[:] for batch in barray] == [[1, 2], [3], [4, 5, 6]] - assert barray.pop() == [4, 5, 6] - assert barray.pop(0) == [1, 2] - assert [batch[:] for batch in barray] == [[3]] + barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + assert barray.pop() == [7, 8, 9] + assert barray.pop(0) == [1, 2, 3] + assert [batch[:] for batch in barray] == [[4, 5, 6]] barray.clear() assert len(barray) == 0 assert [batch[:] for batch in barray] == [] - barray.extend([["a"], ["b", "c"]]) - assert [batch[:] for batch in barray] == [["a"], ["b", "c"]] + barray.extend([["a", "b", "c"], ["d", "e", "f"]]) + assert [batch[:] for batch in barray] == [["a", "b", "c"], ["d", "e", "f"]] if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert [batch[:] for batch in reopened] == [["a"], ["b", "c"]] + assert [batch[:] for batch in reopened] == [["a", "b", "c"], ["d", "e", "f"]] blosc2.remove_urlpath(urlpath) @@ -272,19 +289,19 @@ def test_batcharray_list_like_ops(contiguous, urlpath): def test_batcharray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - expected = [[i, i + 100] for i in range(8)] + expected = [[i, i + 100, i + 200] for i in range(8)] barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) barray.extend(expected) assert [batch[:] for batch in barray[1:6:2]] == expected[1:6:2] assert [batch[:] for batch in barray[::-2]] == expected[::-2] - barray[2:5] = [["a"], ["b", "c"]] - expected[2:5] = [["a"], ["b", "c"]] + barray[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] + expected[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] assert [batch[:] for batch in barray] == expected - barray[1:6:2] = [[100], [101], [102]] - expected[1:6:2] = [[100], [101], [102]] + barray[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] + expected[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] assert [batch[:] for batch in barray] == expected del barray[::3] @@ -322,7 +339,7 @@ def test_batcharray_copy(): original = blosc2.BatchArray(urlpath=urlpath, mode="w", contiguous=True) original.extend(BATCHES) - original.insert(1, ["copy", True]) + original.insert(1, ["copy", True, 123]) copied = original.copy( urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5} @@ -386,7 +403,9 @@ def test_batcharray_validation_errors(): barray.delete(3) with pytest.raises(IndexError): blosc2.BatchArray().pop() - barray.extend([[1]]) + barray.extend([[1, 2, 3]]) + with pytest.raises(ValueError): + barray.append([2, 3]) with pytest.raises(NotImplementedError): barray.pop(slice(0, 1)) From bad841173fa92bc856a91274a4ba1cde92940d2c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 13:45:01 +0100 Subject: [PATCH 27/69] Fix for newer versions of torch --- tests/ndarray/test_setitem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ndarray/test_setitem.py b/tests/ndarray/test_setitem.py index 02a0a336..bde27317 100644 --- a/tests/ndarray/test_setitem.py +++ b/tests/ndarray/test_setitem.py @@ -66,7 +66,8 @@ def test_setitem_torch_proxy(shape, chunks, blocks, slices, dtype): dtype_ = {np.float32: torch.float32, np.int32: torch.int32, np.float64: torch.float64}[dtype] val = torch.ones(slice_shape, dtype=dtype_) a[slices] = val - nparray[slices] = val + # Make the expected assignment explicit so NumPy does not rely on torch.__array__(). + nparray[slices] = val.numpy() np.testing.assert_almost_equal(a[...], nparray) From 4fb6882cef59afe54e5ca9781a3bc2dd362bd3e3 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 14:00:09 +0100 Subject: [PATCH 28/69] BatchArray -> ObjectArray --- src/blosc2/__init__.py | 6 +- src/blosc2/core.py | 10 +- src/blosc2/dict_store.py | 18 +-- src/blosc2/embed_store.py | 10 +- .../{batch_array.py => object_array.py} | 74 ++++++------ src/blosc2/schunk.py | 8 +- src/blosc2/tree_store.py | 6 +- ...st_batch_array.py => test_object_array.py} | 110 +++++++++--------- 8 files changed, 121 insertions(+), 121 deletions(-) rename src/blosc2/{batch_array.py => object_array.py} (89%) rename tests/{test_batch_array.py => test_object_array.py} (79%) diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 66455881..1f058757 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -535,7 +535,7 @@ def _raise(exc): from .embed_store import EmbedStore, estore_from_cframe from .dict_store import DictStore from .tree_store import TreeStore -from .batch_array import Batch, BatchArray, batcharray_from_cframe +from .object_array import Batch, ObjectArray, objectarray_from_cframe from .vlarray import VLArray, vlarray_from_cframe from .c2array import c2context, C2Array, URLPath @@ -721,7 +721,7 @@ def _raise(exc): "C2Array", "CParams", "Batch", - "BatchArray", + "ObjectArray", # Enums "Codec", "DParams", @@ -944,7 +944,7 @@ def _raise(exc): "validate_expr", "var", "vecdot", - "batcharray_from_cframe", + "objectarray_from_cframe", "vlarray_from_cframe", "where", "zeros", diff --git a/src/blosc2/core.py b/src/blosc2/core.py index fc8749ad..6e37b139 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1918,9 +1918,9 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True -) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.BatchArray | blosc2.VLArray: +) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.ObjectArray | blosc2.VLArray: """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`BatchArray ` or :ref:`VLArray ` instance + :ref:`ObjectArray ` or :ref:`VLArray ` instance from a contiguous frame buffer. Parameters @@ -1938,7 +1938,7 @@ def from_cframe( Returns ------- out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`BatchArray ` or :ref:`VLArray ` + :ref:`ObjectArray ` or :ref:`VLArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1952,8 +1952,8 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) - if "batcharray" in schunk.meta: - return blosc2.batcharray_from_cframe(cframe, copy=copy) + if "objectarray" in schunk.meta: + return blosc2.objectarray_from_cframe(cframe, copy=copy) if "vlarray" in schunk.meta: return blosc2.vlarray_from_cframe(cframe, copy=copy) if "b2nd" in schunk.meta: diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 65bab76e..8ed2fac9 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -249,25 +249,25 @@ def estore(self) -> EmbedStore: return self._estore @staticmethod - def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> int: - if isinstance(value, (blosc2.VLArray, blosc2.BatchArray)): + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> int: + if isinstance(value, (blosc2.VLArray, blosc2.ObjectArray)): return value.schunk.nbytes return value.nbytes @staticmethod - def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> bool: - return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.BatchArray)) and bool( + def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> bool: + return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.ObjectArray)) and bool( getattr(value, "urlpath", None) ) @staticmethod - def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> str: + def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" return ".b2f" def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray ) -> None: """Add a node to the DictStore.""" if isinstance(value, np.ndarray): @@ -294,7 +294,7 @@ def __setitem__( if hasattr(value, "save"): value.save(urlpath=dest_path) else: - # SChunk, VLArray and BatchArray can all be persisted via their cframe. + # SChunk, VLArray and ObjectArray can all be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -314,7 +314,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: @@ -346,7 +346,7 @@ def __getitem__( def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index e1c8b0d2..e54967fb 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -174,7 +174,7 @@ def _ensure_capacity(self, needed_bytes: int) -> None: self._store.resize((new_size,)) def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray ) -> None: """Add a node to the embed store.""" if self.mode == "r": @@ -198,7 +198,7 @@ def __setitem__( self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -216,7 +216,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | bl def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -243,12 +243,12 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]]: + def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/batch_array.py b/src/blosc2/object_array.py similarity index 89% rename from src/blosc2/batch_array.py rename to src/blosc2/object_array.py index 2bd486ad..decffc18 100644 --- a/src/blosc2/batch_array.py +++ b/src/blosc2/object_array.py @@ -17,8 +17,8 @@ from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_BATCHARRAY_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} -_BATCHARRAY_LAYOUT_KEY = "batcharray" +_OBJECTARRAY_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} +_OBJECTARRAY_LAYOUT_KEY = "objectarray" def _check_serialized_size(buffer: bytes) -> None: @@ -27,9 +27,9 @@ def _check_serialized_size(buffer: bytes) -> None: class Batch(Sequence[Any]): - """A lazy sequence of Python objects stored in one BatchArray chunk.""" + """A lazy sequence of Python objects stored in one ObjectArray chunk.""" - def __init__(self, parent: BatchArray, nchunk: int, lazychunk: bytes) -> None: + def __init__(self, parent: ObjectArray, nchunk: int, lazychunk: bytes) -> None: self._parent = parent self._nchunk = nchunk self._lazychunk = lazychunk @@ -58,7 +58,7 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: index = self._normalize_index(index) blocksize = self._parent.blocksize if blocksize is None: - raise RuntimeError("BatchArray blocksize is not initialized") + raise RuntimeError("ObjectArray blocksize is not initialized") block_index, item_index = divmod(index, blocksize) return blocks[block_index][item_index] @@ -92,7 +92,7 @@ def __repr__(self) -> str: return f"Batch(len={len(self)}, nbytes={self.nbytes}, cbytes={self.cbytes})" -class BatchArray: +class ObjectArray: """A batched variable-length array backed by an :class:`blosc2.SChunk`.""" @staticmethod @@ -109,14 +109,14 @@ def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: - # BatchArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectArray stores many small serialized payloads, where Zstd dicts help materially. cparams.use_dict = True else: cparams["typesize"] = 1 codec = cparams.get("codec", blosc2.Codec.ZSTD) clevel = cparams.get("clevel", 5) if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: - # BatchArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectArray stores many small serialized payloads, where Zstd dicts help materially. cparams["use_dict"] = True return cparams @@ -142,9 +142,9 @@ def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any @staticmethod def _validate_storage(storage: blosc2.Storage) -> None: if storage.mmap_mode not in (None, "r"): - raise ValueError("For BatchArray containers, mmap_mode must be None or 'r'") + raise ValueError("For ObjectArray containers, mmap_mode must be None or 'r'") if storage.mmap_mode == "r" and storage.mode != "r": - raise ValueError("For BatchArray containers, mmap_mode='r' requires mode='r'") + raise ValueError("For ObjectArray containers, mmap_mode='r' requires mode='r'") def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.schunk = schunk @@ -197,7 +197,7 @@ def __init__( if kwargs: unexpected = ", ".join(sorted(kwargs)) - raise ValueError(f"Unsupported BatchArray keyword argument(s): {unexpected}") + raise ValueError(f"Unsupported ObjectArray keyword argument(s): {unexpected}") self._validate_storage(storage) cparams = self._set_typesize_one(cparams) @@ -209,7 +209,7 @@ def __init__( return fixed_meta = dict(storage.meta or {}) - fixed_meta["batcharray"] = dict(_BATCHARRAY_META) + fixed_meta["objectarray"] = dict(_OBJECTARRAY_META) storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) @@ -217,14 +217,14 @@ def __init__( self._store_layout() def _validate_tag(self) -> None: - if "batcharray" not in self.schunk.meta: - raise ValueError("The supplied SChunk is not tagged as a BatchArray") + if "objectarray" not in self.schunk.meta: + raise ValueError("The supplied SChunk is not tagged as an ObjectArray") def _load_layout(self) -> None: layout = None self._layout_format = None - if _BATCHARRAY_LAYOUT_KEY in self.vlmeta: - layout = self.vlmeta[_BATCHARRAY_LAYOUT_KEY] + if _OBJECTARRAY_LAYOUT_KEY in self.vlmeta: + layout = self.vlmeta[_OBJECTARRAY_LAYOUT_KEY] if isinstance(layout, dict): self._chunksize = layout.get("chunksize") self._blocksize = layout.get("blocksize") @@ -251,24 +251,24 @@ def _store_layout(self) -> None: "format": self._layout_format or "batched_vlblocks", "sizing_policy": "l2_cache_prefix", } - self.vlmeta[_BATCHARRAY_LAYOUT_KEY] = layout + self.vlmeta[_OBJECTARRAY_LAYOUT_KEY] = layout def _check_writable(self) -> None: if self.mode == "r": - raise ValueError("Cannot modify a BatchArray opened in read-only mode") + raise ValueError("Cannot modify an ObjectArray opened in read-only mode") def _normalize_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("BatchArray indices must be integers") + raise TypeError("ObjectArray indices must be integers") if index < 0: index += len(self) if index < 0 or index >= len(self): - raise IndexError("BatchArray index out of range") + raise IndexError("ObjectArray index out of range") return index def _normalize_insert_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("BatchArray indices must be integers") + raise TypeError("ObjectArray indices must be integers") if index < 0: index += len(self) if index < 0: @@ -285,19 +285,19 @@ def _copy_meta(self) -> dict[str, Any]: def _normalize_batch(self, value: object) -> list[Any]: if isinstance(value, (str, bytes, bytearray, memoryview)): - raise TypeError("BatchArray entries must be sequences of Python objects") + raise TypeError("ObjectArray entries must be sequences of Python objects") if not isinstance(value, Sequence): - raise TypeError("BatchArray entries must be sequences of Python objects") + raise TypeError("ObjectArray entries must be sequences of Python objects") values = list(value) if len(values) == 0: - raise ValueError("BatchArray entries cannot be empty") + raise ValueError("ObjectArray entries cannot be empty") return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: if self._chunksize is None: self._chunksize = len(batch) if len(batch) != self._chunksize: - raise ValueError(f"BatchArray entries must contain exactly {self._chunksize} objects") + raise ValueError(f"ObjectArray entries must contain exactly {self._chunksize} objects") if self._blocksize is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] self._blocksize = self._guess_blocksize(payload_sizes) @@ -305,7 +305,7 @@ def _ensure_layout_for_batch(self, batch: list[Any]) -> None: def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: - raise ValueError("BatchArray entries cannot be empty") + raise ValueError("ObjectArray entries cannot be empty") l2_cache_size = blosc2.cpu_info.get("l2_cache_size") if not isinstance(l2_cache_size, int) or l2_cache_size <= 0: return len(payload_sizes) @@ -338,7 +338,7 @@ def _vl_dparams_kwargs(self) -> dict[str, Any]: def _compress_batch(self, batch: list[Any]) -> bytes: if self._blocksize is None: - raise RuntimeError("BatchArray blocksize is not initialized") + raise RuntimeError("ObjectArray blocksize is not initialized") blocks = [ self._serialize_block(batch[i : i + self._blocksize]) for i in range(0, len(batch), self._blocksize) @@ -390,7 +390,7 @@ def pop(self, index: int = -1) -> list[Any]: """Remove and return the batch at ``index``.""" self._check_writable() if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for BatchArray") + raise NotImplementedError("Slicing is not supported for ObjectArray") index = self._normalize_index(index) value = self[index][:] self.schunk.delete_chunk(index) @@ -516,12 +516,12 @@ def contiguous(self) -> bool: @property def info(self) -> InfoReporter: - """Print information about this BatchArray.""" + """Print information about this ObjectArray.""" return InfoReporter(self) @property def info_items(self) -> list: - """A list of tuples with summary information about this BatchArray.""" + """A list of tuples with summary information about this ObjectArray.""" batch_lengths = self._batch_lengths() nitems = sum(batch_lengths) avg_batch_len = nitems / len(batch_lengths) if batch_lengths else 0.0 @@ -544,7 +544,7 @@ def info_items(self) -> list: def to_cframe(self) -> bytes: return self.schunk.to_cframe() - def copy(self, **kwargs: Any) -> BatchArray: + def copy(self, **kwargs: Any) -> ObjectArray: """Create a copy of the container with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") @@ -560,25 +560,25 @@ def copy(self, **kwargs: Any) -> BatchArray: if "urlpath" in kwargs and "mode" not in kwargs: kwargs["mode"] = "w" - out = BatchArray(**kwargs) + out = ObjectArray(**kwargs) if "storage" not in kwargs and len(self.vlmeta) > 0: for key, value in self.vlmeta.getall().items(): out.vlmeta[key] = value out.extend(self) return out - def __enter__(self) -> BatchArray: + def __enter__(self) -> ObjectArray: return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: return False def __repr__(self) -> str: - return f"BatchArray(len={len(self)}, urlpath={self.urlpath!r})" + return f"ObjectArray(len={len(self)}, urlpath={self.urlpath!r})" -def batcharray_from_cframe(cframe: bytes, copy: bool = True) -> BatchArray: - """Deserialize a CFrame buffer into a :class:`BatchArray`.""" +def objectarray_from_cframe(cframe: bytes, copy: bool = True) -> ObjectArray: + """Deserialize a CFrame buffer into a :class:`ObjectArray`.""" schunk = blosc2.schunk_from_cframe(cframe, copy=copy) - return BatchArray(_from_schunk=schunk) + return ObjectArray(_from_schunk=schunk) diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index a422928a..50428dd0 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -1621,10 +1621,10 @@ def _process_opened_object(res): return VLArray(_from_schunk=getattr(res, "schunk", res)) - if "batcharray" in meta: - from blosc2.batch_array import BatchArray + if "objectarray" in meta: + from blosc2.object_array import ObjectArray - return BatchArray(_from_schunk=getattr(res, "schunk", res)) + return ObjectArray(_from_schunk=getattr(res, "schunk", res)) if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta: return blosc2._open_lazyarray(res) @@ -1637,7 +1637,7 @@ def open( ) -> ( blosc2.SChunk | blosc2.NDArray - | blosc2.BatchArray + | blosc2.ObjectArray | blosc2.VLArray | blosc2.C2Array | blosc2.LazyArray diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 82b8b3de..3efcc19f 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -227,7 +227,7 @@ def _validate_key(self, key: str) -> str: return key def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray ) -> None: """Add a node with hierarchical key validation. @@ -272,7 +272,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.BatchArray | TreeStore: + ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.ObjectArray | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -286,7 +286,7 @@ def __getitem__( Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.BatchArray or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.ObjectArray or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. diff --git a/tests/test_batch_array.py b/tests/test_object_array.py similarity index 79% rename from tests/test_batch_array.py rename to tests/test_object_array.py index 9ec692fb..4215801f 100644 --- a/tests/test_batch_array.py +++ b/tests/test_object_array.py @@ -32,15 +32,15 @@ def _storage(contiguous, urlpath, mode="w"): [ (False, None), (True, None), - (True, "test_batcharray.b2frame"), - (False, "test_batcharray_s.b2frame"), + (True, "test_objectarray.b2frame"), + (False, "test_objectarray_s.b2frame"), ], ) -def test_batcharray_roundtrip(contiguous, urlpath): +def test_objectarray_roundtrip(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) - assert barray.meta["batcharray"]["serializer"] == "msgpack" + barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + assert barray.meta["objectarray"]["serializer"] == "msgpack" for i, batch in enumerate(BATCHES, start=1): assert barray.append(batch) == i @@ -82,7 +82,7 @@ def test_batcharray_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert isinstance(reopened, blosc2.BatchArray) + assert isinstance(reopened, blosc2.ObjectArray) assert reopened.chunksize == barray.chunksize assert reopened.blocksize == barray.blocksize assert [batch[:] for batch in reopened] == expected @@ -110,14 +110,14 @@ def test_batcharray_roundtrip(contiguous, urlpath): if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") - assert isinstance(reopened_mmap, blosc2.BatchArray) + assert isinstance(reopened_mmap, blosc2.ObjectArray) assert [batch[:] for batch in reopened_mmap] == expected blosc2.remove_urlpath(urlpath) -def test_batcharray_from_cframe(): - barray = blosc2.BatchArray() +def test_objectarray_from_cframe(): + barray = blosc2.ObjectArray() barray.extend(BATCHES) barray.insert(1, ["inserted", True, None]) del barray[3] @@ -126,16 +126,16 @@ def test_batcharray_from_cframe(): del expected[3] restored = blosc2.from_cframe(barray.to_cframe()) - assert isinstance(restored, blosc2.BatchArray) + assert isinstance(restored, blosc2.ObjectArray) assert [batch[:] for batch in restored] == expected - restored2 = blosc2.batcharray_from_cframe(barray.to_cframe()) - assert isinstance(restored2, blosc2.BatchArray) + restored2 = blosc2.objectarray_from_cframe(barray.to_cframe()) + assert isinstance(restored2, blosc2.ObjectArray) assert [batch[:] for batch in restored2] == expected -def test_batcharray_info(): - barray = blosc2.BatchArray() +def test_objectarray_info(): + barray = blosc2.ObjectArray() barray.extend(BATCHES) assert barray.typesize == 1 @@ -143,7 +143,7 @@ def test_batcharray_info(): assert barray.urlpath == barray.schunk.urlpath items = dict(barray.info_items) - assert items["type"] == "BatchArray" + assert items["type"] == "ObjectArray" assert items["nbatches"] == len(BATCHES) assert items["chunksize"] == len(BATCHES[0]) assert items["blocksize"] == barray.blocksize @@ -160,37 +160,37 @@ def test_batcharray_info(): text = repr(barray.info) assert "type" in text - assert "BatchArray" in text + assert "ObjectArray" in text assert "batch_len_avg" in text -def test_batcharray_zstd_uses_dict_by_default(): - barray = blosc2.BatchArray() +def test_objectarray_zstd_uses_dict_by_default(): + barray = blosc2.ObjectArray() assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is True -def test_batcharray_explicit_chunksize_blocksize(): - barray = blosc2.BatchArray(chunksize=3, blocksize=2) +def test_objectarray_explicit_chunksize_blocksize(): + barray = blosc2.ObjectArray(chunksize=3, blocksize=2) assert barray.chunksize == 3 assert barray.blocksize == 2 barray.append([1, 2, 3]) assert [batch[:] for batch in barray] == [[1, 2, 3]] -def test_batcharray_respects_explicit_use_dict_and_non_zstd(): - barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) +def test_objectarray_respects_explicit_use_dict_and_non_zstd(): + barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 assert barray.cparams.use_dict is False - barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is False - barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) assert barray.cparams.use_dict is False - barray = blosc2.BatchArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + barray = blosc2.ObjectArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) assert barray.cparams.use_dict is False @@ -231,14 +231,14 @@ def test_vlcompress_small_blocks_roundtrip(): assert out == payloads -def test_batcharray_constructor_kwargs(): - urlpath = "test_batcharray_kwargs.b2frame" +def test_objectarray_constructor_kwargs(): + urlpath = "test_objectarray_kwargs.b2frame" blosc2.remove_urlpath(urlpath) - barray = blosc2.BatchArray(urlpath=urlpath, mode="w", contiguous=True) + barray = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) barray.extend(BATCHES) - reopened = blosc2.BatchArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + reopened = blosc2.ObjectArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") assert [batch[:] for batch in reopened] == BATCHES blosc2.remove_urlpath(urlpath) @@ -249,14 +249,14 @@ def test_batcharray_constructor_kwargs(): [ (False, None), (True, None), - (True, "test_batcharray_list_ops.b2frame"), - (False, "test_batcharray_list_ops_s.b2frame"), + (True, "test_objectarray_list_ops.b2frame"), + (False, "test_objectarray_list_ops_s.b2frame"), ], ) -def test_batcharray_list_like_ops(contiguous, urlpath): +def test_objectarray_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) + barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] assert barray.pop() == [7, 8, 9] @@ -282,15 +282,15 @@ def test_batcharray_list_like_ops(contiguous, urlpath): [ (False, None), (True, None), - (True, "test_batcharray_slices.b2frame"), - (False, "test_batcharray_slices_s.b2frame"), + (True, "test_objectarray_slices.b2frame"), + (False, "test_objectarray_slices_s.b2frame"), ], ) -def test_batcharray_slices(contiguous, urlpath): +def test_objectarray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) expected = [[i, i + 100, i + 200] for i in range(8)] - barray = blosc2.BatchArray(storage=_storage(contiguous, urlpath)) + barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) barray.extend(expected) assert [batch[:] for batch in barray[1:6:2]] == expected[1:6:2] @@ -319,8 +319,8 @@ def test_batcharray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) -def test_batcharray_slice_errors(): - barray = blosc2.BatchArray() +def test_objectarray_slice_errors(): + barray = blosc2.ObjectArray() barray.extend([[0], [1], [2], [3]]) with pytest.raises(ValueError, match="extended slice"): @@ -331,13 +331,13 @@ def test_batcharray_slice_errors(): _ = barray[::0] -def test_batcharray_copy(): - urlpath = "test_batcharray_copy.b2frame" - copy_path = "test_batcharray_copy_out.b2frame" +def test_objectarray_copy(): + urlpath = "test_objectarray_copy.b2frame" + copy_path = "test_objectarray_copy_out.b2frame" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) - original = blosc2.BatchArray(urlpath=urlpath, mode="w", contiguous=True) + original = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) original.extend(BATCHES) original.insert(1, ["copy", True, 123]) @@ -362,7 +362,7 @@ def test_batcharray_copy(): @pytest.mark.parametrize(("contiguous", "nthreads"), [(False, 2), (True, 4)]) -def test_batcharray_multithreaded_inner_vl(contiguous, nthreads): +def test_objectarray_multithreaded_inner_vl(contiguous, nthreads): batches = [] for batch_id in range(24): batch = [] @@ -379,7 +379,7 @@ def test_batcharray_multithreaded_inner_vl(contiguous, nthreads): ) batches.append(batch) - barray = blosc2.BatchArray( + barray = blosc2.ObjectArray( storage=blosc2.Storage(contiguous=contiguous), cparams=blosc2.CParams(typesize=1, nthreads=nthreads, codec=blosc2.Codec.ZSTD, clevel=5), dparams=blosc2.DParams(nthreads=nthreads), @@ -390,8 +390,8 @@ def test_batcharray_multithreaded_inner_vl(contiguous, nthreads): assert [barray[i][:] for i in range(len(barray))] == batches -def test_batcharray_validation_errors(): - barray = blosc2.BatchArray() +def test_objectarray_validation_errors(): + barray = blosc2.ObjectArray() with pytest.raises(TypeError): barray.append("value") @@ -402,7 +402,7 @@ def test_batcharray_validation_errors(): with pytest.raises(IndexError): barray.delete(3) with pytest.raises(IndexError): - blosc2.BatchArray().pop() + blosc2.ObjectArray().pop() barray.extend([[1, 2, 3]]) with pytest.raises(ValueError): barray.append([2, 3]) @@ -410,29 +410,29 @@ def test_batcharray_validation_errors(): barray.pop(slice(0, 1)) -def test_batcharray_in_embed_store(): +def test_objectarray_in_embed_store(): estore = blosc2.EmbedStore() - barray = blosc2.BatchArray() + barray = blosc2.ObjectArray() barray.extend(BATCHES) estore["/batch"] = barray restored = estore["/batch"] - assert isinstance(restored, blosc2.BatchArray) + assert isinstance(restored, blosc2.ObjectArray) assert [batch[:] for batch in restored] == BATCHES -def test_batcharray_in_dict_store(): - path = "test_batcharray_store.b2z" +def test_objectarray_in_dict_store(): + path = "test_objectarray_store.b2z" blosc2.remove_urlpath(path) with blosc2.DictStore(path, mode="w", threshold=1) as dstore: - barray = blosc2.BatchArray() + barray = blosc2.ObjectArray() barray.extend(BATCHES) dstore["/batch"] = barray with blosc2.DictStore(path, mode="r") as dstore: restored = dstore["/batch"] - assert isinstance(restored, blosc2.BatchArray) + assert isinstance(restored, blosc2.ObjectArray) assert [batch[:] for batch in restored] == BATCHES blosc2.remove_urlpath(path) From bf1b935c0b26b22c8bcfd02076942267ca62de4d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 17:05:00 +0100 Subject: [PATCH 29/69] Remove legacy fallback --- src/blosc2/object_array.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/blosc2/object_array.py b/src/blosc2/object_array.py index decffc18..21091ac7 100644 --- a/src/blosc2/object_array.py +++ b/src/blosc2/object_array.py @@ -232,14 +232,7 @@ def _load_layout(self) -> None: return if len(self) == 0: return - # Legacy fallback: one object per VL block. - first_nbytes, _, nblocks = blosc2.get_cbuffer_sizes(self.schunk.get_lazychunk(0)) - if nblocks <= 0 or first_nbytes <= 0: - return - self._chunksize = nblocks - self._blocksize = 1 - self._layout_format = "legacy_vlblocks" - self._store_layout() + raise ValueError("ObjectArray layout metadata is missing") def _store_layout(self) -> None: if self._chunksize is None or self.mode == "r": @@ -349,8 +342,6 @@ def _decode_blocks(self, nchunk: int) -> list[list[Any]]: block_payloads = blosc2.blosc2_ext.vldecompress( self.schunk.get_chunk(nchunk), **self._vl_dparams_kwargs() ) - if self._layout_format == "legacy_vlblocks": - return [[msgpack_unpackb(payload)] for payload in block_payloads] return [msgpack_unpackb(payload) for payload in block_payloads] def _get_batch(self, index: int) -> Batch: From 93378e50b26c2b2512a3e69f1782cca3578d9e53 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 17:06:00 +0100 Subject: [PATCH 30/69] Update to latest c-blosc2 --- CMakeLists.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f053288..133e06dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,9 +118,9 @@ else() set(BLOSC_INSTALL ON) include(FetchContent) FetchContent_Declare(blosc2 - #GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - #GIT_TAG 7f6f1204784404099c767371245bd12cd4570c7c # variable-length chunks/blocks - SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 + GIT_REPOSITORY https://github.com/Blosc/c-blosc2 + GIT_TAG fb31a6ab43db2a26ba6d1c690166988b680c3fd7 # variable-length chunks/blocks + # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) include_directories("${blosc2_SOURCE_DIR}/include") From 99be79772bcf32455cf4b9807a764d0dad18a1b9 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 17:25:39 +0100 Subject: [PATCH 31/69] ObjectArray -> ObjectStore --- src/blosc2/__init__.py | 5 +- src/blosc2/core.py | 10 +- src/blosc2/dict_store.py | 18 +-- src/blosc2/embed_store.py | 10 +- .../{object_array.py => object_store.py} | 69 +++++------ src/blosc2/schunk.py | 8 +- src/blosc2/tree_store.py | 6 +- ...t_object_array.py => test_object_store.py} | 110 +++++++++--------- 8 files changed, 114 insertions(+), 122 deletions(-) rename src/blosc2/{object_array.py => object_store.py} (90%) rename tests/{test_object_array.py => test_object_store.py} (81%) diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 1f058757..e750d9d1 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -535,7 +535,7 @@ def _raise(exc): from .embed_store import EmbedStore, estore_from_cframe from .dict_store import DictStore from .tree_store import TreeStore -from .object_array import Batch, ObjectArray, objectarray_from_cframe +from .object_store import Batch, ObjectStore from .vlarray import VLArray, vlarray_from_cframe from .c2array import c2context, C2Array, URLPath @@ -721,7 +721,7 @@ def _raise(exc): "C2Array", "CParams", "Batch", - "ObjectArray", + "ObjectStore", # Enums "Codec", "DParams", @@ -944,7 +944,6 @@ def _raise(exc): "validate_expr", "var", "vecdot", - "objectarray_from_cframe", "vlarray_from_cframe", "where", "zeros", diff --git a/src/blosc2/core.py b/src/blosc2/core.py index 6e37b139..c37fb1b1 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1918,9 +1918,9 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True -) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.ObjectArray | blosc2.VLArray: +) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.ObjectStore | blosc2.VLArray: """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`ObjectArray ` or :ref:`VLArray ` instance + :ref:`ObjectStore ` or :ref:`VLArray ` instance from a contiguous frame buffer. Parameters @@ -1938,7 +1938,7 @@ def from_cframe( Returns ------- out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`ObjectArray ` or :ref:`VLArray ` + :ref:`ObjectStore ` or :ref:`VLArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1952,8 +1952,8 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) - if "objectarray" in schunk.meta: - return blosc2.objectarray_from_cframe(cframe, copy=copy) + if "objectstore" in schunk.meta: + return blosc2.ObjectStore(_from_schunk=schunk_from_cframe(cframe, copy=copy)) if "vlarray" in schunk.meta: return blosc2.vlarray_from_cframe(cframe, copy=copy) if "b2nd" in schunk.meta: diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 8ed2fac9..018c9fe8 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -249,25 +249,25 @@ def estore(self) -> EmbedStore: return self._estore @staticmethod - def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> int: - if isinstance(value, (blosc2.VLArray, blosc2.ObjectArray)): + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> int: + if isinstance(value, (blosc2.VLArray, blosc2.ObjectStore)): return value.schunk.nbytes return value.nbytes @staticmethod - def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> bool: - return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.ObjectArray)) and bool( + def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> bool: + return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.ObjectStore)) and bool( getattr(value, "urlpath", None) ) @staticmethod - def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray) -> str: + def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" return ".b2f" def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore ) -> None: """Add a node to the DictStore.""" if isinstance(value, np.ndarray): @@ -294,7 +294,7 @@ def __setitem__( if hasattr(value, "save"): value.save(urlpath=dest_path) else: - # SChunk, VLArray and ObjectArray can all be persisted via their cframe. + # SChunk, VLArray and ObjectStore can all be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -314,7 +314,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | C2Array: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: @@ -346,7 +346,7 @@ def __getitem__( def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | C2Array | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index e54967fb..7a062b52 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -174,7 +174,7 @@ def _ensure_capacity(self, needed_bytes: int) -> None: self._store.resize((new_size,)) def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore ) -> None: """Add a node to the embed store.""" if self.mode == "r": @@ -198,7 +198,7 @@ def __setitem__( self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -216,7 +216,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | bl def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -243,12 +243,12 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectArray]]: + def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/object_array.py b/src/blosc2/object_store.py similarity index 90% rename from src/blosc2/object_array.py rename to src/blosc2/object_store.py index 21091ac7..f7ed8b47 100644 --- a/src/blosc2/object_array.py +++ b/src/blosc2/object_store.py @@ -18,7 +18,7 @@ from blosc2.info import InfoReporter, format_nbytes_info _OBJECTARRAY_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} -_OBJECTARRAY_LAYOUT_KEY = "objectarray" +_OBJECTARRAY_LAYOUT_KEY = "objectstore" def _check_serialized_size(buffer: bytes) -> None: @@ -27,9 +27,9 @@ def _check_serialized_size(buffer: bytes) -> None: class Batch(Sequence[Any]): - """A lazy sequence of Python objects stored in one ObjectArray chunk.""" + """A lazy sequence of Python objects stored in one ObjectStore chunk.""" - def __init__(self, parent: ObjectArray, nchunk: int, lazychunk: bytes) -> None: + def __init__(self, parent: ObjectStore, nchunk: int, lazychunk: bytes) -> None: self._parent = parent self._nchunk = nchunk self._lazychunk = lazychunk @@ -58,7 +58,7 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: index = self._normalize_index(index) blocksize = self._parent.blocksize if blocksize is None: - raise RuntimeError("ObjectArray blocksize is not initialized") + raise RuntimeError("ObjectStore blocksize is not initialized") block_index, item_index = divmod(index, blocksize) return blocks[block_index][item_index] @@ -92,7 +92,7 @@ def __repr__(self) -> str: return f"Batch(len={len(self)}, nbytes={self.nbytes}, cbytes={self.cbytes})" -class ObjectArray: +class ObjectStore: """A batched variable-length array backed by an :class:`blosc2.SChunk`.""" @staticmethod @@ -109,14 +109,14 @@ def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: - # ObjectArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectStore stores many small serialized payloads, where Zstd dicts help materially. cparams.use_dict = True else: cparams["typesize"] = 1 codec = cparams.get("codec", blosc2.Codec.ZSTD) clevel = cparams.get("clevel", 5) if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: - # ObjectArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectStore stores many small serialized payloads, where Zstd dicts help materially. cparams["use_dict"] = True return cparams @@ -142,9 +142,9 @@ def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any @staticmethod def _validate_storage(storage: blosc2.Storage) -> None: if storage.mmap_mode not in (None, "r"): - raise ValueError("For ObjectArray containers, mmap_mode must be None or 'r'") + raise ValueError("For ObjectStore containers, mmap_mode must be None or 'r'") if storage.mmap_mode == "r" and storage.mode != "r": - raise ValueError("For ObjectArray containers, mmap_mode='r' requires mode='r'") + raise ValueError("For ObjectStore containers, mmap_mode='r' requires mode='r'") def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.schunk = schunk @@ -197,7 +197,7 @@ def __init__( if kwargs: unexpected = ", ".join(sorted(kwargs)) - raise ValueError(f"Unsupported ObjectArray keyword argument(s): {unexpected}") + raise ValueError(f"Unsupported ObjectStore keyword argument(s): {unexpected}") self._validate_storage(storage) cparams = self._set_typesize_one(cparams) @@ -209,7 +209,7 @@ def __init__( return fixed_meta = dict(storage.meta or {}) - fixed_meta["objectarray"] = dict(_OBJECTARRAY_META) + fixed_meta["objectstore"] = dict(_OBJECTARRAY_META) storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) @@ -217,8 +217,8 @@ def __init__( self._store_layout() def _validate_tag(self) -> None: - if "objectarray" not in self.schunk.meta: - raise ValueError("The supplied SChunk is not tagged as an ObjectArray") + if "objectstore" not in self.schunk.meta: + raise ValueError("The supplied SChunk is not tagged as an ObjectStore") def _load_layout(self) -> None: layout = None @@ -232,7 +232,7 @@ def _load_layout(self) -> None: return if len(self) == 0: return - raise ValueError("ObjectArray layout metadata is missing") + raise ValueError("ObjectStore layout metadata is missing") def _store_layout(self) -> None: if self._chunksize is None or self.mode == "r": @@ -248,20 +248,20 @@ def _store_layout(self) -> None: def _check_writable(self) -> None: if self.mode == "r": - raise ValueError("Cannot modify an ObjectArray opened in read-only mode") + raise ValueError("Cannot modify an ObjectStore opened in read-only mode") def _normalize_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("ObjectArray indices must be integers") + raise TypeError("ObjectStore indices must be integers") if index < 0: index += len(self) if index < 0 or index >= len(self): - raise IndexError("ObjectArray index out of range") + raise IndexError("ObjectStore index out of range") return index def _normalize_insert_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("ObjectArray indices must be integers") + raise TypeError("ObjectStore indices must be integers") if index < 0: index += len(self) if index < 0: @@ -278,19 +278,19 @@ def _copy_meta(self) -> dict[str, Any]: def _normalize_batch(self, value: object) -> list[Any]: if isinstance(value, (str, bytes, bytearray, memoryview)): - raise TypeError("ObjectArray entries must be sequences of Python objects") + raise TypeError("ObjectStore entries must be sequences of Python objects") if not isinstance(value, Sequence): - raise TypeError("ObjectArray entries must be sequences of Python objects") + raise TypeError("ObjectStore entries must be sequences of Python objects") values = list(value) if len(values) == 0: - raise ValueError("ObjectArray entries cannot be empty") + raise ValueError("ObjectStore entries cannot be empty") return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: if self._chunksize is None: self._chunksize = len(batch) if len(batch) != self._chunksize: - raise ValueError(f"ObjectArray entries must contain exactly {self._chunksize} objects") + raise ValueError(f"ObjectStore entries must contain exactly {self._chunksize} objects") if self._blocksize is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] self._blocksize = self._guess_blocksize(payload_sizes) @@ -298,7 +298,7 @@ def _ensure_layout_for_batch(self, batch: list[Any]) -> None: def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: - raise ValueError("ObjectArray entries cannot be empty") + raise ValueError("ObjectStore entries cannot be empty") l2_cache_size = blosc2.cpu_info.get("l2_cache_size") if not isinstance(l2_cache_size, int) or l2_cache_size <= 0: return len(payload_sizes) @@ -331,7 +331,7 @@ def _vl_dparams_kwargs(self) -> dict[str, Any]: def _compress_batch(self, batch: list[Any]) -> bytes: if self._blocksize is None: - raise RuntimeError("ObjectArray blocksize is not initialized") + raise RuntimeError("ObjectStore blocksize is not initialized") blocks = [ self._serialize_block(batch[i : i + self._blocksize]) for i in range(0, len(batch), self._blocksize) @@ -381,7 +381,7 @@ def pop(self, index: int = -1) -> list[Any]: """Remove and return the batch at ``index``.""" self._check_writable() if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for ObjectArray") + raise NotImplementedError("Slicing is not supported for ObjectStore") index = self._normalize_index(index) value = self[index][:] self.schunk.delete_chunk(index) @@ -507,12 +507,12 @@ def contiguous(self) -> bool: @property def info(self) -> InfoReporter: - """Print information about this ObjectArray.""" + """Print information about this ObjectStore.""" return InfoReporter(self) @property def info_items(self) -> list: - """A list of tuples with summary information about this ObjectArray.""" + """A list of tuples with summary information about this ObjectStore.""" batch_lengths = self._batch_lengths() nitems = sum(batch_lengths) avg_batch_len = nitems / len(batch_lengths) if batch_lengths else 0.0 @@ -535,7 +535,7 @@ def info_items(self) -> list: def to_cframe(self) -> bytes: return self.schunk.to_cframe() - def copy(self, **kwargs: Any) -> ObjectArray: + def copy(self, **kwargs: Any) -> ObjectStore: """Create a copy of the container with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") @@ -551,25 +551,18 @@ def copy(self, **kwargs: Any) -> ObjectArray: if "urlpath" in kwargs and "mode" not in kwargs: kwargs["mode"] = "w" - out = ObjectArray(**kwargs) + out = ObjectStore(**kwargs) if "storage" not in kwargs and len(self.vlmeta) > 0: for key, value in self.vlmeta.getall().items(): out.vlmeta[key] = value out.extend(self) return out - def __enter__(self) -> ObjectArray: + def __enter__(self) -> ObjectStore: return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: return False def __repr__(self) -> str: - return f"ObjectArray(len={len(self)}, urlpath={self.urlpath!r})" - - -def objectarray_from_cframe(cframe: bytes, copy: bool = True) -> ObjectArray: - """Deserialize a CFrame buffer into a :class:`ObjectArray`.""" - - schunk = blosc2.schunk_from_cframe(cframe, copy=copy) - return ObjectArray(_from_schunk=schunk) + return f"ObjectStore(len={len(self)}, urlpath={self.urlpath!r})" diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 50428dd0..aca106d1 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -1621,10 +1621,10 @@ def _process_opened_object(res): return VLArray(_from_schunk=getattr(res, "schunk", res)) - if "objectarray" in meta: - from blosc2.object_array import ObjectArray + if "objectstore" in meta: + from blosc2.object_store import ObjectStore - return ObjectArray(_from_schunk=getattr(res, "schunk", res)) + return ObjectStore(_from_schunk=getattr(res, "schunk", res)) if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta: return blosc2._open_lazyarray(res) @@ -1637,7 +1637,7 @@ def open( ) -> ( blosc2.SChunk | blosc2.NDArray - | blosc2.ObjectArray + | blosc2.ObjectStore | blosc2.VLArray | blosc2.C2Array | blosc2.LazyArray diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 3efcc19f..9287b4fc 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -227,7 +227,7 @@ def _validate_key(self, key: str) -> str: return key def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectArray + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore ) -> None: """Add a node with hierarchical key validation. @@ -272,7 +272,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.ObjectArray | TreeStore: + ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.ObjectStore | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -286,7 +286,7 @@ def __getitem__( Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.ObjectArray or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.ObjectStore or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. diff --git a/tests/test_object_array.py b/tests/test_object_store.py similarity index 81% rename from tests/test_object_array.py rename to tests/test_object_store.py index 4215801f..b8d1112d 100644 --- a/tests/test_object_array.py +++ b/tests/test_object_store.py @@ -32,15 +32,15 @@ def _storage(contiguous, urlpath, mode="w"): [ (False, None), (True, None), - (True, "test_objectarray.b2frame"), - (False, "test_objectarray_s.b2frame"), + (True, "test_objectstore.b2frame"), + (False, "test_objectstore_s.b2frame"), ], ) -def test_objectarray_roundtrip(contiguous, urlpath): +def test_objectstore_roundtrip(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) - assert barray.meta["objectarray"]["serializer"] == "msgpack" + barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) + assert barray.meta["objectstore"]["serializer"] == "msgpack" for i, batch in enumerate(BATCHES, start=1): assert barray.append(batch) == i @@ -82,7 +82,7 @@ def test_objectarray_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert isinstance(reopened, blosc2.ObjectArray) + assert isinstance(reopened, blosc2.ObjectStore) assert reopened.chunksize == barray.chunksize assert reopened.blocksize == barray.blocksize assert [batch[:] for batch in reopened] == expected @@ -110,14 +110,14 @@ def test_objectarray_roundtrip(contiguous, urlpath): if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") - assert isinstance(reopened_mmap, blosc2.ObjectArray) + assert isinstance(reopened_mmap, blosc2.ObjectStore) assert [batch[:] for batch in reopened_mmap] == expected blosc2.remove_urlpath(urlpath) -def test_objectarray_from_cframe(): - barray = blosc2.ObjectArray() +def test_objectstore_from_cframe(): + barray = blosc2.ObjectStore() barray.extend(BATCHES) barray.insert(1, ["inserted", True, None]) del barray[3] @@ -126,16 +126,16 @@ def test_objectarray_from_cframe(): del expected[3] restored = blosc2.from_cframe(barray.to_cframe()) - assert isinstance(restored, blosc2.ObjectArray) + assert isinstance(restored, blosc2.ObjectStore) assert [batch[:] for batch in restored] == expected - restored2 = blosc2.objectarray_from_cframe(barray.to_cframe()) - assert isinstance(restored2, blosc2.ObjectArray) + restored2 = blosc2.from_cframe(barray.to_cframe()) + assert isinstance(restored2, blosc2.ObjectStore) assert [batch[:] for batch in restored2] == expected -def test_objectarray_info(): - barray = blosc2.ObjectArray() +def test_objectstore_info(): + barray = blosc2.ObjectStore() barray.extend(BATCHES) assert barray.typesize == 1 @@ -143,7 +143,7 @@ def test_objectarray_info(): assert barray.urlpath == barray.schunk.urlpath items = dict(barray.info_items) - assert items["type"] == "ObjectArray" + assert items["type"] == "ObjectStore" assert items["nbatches"] == len(BATCHES) assert items["chunksize"] == len(BATCHES[0]) assert items["blocksize"] == barray.blocksize @@ -160,37 +160,37 @@ def test_objectarray_info(): text = repr(barray.info) assert "type" in text - assert "ObjectArray" in text + assert "ObjectStore" in text assert "batch_len_avg" in text -def test_objectarray_zstd_uses_dict_by_default(): - barray = blosc2.ObjectArray() +def test_objectstore_zstd_uses_dict_by_default(): + barray = blosc2.ObjectStore() assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is True -def test_objectarray_explicit_chunksize_blocksize(): - barray = blosc2.ObjectArray(chunksize=3, blocksize=2) +def test_objectstore_explicit_chunksize_blocksize(): + barray = blosc2.ObjectStore(chunksize=3, blocksize=2) assert barray.chunksize == 3 assert barray.blocksize == 2 barray.append([1, 2, 3]) assert [batch[:] for batch in barray] == [[1, 2, 3]] -def test_objectarray_respects_explicit_use_dict_and_non_zstd(): - barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) +def test_objectstore_respects_explicit_use_dict_and_non_zstd(): + barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 assert barray.cparams.use_dict is False - barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is False - barray = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) assert barray.cparams.use_dict is False - barray = blosc2.ObjectArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + barray = blosc2.ObjectStore(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) assert barray.cparams.use_dict is False @@ -231,14 +231,14 @@ def test_vlcompress_small_blocks_roundtrip(): assert out == payloads -def test_objectarray_constructor_kwargs(): - urlpath = "test_objectarray_kwargs.b2frame" +def test_objectstore_constructor_kwargs(): + urlpath = "test_objectstore_kwargs.b2frame" blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) + barray = blosc2.ObjectStore(urlpath=urlpath, mode="w", contiguous=True) barray.extend(BATCHES) - reopened = blosc2.ObjectArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + reopened = blosc2.ObjectStore(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") assert [batch[:] for batch in reopened] == BATCHES blosc2.remove_urlpath(urlpath) @@ -249,14 +249,14 @@ def test_objectarray_constructor_kwargs(): [ (False, None), (True, None), - (True, "test_objectarray_list_ops.b2frame"), - (False, "test_objectarray_list_ops_s.b2frame"), + (True, "test_objectstore_list_ops.b2frame"), + (False, "test_objectstore_list_ops_s.b2frame"), ], ) -def test_objectarray_list_like_ops(contiguous, urlpath): +def test_objectstore_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] assert barray.pop() == [7, 8, 9] @@ -282,15 +282,15 @@ def test_objectarray_list_like_ops(contiguous, urlpath): [ (False, None), (True, None), - (True, "test_objectarray_slices.b2frame"), - (False, "test_objectarray_slices_s.b2frame"), + (True, "test_objectstore_slices.b2frame"), + (False, "test_objectstore_slices_s.b2frame"), ], ) -def test_objectarray_slices(contiguous, urlpath): +def test_objectstore_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) expected = [[i, i + 100, i + 200] for i in range(8)] - barray = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) barray.extend(expected) assert [batch[:] for batch in barray[1:6:2]] == expected[1:6:2] @@ -319,8 +319,8 @@ def test_objectarray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) -def test_objectarray_slice_errors(): - barray = blosc2.ObjectArray() +def test_objectstore_slice_errors(): + barray = blosc2.ObjectStore() barray.extend([[0], [1], [2], [3]]) with pytest.raises(ValueError, match="extended slice"): @@ -331,13 +331,13 @@ def test_objectarray_slice_errors(): _ = barray[::0] -def test_objectarray_copy(): - urlpath = "test_objectarray_copy.b2frame" - copy_path = "test_objectarray_copy_out.b2frame" +def test_objectstore_copy(): + urlpath = "test_objectstore_copy.b2frame" + copy_path = "test_objectstore_copy_out.b2frame" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) - original = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) + original = blosc2.ObjectStore(urlpath=urlpath, mode="w", contiguous=True) original.extend(BATCHES) original.insert(1, ["copy", True, 123]) @@ -362,7 +362,7 @@ def test_objectarray_copy(): @pytest.mark.parametrize(("contiguous", "nthreads"), [(False, 2), (True, 4)]) -def test_objectarray_multithreaded_inner_vl(contiguous, nthreads): +def test_objectstore_multithreaded_inner_vl(contiguous, nthreads): batches = [] for batch_id in range(24): batch = [] @@ -379,7 +379,7 @@ def test_objectarray_multithreaded_inner_vl(contiguous, nthreads): ) batches.append(batch) - barray = blosc2.ObjectArray( + barray = blosc2.ObjectStore( storage=blosc2.Storage(contiguous=contiguous), cparams=blosc2.CParams(typesize=1, nthreads=nthreads, codec=blosc2.Codec.ZSTD, clevel=5), dparams=blosc2.DParams(nthreads=nthreads), @@ -390,8 +390,8 @@ def test_objectarray_multithreaded_inner_vl(contiguous, nthreads): assert [barray[i][:] for i in range(len(barray))] == batches -def test_objectarray_validation_errors(): - barray = blosc2.ObjectArray() +def test_objectstore_validation_errors(): + barray = blosc2.ObjectStore() with pytest.raises(TypeError): barray.append("value") @@ -402,7 +402,7 @@ def test_objectarray_validation_errors(): with pytest.raises(IndexError): barray.delete(3) with pytest.raises(IndexError): - blosc2.ObjectArray().pop() + blosc2.ObjectStore().pop() barray.extend([[1, 2, 3]]) with pytest.raises(ValueError): barray.append([2, 3]) @@ -410,29 +410,29 @@ def test_objectarray_validation_errors(): barray.pop(slice(0, 1)) -def test_objectarray_in_embed_store(): +def test_objectstore_in_embed_store(): estore = blosc2.EmbedStore() - barray = blosc2.ObjectArray() + barray = blosc2.ObjectStore() barray.extend(BATCHES) estore["/batch"] = barray restored = estore["/batch"] - assert isinstance(restored, blosc2.ObjectArray) + assert isinstance(restored, blosc2.ObjectStore) assert [batch[:] for batch in restored] == BATCHES -def test_objectarray_in_dict_store(): - path = "test_objectarray_store.b2z" +def test_objectstore_in_dict_store(): + path = "test_objectstore_store.b2z" blosc2.remove_urlpath(path) with blosc2.DictStore(path, mode="w", threshold=1) as dstore: - barray = blosc2.ObjectArray() + barray = blosc2.ObjectStore() barray.extend(BATCHES) dstore["/batch"] = barray with blosc2.DictStore(path, mode="r") as dstore: restored = dstore["/batch"] - assert isinstance(restored, blosc2.ObjectArray) + assert isinstance(restored, blosc2.ObjectStore) assert [batch[:] for batch in restored] == BATCHES blosc2.remove_urlpath(path) From 244c86d74bf3eef9244108eaac26717e02a6158f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 20:06:45 +0100 Subject: [PATCH 32/69] chunksize -> batchsize --- src/blosc2/object_store.py | 52 +++++++++++++++++++++++--------------- tests/test_object_store.py | 17 ++++++++----- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/blosc2/object_store.py b/src/blosc2/object_store.py index f7ed8b47..9a5171e4 100644 --- a/src/blosc2/object_store.py +++ b/src/blosc2/object_store.py @@ -63,10 +63,10 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: return blocks[block_index][item_index] def __len__(self) -> int: - chunksize = self._parent.chunksize - if chunksize is None: + batchsize = self._parent.batchsize + if batchsize is None: return self._nblocks - return chunksize + return batchsize def __iter__(self) -> Iterator[Any]: for i in range(len(self)): @@ -174,17 +174,21 @@ def _make_storage(self) -> blosc2.Storage: def __init__( self, - chunksize: int | None = None, + batchsize: int | None = None, blocksize: int | None = None, _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: - self._chunksize: int | None = chunksize + if "chunksize" in kwargs: + if batchsize is not None: + raise ValueError("Cannot pass both `batchsize` and `chunksize`") + batchsize = kwargs.pop("chunksize") + self._batchsize: int | None = batchsize self._blocksize: int | None = blocksize self._layout_format: str | None = None if _from_schunk is not None: - if chunksize is not None or blocksize is not None: - raise ValueError("Cannot pass `chunksize` or `blocksize` together with `_from_schunk`") + if batchsize is not None or blocksize is not None: + raise ValueError("Cannot pass `batchsize` or `blocksize` together with `_from_schunk`") if kwargs: unexpected = ", ".join(sorted(kwargs)) raise ValueError(f"Cannot pass {unexpected} together with `_from_schunk`") @@ -213,7 +217,7 @@ def __init__( storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) - if self._chunksize is not None or self._blocksize is not None: + if self._batchsize is not None or self._blocksize is not None: self._store_layout() def _validate_tag(self) -> None: @@ -226,7 +230,7 @@ def _load_layout(self) -> None: if _OBJECTARRAY_LAYOUT_KEY in self.vlmeta: layout = self.vlmeta[_OBJECTARRAY_LAYOUT_KEY] if isinstance(layout, dict): - self._chunksize = layout.get("chunksize") + self._batchsize = layout.get("batchsize", layout.get("chunksize")) self._blocksize = layout.get("blocksize") self._layout_format = layout.get("format", "batched_vlblocks") return @@ -235,11 +239,11 @@ def _load_layout(self) -> None: raise ValueError("ObjectStore layout metadata is missing") def _store_layout(self) -> None: - if self._chunksize is None or self.mode == "r": + if self._batchsize is None or self.mode == "r": return layout = { "version": 1, - "chunksize": self._chunksize, + "batchsize": self._batchsize, "blocksize": self._blocksize, "format": self._layout_format or "batched_vlblocks", "sizing_policy": "l2_cache_prefix", @@ -287,10 +291,10 @@ def _normalize_batch(self, value: object) -> list[Any]: return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: - if self._chunksize is None: - self._chunksize = len(batch) - if len(batch) != self._chunksize: - raise ValueError(f"ObjectStore entries must contain exactly {self._chunksize} objects") + if self._batchsize is None: + self._batchsize = len(batch) + if len(batch) != self._batchsize: + raise ValueError(f"ObjectStore entries must contain exactly {self._batchsize} objects") if self._blocksize is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] self._blocksize = self._guess_blocksize(payload_sizes) @@ -348,8 +352,8 @@ def _get_batch(self, index: int) -> Batch: return Batch(self, index, self.schunk.get_lazychunk(index)) def _batch_lengths(self) -> list[int]: - if self.chunksize is not None: - return [self.chunksize for _ in range(len(self))] + if self.batchsize is not None: + return [self.batchsize for _ in range(len(self))] return [len(self[i]) for i in range(len(self))] def append(self, value: object) -> int: @@ -475,7 +479,11 @@ def dparams(self): @property def chunksize(self) -> int: - return self._chunksize + return self._batchsize + + @property + def batchsize(self) -> int: + return self._batchsize @property def blocksize(self) -> int: @@ -519,7 +527,7 @@ def info_items(self) -> list: return [ ("type", f"{self.__class__.__name__}"), ("nbatches", len(self)), - ("chunksize", self.chunksize), + ("batchsize", self.batchsize), ("blocksize", self.blocksize), ("nitems", nitems), ("batch_len_min", min(batch_lengths) if batch_lengths else 0), @@ -539,10 +547,14 @@ def copy(self, **kwargs: Any) -> ObjectStore: """Create a copy of the container with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") + if "chunksize" in kwargs: + if "batchsize" in kwargs: + raise ValueError("Cannot pass both `batchsize` and `chunksize` to copy") + kwargs["batchsize"] = kwargs.pop("chunksize") kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) - kwargs["chunksize"] = kwargs.get("chunksize", self.chunksize) + kwargs["batchsize"] = kwargs.get("batchsize", self.batchsize) kwargs["blocksize"] = kwargs.get("blocksize", self.blocksize) if "storage" not in kwargs: diff --git a/tests/test_object_store.py b/tests/test_object_store.py index b8d1112d..1e67bb6a 100644 --- a/tests/test_object_store.py +++ b/tests/test_object_store.py @@ -46,9 +46,9 @@ def test_objectstore_roundtrip(contiguous, urlpath): assert barray.append(batch) == i assert len(barray) == len(BATCHES) - assert barray.chunksize == len(BATCHES[0]) + assert barray.batchsize == len(BATCHES[0]) assert barray.blocksize is not None - assert 1 <= barray.blocksize <= barray.chunksize + assert 1 <= barray.blocksize <= barray.batchsize assert [batch[:] for batch in barray] == BATCHES with pytest.raises(ValueError): barray.append([1, 2]) @@ -83,7 +83,7 @@ def test_objectstore_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.ObjectStore) - assert reopened.chunksize == barray.chunksize + assert reopened.batchsize == barray.batchsize assert reopened.blocksize == barray.blocksize assert [batch[:] for batch in reopened] == expected with pytest.raises(ValueError): @@ -145,7 +145,7 @@ def test_objectstore_info(): items = dict(barray.info_items) assert items["type"] == "ObjectStore" assert items["nbatches"] == len(BATCHES) - assert items["chunksize"] == len(BATCHES[0]) + assert items["batchsize"] == len(BATCHES[0]) assert items["blocksize"] == barray.blocksize assert items["nitems"] == sum(len(batch) for batch in BATCHES) assert items["batch_len_min"] == 3 @@ -170,13 +170,18 @@ def test_objectstore_zstd_uses_dict_by_default(): assert barray.cparams.use_dict is True -def test_objectstore_explicit_chunksize_blocksize(): - barray = blosc2.ObjectStore(chunksize=3, blocksize=2) +def test_objectstore_explicit_batchsize_blocksize(): + barray = blosc2.ObjectStore(batchsize=3, blocksize=2) + assert barray.batchsize == 3 assert barray.chunksize == 3 assert barray.blocksize == 2 barray.append([1, 2, 3]) assert [batch[:] for batch in barray] == [[1, 2, 3]] + legacy = blosc2.ObjectStore(chunksize=3, blocksize=2) + assert legacy.batchsize == 3 + assert legacy.chunksize == 3 + def test_objectstore_respects_explicit_use_dict_and_non_zstd(): barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) From e999d1d20d2beaf5701311262f2ff51c9f9acfab Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 20:09:10 +0100 Subject: [PATCH 33/69] batchsize is always constant --- src/blosc2/object_store.py | 12 +----------- tests/test_object_store.py | 5 +---- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/blosc2/object_store.py b/src/blosc2/object_store.py index 9a5171e4..b6b54d14 100644 --- a/src/blosc2/object_store.py +++ b/src/blosc2/object_store.py @@ -351,11 +351,6 @@ def _decode_blocks(self, nchunk: int) -> list[list[Any]]: def _get_batch(self, index: int) -> Batch: return Batch(self, index, self.schunk.get_lazychunk(index)) - def _batch_lengths(self) -> list[int]: - if self.batchsize is not None: - return [self.batchsize for _ in range(len(self))] - return [len(self[i]) for i in range(len(self))] - def append(self, value: object) -> int: """Append one batch and return the new number of entries.""" self._check_writable() @@ -521,18 +516,13 @@ def info(self) -> InfoReporter: @property def info_items(self) -> list: """A list of tuples with summary information about this ObjectStore.""" - batch_lengths = self._batch_lengths() - nitems = sum(batch_lengths) - avg_batch_len = nitems / len(batch_lengths) if batch_lengths else 0.0 + nitems = len(self) * self.batchsize if self.batchsize is not None else 0 return [ ("type", f"{self.__class__.__name__}"), ("nbatches", len(self)), ("batchsize", self.batchsize), ("blocksize", self.blocksize), ("nitems", nitems), - ("batch_len_min", min(batch_lengths) if batch_lengths else 0), - ("batch_len_max", max(batch_lengths) if batch_lengths else 0), - ("batch_len_avg", f"{avg_batch_len:.2f}"), ("nbytes", format_nbytes_info(self.nbytes)), ("cbytes", format_nbytes_info(self.cbytes)), ("cratio", f"{self.cratio:.2f}"), diff --git a/tests/test_object_store.py b/tests/test_object_store.py index 1e67bb6a..63190614 100644 --- a/tests/test_object_store.py +++ b/tests/test_object_store.py @@ -148,9 +148,6 @@ def test_objectstore_info(): assert items["batchsize"] == len(BATCHES[0]) assert items["blocksize"] == barray.blocksize assert items["nitems"] == sum(len(batch) for batch in BATCHES) - assert items["batch_len_min"] == 3 - assert items["batch_len_max"] == 3 - assert items["batch_len_avg"] == "3.00" assert "urlpath" not in items assert "contiguous" not in items assert "typesize" not in items @@ -161,7 +158,7 @@ def test_objectstore_info(): text = repr(barray.info) assert "type" in text assert "ObjectStore" in text - assert "batch_len_avg" in text + assert "batchsize" in text def test_objectstore_zstd_uses_dict_by_default(): From afc84365dcdc5d7fc81c5b3858987a28ca253a03 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 20:16:13 +0100 Subject: [PATCH 34/69] ObjectStore -> BatchStore --- src/blosc2/__init__.py | 4 +- .../{object_store.py => batch_store.py} | 85 ++++++------- src/blosc2/core.py | 10 +- src/blosc2/dict_store.py | 18 +-- src/blosc2/embed_store.py | 10 +- src/blosc2/schunk.py | 8 +- src/blosc2/tree_store.py | 6 +- ...st_object_store.py => test_batch_store.py} | 113 +++++++++--------- 8 files changed, 118 insertions(+), 136 deletions(-) rename src/blosc2/{object_store.py => batch_store.py} (86%) rename tests/{test_object_store.py => test_batch_store.py} (79%) diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index e750d9d1..e32b2f48 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -535,7 +535,7 @@ def _raise(exc): from .embed_store import EmbedStore, estore_from_cframe from .dict_store import DictStore from .tree_store import TreeStore -from .object_store import Batch, ObjectStore +from .batch_store import Batch, BatchStore from .vlarray import VLArray, vlarray_from_cframe from .c2array import c2context, C2Array, URLPath @@ -721,7 +721,7 @@ def _raise(exc): "C2Array", "CParams", "Batch", - "ObjectStore", + "BatchStore", # Enums "Codec", "DParams", diff --git a/src/blosc2/object_store.py b/src/blosc2/batch_store.py similarity index 86% rename from src/blosc2/object_store.py rename to src/blosc2/batch_store.py index b6b54d14..cb7e31fb 100644 --- a/src/blosc2/object_store.py +++ b/src/blosc2/batch_store.py @@ -17,8 +17,8 @@ from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_OBJECTARRAY_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} -_OBJECTARRAY_LAYOUT_KEY = "objectstore" +_BATCHSTORE_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} +_BATCHSTORE_LAYOUT_KEY = "batchstore" def _check_serialized_size(buffer: bytes) -> None: @@ -27,9 +27,9 @@ def _check_serialized_size(buffer: bytes) -> None: class Batch(Sequence[Any]): - """A lazy sequence of Python objects stored in one ObjectStore chunk.""" + """A lazy sequence of Python objects stored in one BatchStore chunk.""" - def __init__(self, parent: ObjectStore, nchunk: int, lazychunk: bytes) -> None: + def __init__(self, parent: BatchStore, nchunk: int, lazychunk: bytes) -> None: self._parent = parent self._nchunk = nchunk self._lazychunk = lazychunk @@ -58,7 +58,7 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: index = self._normalize_index(index) blocksize = self._parent.blocksize if blocksize is None: - raise RuntimeError("ObjectStore blocksize is not initialized") + raise RuntimeError("BatchStore blocksize is not initialized") block_index, item_index = divmod(index, blocksize) return blocks[block_index][item_index] @@ -92,7 +92,7 @@ def __repr__(self) -> str: return f"Batch(len={len(self)}, nbytes={self.nbytes}, cbytes={self.cbytes})" -class ObjectStore: +class BatchStore: """A batched variable-length array backed by an :class:`blosc2.SChunk`.""" @staticmethod @@ -109,14 +109,14 @@ def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: - # ObjectStore stores many small serialized payloads, where Zstd dicts help materially. + # BatchStore stores many small serialized payloads, where Zstd dicts help materially. cparams.use_dict = True else: cparams["typesize"] = 1 codec = cparams.get("codec", blosc2.Codec.ZSTD) clevel = cparams.get("clevel", 5) if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: - # ObjectStore stores many small serialized payloads, where Zstd dicts help materially. + # BatchStore stores many small serialized payloads, where Zstd dicts help materially. cparams["use_dict"] = True return cparams @@ -142,9 +142,9 @@ def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any @staticmethod def _validate_storage(storage: blosc2.Storage) -> None: if storage.mmap_mode not in (None, "r"): - raise ValueError("For ObjectStore containers, mmap_mode must be None or 'r'") + raise ValueError("For BatchStore containers, mmap_mode must be None or 'r'") if storage.mmap_mode == "r" and storage.mode != "r": - raise ValueError("For ObjectStore containers, mmap_mode='r' requires mode='r'") + raise ValueError("For BatchStore containers, mmap_mode='r' requires mode='r'") def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.schunk = schunk @@ -179,10 +179,6 @@ def __init__( _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: - if "chunksize" in kwargs: - if batchsize is not None: - raise ValueError("Cannot pass both `batchsize` and `chunksize`") - batchsize = kwargs.pop("chunksize") self._batchsize: int | None = batchsize self._blocksize: int | None = blocksize self._layout_format: str | None = None @@ -201,7 +197,7 @@ def __init__( if kwargs: unexpected = ", ".join(sorted(kwargs)) - raise ValueError(f"Unsupported ObjectStore keyword argument(s): {unexpected}") + raise ValueError(f"Unsupported BatchStore keyword argument(s): {unexpected}") self._validate_storage(storage) cparams = self._set_typesize_one(cparams) @@ -213,7 +209,7 @@ def __init__( return fixed_meta = dict(storage.meta or {}) - fixed_meta["objectstore"] = dict(_OBJECTARRAY_META) + fixed_meta["batchstore"] = dict(_BATCHSTORE_META) storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) @@ -221,22 +217,22 @@ def __init__( self._store_layout() def _validate_tag(self) -> None: - if "objectstore" not in self.schunk.meta: - raise ValueError("The supplied SChunk is not tagged as an ObjectStore") + if "batchstore" not in self.schunk.meta: + raise ValueError("The supplied SChunk is not tagged as a BatchStore") def _load_layout(self) -> None: layout = None self._layout_format = None - if _OBJECTARRAY_LAYOUT_KEY in self.vlmeta: - layout = self.vlmeta[_OBJECTARRAY_LAYOUT_KEY] + if _BATCHSTORE_LAYOUT_KEY in self.vlmeta: + layout = self.vlmeta[_BATCHSTORE_LAYOUT_KEY] if isinstance(layout, dict): - self._batchsize = layout.get("batchsize", layout.get("chunksize")) + self._batchsize = layout["batchsize"] self._blocksize = layout.get("blocksize") self._layout_format = layout.get("format", "batched_vlblocks") return if len(self) == 0: return - raise ValueError("ObjectStore layout metadata is missing") + raise ValueError("BatchStore layout metadata is missing") def _store_layout(self) -> None: if self._batchsize is None or self.mode == "r": @@ -248,24 +244,24 @@ def _store_layout(self) -> None: "format": self._layout_format or "batched_vlblocks", "sizing_policy": "l2_cache_prefix", } - self.vlmeta[_OBJECTARRAY_LAYOUT_KEY] = layout + self.vlmeta[_BATCHSTORE_LAYOUT_KEY] = layout def _check_writable(self) -> None: if self.mode == "r": - raise ValueError("Cannot modify an ObjectStore opened in read-only mode") + raise ValueError("Cannot modify a BatchStore opened in read-only mode") def _normalize_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("ObjectStore indices must be integers") + raise TypeError("BatchStore indices must be integers") if index < 0: index += len(self) if index < 0 or index >= len(self): - raise IndexError("ObjectStore index out of range") + raise IndexError("BatchStore index out of range") return index def _normalize_insert_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("ObjectStore indices must be integers") + raise TypeError("BatchStore indices must be integers") if index < 0: index += len(self) if index < 0: @@ -282,19 +278,19 @@ def _copy_meta(self) -> dict[str, Any]: def _normalize_batch(self, value: object) -> list[Any]: if isinstance(value, (str, bytes, bytearray, memoryview)): - raise TypeError("ObjectStore entries must be sequences of Python objects") + raise TypeError("BatchStore entries must be sequences of Python objects") if not isinstance(value, Sequence): - raise TypeError("ObjectStore entries must be sequences of Python objects") + raise TypeError("BatchStore entries must be sequences of Python objects") values = list(value) if len(values) == 0: - raise ValueError("ObjectStore entries cannot be empty") + raise ValueError("BatchStore entries cannot be empty") return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: if self._batchsize is None: self._batchsize = len(batch) if len(batch) != self._batchsize: - raise ValueError(f"ObjectStore entries must contain exactly {self._batchsize} objects") + raise ValueError(f"BatchStore entries must contain exactly {self._batchsize} objects") if self._blocksize is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] self._blocksize = self._guess_blocksize(payload_sizes) @@ -302,7 +298,7 @@ def _ensure_layout_for_batch(self, batch: list[Any]) -> None: def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: - raise ValueError("ObjectStore entries cannot be empty") + raise ValueError("BatchStore entries cannot be empty") l2_cache_size = blosc2.cpu_info.get("l2_cache_size") if not isinstance(l2_cache_size, int) or l2_cache_size <= 0: return len(payload_sizes) @@ -335,7 +331,7 @@ def _vl_dparams_kwargs(self) -> dict[str, Any]: def _compress_batch(self, batch: list[Any]) -> bytes: if self._blocksize is None: - raise RuntimeError("ObjectStore blocksize is not initialized") + raise RuntimeError("BatchStore blocksize is not initialized") blocks = [ self._serialize_block(batch[i : i + self._blocksize]) for i in range(0, len(batch), self._blocksize) @@ -380,7 +376,7 @@ def pop(self, index: int = -1) -> list[Any]: """Remove and return the batch at ``index``.""" self._check_writable() if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for ObjectStore") + raise NotImplementedError("Slicing is not supported for BatchStore") index = self._normalize_index(index) value = self[index][:] self.schunk.delete_chunk(index) @@ -472,10 +468,6 @@ def cparams(self): def dparams(self): return self.schunk.dparams - @property - def chunksize(self) -> int: - return self._batchsize - @property def batchsize(self) -> int: return self._batchsize @@ -510,12 +502,12 @@ def contiguous(self) -> bool: @property def info(self) -> InfoReporter: - """Print information about this ObjectStore.""" + """Print information about this BatchStore.""" return InfoReporter(self) @property def info_items(self) -> list: - """A list of tuples with summary information about this ObjectStore.""" + """A list of tuples with summary information about this BatchStore.""" nitems = len(self) * self.batchsize if self.batchsize is not None else 0 return [ ("type", f"{self.__class__.__name__}"), @@ -533,15 +525,10 @@ def info_items(self) -> list: def to_cframe(self) -> bytes: return self.schunk.to_cframe() - def copy(self, **kwargs: Any) -> ObjectStore: + def copy(self, **kwargs: Any) -> BatchStore: """Create a copy of the container with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") - if "chunksize" in kwargs: - if "batchsize" in kwargs: - raise ValueError("Cannot pass both `batchsize` and `chunksize` to copy") - kwargs["batchsize"] = kwargs.pop("chunksize") - kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) kwargs["batchsize"] = kwargs.get("batchsize", self.batchsize) @@ -553,18 +540,18 @@ def copy(self, **kwargs: Any) -> ObjectStore: if "urlpath" in kwargs and "mode" not in kwargs: kwargs["mode"] = "w" - out = ObjectStore(**kwargs) + out = BatchStore(**kwargs) if "storage" not in kwargs and len(self.vlmeta) > 0: for key, value in self.vlmeta.getall().items(): out.vlmeta[key] = value out.extend(self) return out - def __enter__(self) -> ObjectStore: + def __enter__(self) -> BatchStore: return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: return False def __repr__(self) -> str: - return f"ObjectStore(len={len(self)}, urlpath={self.urlpath!r})" + return f"BatchStore(len={len(self)}, urlpath={self.urlpath!r})" diff --git a/src/blosc2/core.py b/src/blosc2/core.py index c37fb1b1..d574a21e 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1918,9 +1918,9 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True -) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.ObjectStore | blosc2.VLArray: +) -> blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.BatchStore | blosc2.VLArray: """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`ObjectStore ` or :ref:`VLArray ` instance + :ref:`BatchStore ` or :ref:`VLArray ` instance from a contiguous frame buffer. Parameters @@ -1938,7 +1938,7 @@ def from_cframe( Returns ------- out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`ObjectStore ` or :ref:`VLArray ` + :ref:`BatchStore ` or :ref:`VLArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1952,8 +1952,8 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) - if "objectstore" in schunk.meta: - return blosc2.ObjectStore(_from_schunk=schunk_from_cframe(cframe, copy=copy)) + if "batchstore" in schunk.meta: + return blosc2.BatchStore(_from_schunk=schunk_from_cframe(cframe, copy=copy)) if "vlarray" in schunk.meta: return blosc2.vlarray_from_cframe(cframe, copy=copy) if "b2nd" in schunk.meta: diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 018c9fe8..0cb5ef63 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -249,25 +249,25 @@ def estore(self) -> EmbedStore: return self._estore @staticmethod - def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> int: - if isinstance(value, (blosc2.VLArray, blosc2.ObjectStore)): + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> int: + if isinstance(value, (blosc2.VLArray, blosc2.BatchStore)): return value.schunk.nbytes return value.nbytes @staticmethod - def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> bool: - return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.ObjectStore)) and bool( + def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> bool: + return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.BatchStore)) and bool( getattr(value, "urlpath", None) ) @staticmethod - def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore) -> str: + def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" return ".b2f" def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore ) -> None: """Add a node to the DictStore.""" if isinstance(value, np.ndarray): @@ -294,7 +294,7 @@ def __setitem__( if hasattr(value, "save"): value.save(urlpath=dest_path) else: - # SChunk, VLArray and ObjectStore can all be persisted via their cframe. + # SChunk, VLArray and BatchStore can all be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -314,7 +314,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | C2Array: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: @@ -346,7 +346,7 @@ def __getitem__( def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | C2Array | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index 7a062b52..2497cad2 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -174,7 +174,7 @@ def _ensure_capacity(self, needed_bytes: int) -> None: self._store.resize((new_size,)) def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore ) -> None: """Add a node to the embed store.""" if self.mode == "r": @@ -198,7 +198,7 @@ def __setitem__( self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -216,7 +216,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | bl def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore | Any: + ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -243,12 +243,12 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.ObjectStore]]: + def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index aca106d1..b39524c6 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -1621,10 +1621,10 @@ def _process_opened_object(res): return VLArray(_from_schunk=getattr(res, "schunk", res)) - if "objectstore" in meta: - from blosc2.object_store import ObjectStore + if "batchstore" in meta: + from blosc2.batch_store import BatchStore - return ObjectStore(_from_schunk=getattr(res, "schunk", res)) + return BatchStore(_from_schunk=getattr(res, "schunk", res)) if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta: return blosc2._open_lazyarray(res) @@ -1637,7 +1637,7 @@ def open( ) -> ( blosc2.SChunk | blosc2.NDArray - | blosc2.ObjectStore + | blosc2.BatchStore | blosc2.VLArray | blosc2.C2Array | blosc2.LazyArray diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 9287b4fc..7f4fe6ba 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -227,7 +227,7 @@ def _validate_key(self, key: str) -> str: return key def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.ObjectStore + self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore ) -> None: """Add a node with hierarchical key validation. @@ -272,7 +272,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.ObjectStore | TreeStore: + ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.BatchStore | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -286,7 +286,7 @@ def __getitem__( Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.ObjectStore or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.BatchStore or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. diff --git a/tests/test_object_store.py b/tests/test_batch_store.py similarity index 79% rename from tests/test_object_store.py rename to tests/test_batch_store.py index 63190614..45ff9195 100644 --- a/tests/test_object_store.py +++ b/tests/test_batch_store.py @@ -32,15 +32,15 @@ def _storage(contiguous, urlpath, mode="w"): [ (False, None), (True, None), - (True, "test_objectstore.b2frame"), - (False, "test_objectstore_s.b2frame"), + (True, "test_batchstore.b2frame"), + (False, "test_batchstore_s.b2frame"), ], ) -def test_objectstore_roundtrip(contiguous, urlpath): +def test_batchstore_roundtrip(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) - assert barray.meta["objectstore"]["serializer"] == "msgpack" + barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath)) + assert barray.meta["batchstore"]["serializer"] == "msgpack" for i, batch in enumerate(BATCHES, start=1): assert barray.append(batch) == i @@ -82,7 +82,7 @@ def test_objectstore_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert isinstance(reopened, blosc2.ObjectStore) + assert isinstance(reopened, blosc2.BatchStore) assert reopened.batchsize == barray.batchsize assert reopened.blocksize == barray.blocksize assert [batch[:] for batch in reopened] == expected @@ -110,14 +110,14 @@ def test_objectstore_roundtrip(contiguous, urlpath): if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") - assert isinstance(reopened_mmap, blosc2.ObjectStore) + assert isinstance(reopened_mmap, blosc2.BatchStore) assert [batch[:] for batch in reopened_mmap] == expected blosc2.remove_urlpath(urlpath) -def test_objectstore_from_cframe(): - barray = blosc2.ObjectStore() +def test_batchstore_from_cframe(): + barray = blosc2.BatchStore() barray.extend(BATCHES) barray.insert(1, ["inserted", True, None]) del barray[3] @@ -126,16 +126,16 @@ def test_objectstore_from_cframe(): del expected[3] restored = blosc2.from_cframe(barray.to_cframe()) - assert isinstance(restored, blosc2.ObjectStore) + assert isinstance(restored, blosc2.BatchStore) assert [batch[:] for batch in restored] == expected restored2 = blosc2.from_cframe(barray.to_cframe()) - assert isinstance(restored2, blosc2.ObjectStore) + assert isinstance(restored2, blosc2.BatchStore) assert [batch[:] for batch in restored2] == expected -def test_objectstore_info(): - barray = blosc2.ObjectStore() +def test_batchstore_info(): + barray = blosc2.BatchStore() barray.extend(BATCHES) assert barray.typesize == 1 @@ -143,7 +143,7 @@ def test_objectstore_info(): assert barray.urlpath == barray.schunk.urlpath items = dict(barray.info_items) - assert items["type"] == "ObjectStore" + assert items["type"] == "BatchStore" assert items["nbatches"] == len(BATCHES) assert items["batchsize"] == len(BATCHES[0]) assert items["blocksize"] == barray.blocksize @@ -157,42 +157,37 @@ def test_objectstore_info(): text = repr(barray.info) assert "type" in text - assert "ObjectStore" in text + assert "BatchStore" in text assert "batchsize" in text -def test_objectstore_zstd_uses_dict_by_default(): - barray = blosc2.ObjectStore() +def test_batchstore_zstd_uses_dict_by_default(): + barray = blosc2.BatchStore() assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is True -def test_objectstore_explicit_batchsize_blocksize(): - barray = blosc2.ObjectStore(batchsize=3, blocksize=2) +def test_batchstore_explicit_batchsize_blocksize(): + barray = blosc2.BatchStore(batchsize=3, blocksize=2) assert barray.batchsize == 3 - assert barray.chunksize == 3 assert barray.blocksize == 2 barray.append([1, 2, 3]) assert [batch[:] for batch in barray] == [[1, 2, 3]] - legacy = blosc2.ObjectStore(chunksize=3, blocksize=2) - assert legacy.batchsize == 3 - assert legacy.chunksize == 3 - -def test_objectstore_respects_explicit_use_dict_and_non_zstd(): - barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) +def test_batchstore_respects_explicit_use_dict_and_non_zstd(): + barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 assert barray.cparams.use_dict is False - barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is False - barray = blosc2.ObjectStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) assert barray.cparams.use_dict is False - barray = blosc2.ObjectStore(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + barray = blosc2.BatchStore(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) assert barray.cparams.use_dict is False @@ -233,14 +228,14 @@ def test_vlcompress_small_blocks_roundtrip(): assert out == payloads -def test_objectstore_constructor_kwargs(): - urlpath = "test_objectstore_kwargs.b2frame" +def test_batchstore_constructor_kwargs(): + urlpath = "test_batchstore_kwargs.b2frame" blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectStore(urlpath=urlpath, mode="w", contiguous=True) + barray = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True) barray.extend(BATCHES) - reopened = blosc2.ObjectStore(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + reopened = blosc2.BatchStore(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") assert [batch[:] for batch in reopened] == BATCHES blosc2.remove_urlpath(urlpath) @@ -251,14 +246,14 @@ def test_objectstore_constructor_kwargs(): [ (False, None), (True, None), - (True, "test_objectstore_list_ops.b2frame"), - (False, "test_objectstore_list_ops_s.b2frame"), + (True, "test_batchstore_list_ops.b2frame"), + (False, "test_batchstore_list_ops_s.b2frame"), ], ) -def test_objectstore_list_like_ops(contiguous, urlpath): +def test_batchstore_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) + barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath)) barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] assert barray.pop() == [7, 8, 9] @@ -284,15 +279,15 @@ def test_objectstore_list_like_ops(contiguous, urlpath): [ (False, None), (True, None), - (True, "test_objectstore_slices.b2frame"), - (False, "test_objectstore_slices_s.b2frame"), + (True, "test_batchstore_slices.b2frame"), + (False, "test_batchstore_slices_s.b2frame"), ], ) -def test_objectstore_slices(contiguous, urlpath): +def test_batchstore_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) expected = [[i, i + 100, i + 200] for i in range(8)] - barray = blosc2.ObjectStore(storage=_storage(contiguous, urlpath)) + barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath)) barray.extend(expected) assert [batch[:] for batch in barray[1:6:2]] == expected[1:6:2] @@ -321,8 +316,8 @@ def test_objectstore_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) -def test_objectstore_slice_errors(): - barray = blosc2.ObjectStore() +def test_batchstore_slice_errors(): + barray = blosc2.BatchStore() barray.extend([[0], [1], [2], [3]]) with pytest.raises(ValueError, match="extended slice"): @@ -333,13 +328,13 @@ def test_objectstore_slice_errors(): _ = barray[::0] -def test_objectstore_copy(): - urlpath = "test_objectstore_copy.b2frame" - copy_path = "test_objectstore_copy_out.b2frame" +def test_batchstore_copy(): + urlpath = "test_batchstore_copy.b2frame" + copy_path = "test_batchstore_copy_out.b2frame" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) - original = blosc2.ObjectStore(urlpath=urlpath, mode="w", contiguous=True) + original = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True) original.extend(BATCHES) original.insert(1, ["copy", True, 123]) @@ -364,7 +359,7 @@ def test_objectstore_copy(): @pytest.mark.parametrize(("contiguous", "nthreads"), [(False, 2), (True, 4)]) -def test_objectstore_multithreaded_inner_vl(contiguous, nthreads): +def test_batchstore_multithreaded_inner_vl(contiguous, nthreads): batches = [] for batch_id in range(24): batch = [] @@ -381,7 +376,7 @@ def test_objectstore_multithreaded_inner_vl(contiguous, nthreads): ) batches.append(batch) - barray = blosc2.ObjectStore( + barray = blosc2.BatchStore( storage=blosc2.Storage(contiguous=contiguous), cparams=blosc2.CParams(typesize=1, nthreads=nthreads, codec=blosc2.Codec.ZSTD, clevel=5), dparams=blosc2.DParams(nthreads=nthreads), @@ -392,8 +387,8 @@ def test_objectstore_multithreaded_inner_vl(contiguous, nthreads): assert [barray[i][:] for i in range(len(barray))] == batches -def test_objectstore_validation_errors(): - barray = blosc2.ObjectStore() +def test_batchstore_validation_errors(): + barray = blosc2.BatchStore() with pytest.raises(TypeError): barray.append("value") @@ -404,7 +399,7 @@ def test_objectstore_validation_errors(): with pytest.raises(IndexError): barray.delete(3) with pytest.raises(IndexError): - blosc2.ObjectStore().pop() + blosc2.BatchStore().pop() barray.extend([[1, 2, 3]]) with pytest.raises(ValueError): barray.append([2, 3]) @@ -412,29 +407,29 @@ def test_objectstore_validation_errors(): barray.pop(slice(0, 1)) -def test_objectstore_in_embed_store(): +def test_batchstore_in_embed_store(): estore = blosc2.EmbedStore() - barray = blosc2.ObjectStore() + barray = blosc2.BatchStore() barray.extend(BATCHES) estore["/batch"] = barray restored = estore["/batch"] - assert isinstance(restored, blosc2.ObjectStore) + assert isinstance(restored, blosc2.BatchStore) assert [batch[:] for batch in restored] == BATCHES -def test_objectstore_in_dict_store(): - path = "test_objectstore_store.b2z" +def test_batchstore_in_dict_store(): + path = "test_batchstore_store.b2z" blosc2.remove_urlpath(path) with blosc2.DictStore(path, mode="w", threshold=1) as dstore: - barray = blosc2.ObjectStore() + barray = blosc2.BatchStore() barray.extend(BATCHES) dstore["/batch"] = barray with blosc2.DictStore(path, mode="r") as dstore: restored = dstore["/batch"] - assert isinstance(restored, blosc2.ObjectStore) + assert isinstance(restored, blosc2.BatchStore) assert [batch[:] for batch in restored] == BATCHES blosc2.remove_urlpath(path) From be41f852fb498fe10468127c09464b43fcfef70f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 18 Mar 2026 20:21:24 +0100 Subject: [PATCH 35/69] More consistent naming --- src/blosc2/batch_store.py | 44 +++++++++++++++++++-------------------- tests/test_batch_store.py | 6 +++--- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index cb7e31fb..b9b222c9 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -27,14 +27,14 @@ def _check_serialized_size(buffer: bytes) -> None: class Batch(Sequence[Any]): - """A lazy sequence of Python objects stored in one BatchStore chunk.""" + """A lazy sequence of Python objects stored in one BatchStore batch.""" - def __init__(self, parent: BatchStore, nchunk: int, lazychunk: bytes) -> None: + def __init__(self, parent: BatchStore, nbatch: int, lazybatch: bytes) -> None: self._parent = parent - self._nchunk = nchunk - self._lazychunk = lazychunk + self._nbatch = nbatch + self._lazybatch = lazybatch self._blocks: list[list[Any]] | None = None - self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazychunk) + self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazybatch) def _normalize_index(self, index: int) -> int: if not isinstance(index, int): @@ -47,7 +47,7 @@ def _normalize_index(self, index: int) -> int: def _decode_blocks(self) -> list[list[Any]]: if self._blocks is None: - self._blocks = self._parent._decode_blocks(self._nchunk) + self._blocks = self._parent._decode_blocks(self._nbatch) return self._blocks def __getitem__(self, index: int | slice) -> Any | list[Any]: @@ -73,8 +73,8 @@ def __iter__(self) -> Iterator[Any]: yield self[i] @property - def lazychunk(self) -> bytes: - return self._lazychunk + def lazybatch(self) -> bytes: + return self._lazybatch @property def nbytes(self) -> int: @@ -338,9 +338,9 @@ def _compress_batch(self, batch: list[Any]) -> bytes: ] return blosc2.blosc2_ext.vlcompress(blocks, **self._vl_cparams_kwargs()) - def _decode_blocks(self, nchunk: int) -> list[list[Any]]: + def _decode_blocks(self, nbatch: int) -> list[list[Any]]: block_payloads = blosc2.blosc2_ext.vldecompress( - self.schunk.get_chunk(nchunk), **self._vl_dparams_kwargs() + self.schunk.get_chunk(nbatch), **self._vl_dparams_kwargs() ) return [msgpack_unpackb(payload) for payload in block_payloads] @@ -351,16 +351,16 @@ def append(self, value: object) -> int: """Append one batch and return the new number of entries.""" self._check_writable() batch = self._serialize_batch(value) - chunk = self._compress_batch(batch) - return self.schunk.append_chunk(chunk) + batch_payload = self._compress_batch(batch) + return self.schunk.append_chunk(batch_payload) def insert(self, index: int, value: object) -> int: """Insert one batch at ``index`` and return the new number of entries.""" self._check_writable() index = self._normalize_insert_index(index) batch = self._serialize_batch(value) - chunk = self._compress_batch(batch) - return self.schunk.insert_chunk(index, chunk) + batch_payload = self._compress_batch(batch) + return self.schunk.insert_chunk(index, batch_payload) def delete(self, index: int | slice) -> int: """Delete the batch at ``index`` and return the new number of entries.""" @@ -387,8 +387,8 @@ def extend(self, values: object) -> None: self._check_writable() for value in values: batch = self._serialize_batch(value) - chunk = self._compress_batch(batch) - self.schunk.append_chunk(chunk) + batch_payload = self._compress_batch(batch) + self.schunk.append_chunk(batch_payload) def clear(self) -> None: """Remove all entries from the container.""" @@ -424,8 +424,8 @@ def __setitem__(self, index: int | slice, value: object) -> None: self.schunk.delete_chunk(idx) for offset, item in enumerate(values): batch = self._serialize_batch(item) - chunk = self._compress_batch(batch) - self.schunk.insert_chunk(start + offset, chunk) + batch_payload = self._compress_batch(batch) + self.schunk.insert_chunk(start + offset, batch_payload) return if len(values) != len(indices): raise ValueError( @@ -433,14 +433,14 @@ def __setitem__(self, index: int | slice, value: object) -> None: ) for idx, item in zip(indices, values, strict=True): batch = self._serialize_batch(item) - chunk = self._compress_batch(batch) - self.schunk.update_chunk(idx, chunk) + batch_payload = self._compress_batch(batch) + self.schunk.update_chunk(idx, batch_payload) return self._check_writable() index = self._normalize_index(index) batch = self._serialize_batch(value) - chunk = self._compress_batch(batch) - self.schunk.update_chunk(index, chunk) + batch_payload = self._compress_batch(batch) + self.schunk.update_chunk(index, batch_payload) def __delitem__(self, index: int | slice) -> None: self.delete(index) diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index 45ff9195..1399626f 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -58,7 +58,7 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert len(batch0) == len(BATCHES[0]) assert batch0[1] == BATCHES[0][1] assert batch0[:] == BATCHES[0] - assert isinstance(batch0.lazychunk, bytes) + assert isinstance(batch0.lazybatch, bytes) assert batch0.nbytes > 0 assert batch0.cbytes > 0 assert batch0.cratio > 0 @@ -216,14 +216,14 @@ def test_vlcompress_small_blocks_roundtrip(): ] payloads = [msgpack_packb(value) for value in values] - chunk = blosc2.blosc2_ext.vlcompress( + batch_payload = blosc2.blosc2_ext.vlcompress( payloads, codec=blosc2.Codec.ZSTD, clevel=5, typesize=1, nthreads=1, ) - out = blosc2.blosc2_ext.vldecompress(chunk, nthreads=1) + out = blosc2.blosc2_ext.vldecompress(batch_payload, nthreads=1) assert out == payloads From c6540e6cfcb1d448bddecf2c6217318237ff9969 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 06:37:05 +0100 Subject: [PATCH 36/69] batchsize is not immutable anymore (and neither blocksize) --- src/blosc2/batch_store.py | 114 ++++++++++++-------------------------- tests/test_batch_store.py | 34 ++++++------ 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index b9b222c9..c30ef325 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -9,6 +9,7 @@ import copy import pathlib +import statistics from collections.abc import Iterator, Sequence from dataclasses import asdict from typing import Any @@ -17,8 +18,7 @@ from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_BATCHSTORE_META = {"version": 2, "serializer": "msgpack", "format": "batched_vlblocks"} -_BATCHSTORE_LAYOUT_KEY = "batchstore" +_BATCHSTORE_META = {"version": 1, "serializer": "msgpack"} def _check_serialized_size(buffer: bytes) -> None: @@ -33,7 +33,7 @@ def __init__(self, parent: BatchStore, nbatch: int, lazybatch: bytes) -> None: self._parent = parent self._nbatch = nbatch self._lazybatch = lazybatch - self._blocks: list[list[Any]] | None = None + self._items: list[Any] | None = None self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazybatch) def _normalize_index(self, index: int) -> int: @@ -45,28 +45,21 @@ def _normalize_index(self, index: int) -> int: raise IndexError("Batch index out of range") return index - def _decode_blocks(self) -> list[list[Any]]: - if self._blocks is None: - self._blocks = self._parent._decode_blocks(self._nbatch) - return self._blocks + def _decode_items(self) -> list[Any]: + if self._items is None: + blocks = self._parent._decode_blocks(self._nbatch) + self._items = [item for block in blocks for item in block] + return self._items def __getitem__(self, index: int | slice) -> Any | list[Any]: - blocks = self._decode_blocks() + items = self._decode_items() if isinstance(index, slice): - flat_items = [item for block in blocks for item in block] - return flat_items[index] + return items[index] index = self._normalize_index(index) - blocksize = self._parent.blocksize - if blocksize is None: - raise RuntimeError("BatchStore blocksize is not initialized") - block_index, item_index = divmod(index, blocksize) - return blocks[block_index][item_index] + return items[index] def __len__(self) -> int: - batchsize = self._parent.batchsize - if batchsize is None: - return self._nblocks - return batchsize + return len(self._decode_items()) def __iter__(self) -> Iterator[Any]: for i in range(len(self)): @@ -151,7 +144,6 @@ def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.mode = schunk.mode self.mmap_mode = getattr(schunk, "mmap_mode", None) self._validate_tag() - self._load_layout() def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: urlpath = storage.urlpath @@ -174,17 +166,14 @@ def _make_storage(self) -> blosc2.Storage: def __init__( self, - batchsize: int | None = None, - blocksize: int | None = None, + blocksize_max: int | None = None, _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: - self._batchsize: int | None = batchsize - self._blocksize: int | None = blocksize - self._layout_format: str | None = None + if blocksize_max is not None and blocksize_max <= 0: + raise ValueError("blocksize_max must be a positive integer") + self._blocksize_max: int | None = blocksize_max if _from_schunk is not None: - if batchsize is not None or blocksize is not None: - raise ValueError("Cannot pass `batchsize` or `blocksize` together with `_from_schunk`") if kwargs: unexpected = ", ".join(sorted(kwargs)) raise ValueError(f"Cannot pass {unexpected} together with `_from_schunk`") @@ -213,39 +202,11 @@ def __init__( storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) - if self._batchsize is not None or self._blocksize is not None: - self._store_layout() def _validate_tag(self) -> None: if "batchstore" not in self.schunk.meta: raise ValueError("The supplied SChunk is not tagged as a BatchStore") - def _load_layout(self) -> None: - layout = None - self._layout_format = None - if _BATCHSTORE_LAYOUT_KEY in self.vlmeta: - layout = self.vlmeta[_BATCHSTORE_LAYOUT_KEY] - if isinstance(layout, dict): - self._batchsize = layout["batchsize"] - self._blocksize = layout.get("blocksize") - self._layout_format = layout.get("format", "batched_vlblocks") - return - if len(self) == 0: - return - raise ValueError("BatchStore layout metadata is missing") - - def _store_layout(self) -> None: - if self._batchsize is None or self.mode == "r": - return - layout = { - "version": 1, - "batchsize": self._batchsize, - "blocksize": self._blocksize, - "format": self._layout_format or "batched_vlblocks", - "sizing_policy": "l2_cache_prefix", - } - self.vlmeta[_BATCHSTORE_LAYOUT_KEY] = layout - def _check_writable(self) -> None: if self.mode == "r": raise ValueError("Cannot modify a BatchStore opened in read-only mode") @@ -287,14 +248,9 @@ def _normalize_batch(self, value: object) -> list[Any]: return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: - if self._batchsize is None: - self._batchsize = len(batch) - if len(batch) != self._batchsize: - raise ValueError(f"BatchStore entries must contain exactly {self._batchsize} objects") - if self._blocksize is None: + if self._blocksize_max is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] - self._blocksize = self._guess_blocksize(payload_sizes) - self._store_layout() + self._blocksize_max = self._guess_blocksize(payload_sizes) def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: @@ -330,11 +286,11 @@ def _vl_dparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.dparams) def _compress_batch(self, batch: list[Any]) -> bytes: - if self._blocksize is None: - raise RuntimeError("BatchStore blocksize is not initialized") + if self._blocksize_max is None: + raise RuntimeError("BatchStore blocksize_max is not initialized") blocks = [ - self._serialize_block(batch[i : i + self._blocksize]) - for i in range(0, len(batch), self._blocksize) + self._serialize_block(batch[i : i + self._blocksize_max]) + for i in range(0, len(batch), self._blocksize_max) ] return blosc2.blosc2_ext.vlcompress(blocks, **self._vl_cparams_kwargs()) @@ -404,7 +360,6 @@ def clear(self) -> None: storage=storage, ) self._attach_schunk(schunk) - self._store_layout() def __getitem__(self, index: int | slice) -> Batch | list[Batch]: if isinstance(index, slice): @@ -469,12 +424,8 @@ def dparams(self): return self.schunk.dparams @property - def batchsize(self) -> int: - return self._batchsize - - @property - def blocksize(self) -> int: - return self._blocksize + def blocksize_max(self) -> int | None: + return self._blocksize_max @property def typesize(self) -> int: @@ -508,13 +459,19 @@ def info(self) -> InfoReporter: @property def info_items(self) -> list: """A list of tuples with summary information about this BatchStore.""" - nitems = len(self) * self.batchsize if self.batchsize is not None else 0 + batch_sizes = [len(batch) for batch in self] + if batch_sizes: + batch_stats = ( + f"mean={statistics.fmean(batch_sizes):.2f}, max={max(batch_sizes)}, min={min(batch_sizes)}" + ) + else: + batch_stats = "n/a" return [ ("type", f"{self.__class__.__name__}"), ("nbatches", len(self)), - ("batchsize", self.batchsize), - ("blocksize", self.blocksize), - ("nitems", nitems), + ("batch stats", batch_stats), + ("blocksize_max", self.blocksize_max), + ("nitems", sum(batch_sizes)), ("nbytes", format_nbytes_info(self.nbytes)), ("cbytes", format_nbytes_info(self.cbytes)), ("cratio", f"{self.cratio:.2f}"), @@ -531,8 +488,7 @@ def copy(self, **kwargs: Any) -> BatchStore: raise ValueError("meta should not be passed to copy") kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) - kwargs["batchsize"] = kwargs.get("batchsize", self.batchsize) - kwargs["blocksize"] = kwargs.get("blocksize", self.blocksize) + kwargs["blocksize_max"] = kwargs.get("blocksize_max", self.blocksize_max) if "storage" not in kwargs: kwargs["meta"] = self._copy_meta() diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index 1399626f..dbd54cde 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -46,12 +46,11 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert barray.append(batch) == i assert len(barray) == len(BATCHES) - assert barray.batchsize == len(BATCHES[0]) - assert barray.blocksize is not None - assert 1 <= barray.blocksize <= barray.batchsize + assert barray.blocksize_max is not None + assert 1 <= barray.blocksize_max <= len(BATCHES[0]) assert [batch[:] for batch in barray] == BATCHES - with pytest.raises(ValueError): - barray.append([1, 2]) + assert barray.append([1, 2]) == len(BATCHES) + 1 + assert [batch[:] for batch in barray][-1] == [1, 2] batch0 = barray[0] assert isinstance(batch0, blosc2.Batch) @@ -64,6 +63,7 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert batch0.cratio > 0 expected = list(BATCHES) + expected.append([1, 2]) expected[1] = ["updated", {"tuple": (7, 8)}, 99] expected[-1] = ["tiny", False, "x"] barray[1] = expected[1] @@ -83,8 +83,7 @@ def test_batchstore_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchStore) - assert reopened.batchsize == barray.batchsize - assert reopened.blocksize == barray.blocksize + assert reopened.blocksize_max is None assert [batch[:] for batch in reopened] == expected with pytest.raises(ValueError): reopened.append(["nope"]) @@ -145,8 +144,8 @@ def test_batchstore_info(): items = dict(barray.info_items) assert items["type"] == "BatchStore" assert items["nbatches"] == len(BATCHES) - assert items["batchsize"] == len(BATCHES[0]) - assert items["blocksize"] == barray.blocksize + assert items["batch stats"].startswith("mean=") + assert items["blocksize_max"] == barray.blocksize_max assert items["nitems"] == sum(len(batch) for batch in BATCHES) assert "urlpath" not in items assert "contiguous" not in items @@ -158,7 +157,8 @@ def test_batchstore_info(): text = repr(barray.info) assert "type" in text assert "BatchStore" in text - assert "batchsize" in text + assert "batch stats" in text + assert "blocksize_max" in text def test_batchstore_zstd_uses_dict_by_default(): @@ -167,12 +167,12 @@ def test_batchstore_zstd_uses_dict_by_default(): assert barray.cparams.use_dict is True -def test_batchstore_explicit_batchsize_blocksize(): - barray = blosc2.BatchStore(batchsize=3, blocksize=2) - assert barray.batchsize == 3 - assert barray.blocksize == 2 +def test_batchstore_explicit_blocksize_max(): + barray = blosc2.BatchStore(blocksize_max=2) + assert barray.blocksize_max == 2 barray.append([1, 2, 3]) - assert [batch[:] for batch in barray] == [[1, 2, 3]] + barray.append([4]) + assert [batch[:] for batch in barray] == [[1, 2, 3], [4]] def test_batchstore_respects_explicit_use_dict_and_non_zstd(): @@ -401,8 +401,8 @@ def test_batchstore_validation_errors(): with pytest.raises(IndexError): blosc2.BatchStore().pop() barray.extend([[1, 2, 3]]) - with pytest.raises(ValueError): - barray.append([2, 3]) + assert barray.append([2, 3]) == 2 + assert [batch[:] for batch in barray] == [[1, 2, 3], [2, 3]] with pytest.raises(NotImplementedError): barray.pop(slice(0, 1)) From e60e31c454c7a5978d7b5c69ffa637e14846b88f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 07:29:55 +0100 Subject: [PATCH 37/69] Implemented block-only reads for improved random read access --- CMakeLists.txt | 2 +- bench/batch_store.py | 151 ++++++++++++++++++++++++++++++++++++++ examples/batch_store.py | 68 +++++++++++++++++ src/blosc2/batch_store.py | 27 ++++--- src/blosc2/blosc2_ext.pyx | 49 +++++++++++++ src/blosc2/schunk.py | 4 + tests/test_batch_store.py | 51 ++++++++++--- 7 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 bench/batch_store.py create mode 100644 examples/batch_store.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 133e06dc..3ed060c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG fb31a6ab43db2a26ba6d1c690166988b680c3fd7 # variable-length chunks/blocks + GIT_TAG c0f5416f55662fccad861aa0387e965f73f644b4 # variable-length chunks/blocks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) diff --git a/bench/batch_store.py b/bench/batch_store.py new file mode 100644 index 00000000..67cc25ed --- /dev/null +++ b/bench/batch_store.py @@ -0,0 +1,151 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import argparse +import random +import statistics +import time + +import blosc2 + + +URLPATH = "bench_batch_store.b2b" +NBATCHES = 10_000 +OBJECTS_PER_BATCH = 100 +TOTAL_OBJECTS = NBATCHES * OBJECTS_PER_BATCH +BLOCKSIZE_MAX = 32 +N_RANDOM_READS = 1_000 + + +def make_rgb(batch_index: int, item_index: int) -> dict[str, int]: + global_index = batch_index * OBJECTS_PER_BATCH + item_index + return { + "red": batch_index, + "green": item_index, + "blue": global_index, + } + + +def make_batch(batch_index: int) -> list[dict[str, int]]: + return [make_rgb(batch_index, item_index) for item_index in range(OBJECTS_PER_BATCH)] + + +def expected_entry(batch_index: int, item_index: int) -> dict[str, int]: + return { + "red": batch_index, + "green": item_index, + "blue": batch_index * OBJECTS_PER_BATCH + item_index, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Benchmark BatchStore single-entry reads.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--codec", type=str, default="ZSTD", choices=[codec.name for codec in blosc2.Codec]) + parser.add_argument("--clevel", type=int, default=5) + parser.add_argument("--use-dict", action="store_true", help="Enable dictionaries for ZSTD/LZ4 codecs.") + parser.add_argument("--in-mem", action="store_true", help="Keep the BatchStore purely in memory.") + return parser + + +def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) -> blosc2.BatchStore | None: + if in_mem: + storage = blosc2.Storage(mode="w") + store = blosc2.BatchStore( + storage=storage, + blocksize_max=BLOCKSIZE_MAX, + cparams={ + "codec": codec, + "clevel": clevel, + "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4), + }, + ) + for batch_index in range(NBATCHES): + store.append(make_batch(batch_index)) + return store + + blosc2.remove_urlpath(URLPATH) + storage = blosc2.Storage(urlpath=URLPATH, mode="w", contiguous=True) + cparams = { + "codec": codec, + "clevel": clevel, + "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4), + } + with blosc2.BatchStore(storage=storage, blocksize_max=BLOCKSIZE_MAX, cparams=cparams) as store: + for batch_index in range(NBATCHES): + store.append(make_batch(batch_index)) + return None + + +def measure_random_reads(store: blosc2.BatchStore) -> tuple[list[tuple[int, int, int, dict[str, int]]], list[int]]: + rng = random.Random(2024) + samples: list[tuple[int, int, int, dict[str, int]]] = [] + timings_ns: list[int] = [] + + for _ in range(N_RANDOM_READS): + batch_index = rng.randrange(len(store)) + item_index = rng.randrange(OBJECTS_PER_BATCH) + t0 = time.perf_counter_ns() + value = store[batch_index][item_index] + timings_ns.append(time.perf_counter_ns() - t0) + if value != expected_entry(batch_index, item_index): + raise RuntimeError(f"Value mismatch at batch={batch_index}, item={item_index}") + samples.append((timings_ns[-1], batch_index, item_index, value)) + + return samples, timings_ns + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + codec = blosc2.Codec[args.codec] + use_dict = args.use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4) + + mode_label = "in-memory" if args.in_mem else "persistent" + article = "an" if args.in_mem else "a" + print(f"Building {article} {mode_label} BatchStore with 1,000,000 RGB dicts and timing 1,000 random scalar reads...") + print(f" codec: {codec.name}") + print(f" clevel: {args.clevel}") + print(f" use_dict: {use_dict}") + print(f" in_mem: {args.in_mem}") + t0 = time.perf_counter() + store = build_store(codec=codec, clevel=args.clevel, use_dict=use_dict, in_mem=args.in_mem) + build_time_s = time.perf_counter() - t0 + if args.in_mem: + assert store is not None + read_store = store + else: + read_store = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, blocksize_max=BLOCKSIZE_MAX) + samples, timings_ns = measure_random_reads(read_store) + + print() + print("BatchStore benchmark") + print(f" build time: {build_time_s:.3f} s") + print(f" batches: {len(read_store)}") + print(f" objects: {TOTAL_OBJECTS}") + print(f" blocksize_max: {read_store.blocksize_max}") + print() + print(read_store.info) + print(f"Random scalar reads: {N_RANDOM_READS}") + print(f" mean: {statistics.fmean(timings_ns) / 1_000:.2f} us") + print(f" max: {max(timings_ns) / 1_000:.2f} us") + print(f" min: {min(timings_ns) / 1_000:.2f} us") + print("Sample reads:") + for timing_ns, batch_index, item_index, value in samples[:5]: + print(f" {timing_ns / 1_000:.2f} us -> read_store[{batch_index}][{item_index}] = {value}") + if args.in_mem: + print("BatchStore kept in memory") + else: + print(f"BatchStore file at: {read_store.urlpath}") + + +if __name__ == "__main__": + main() diff --git a/examples/batch_store.py b/examples/batch_store.py new file mode 100644 index 00000000..5a127576 --- /dev/null +++ b/examples/batch_store.py @@ -0,0 +1,68 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import random + +import blosc2 + +URLPATH = "example_batch_store.b2b" +NBATCHES = 100 +OBJECTS_PER_BATCH = 100 +BLOCKSIZE_MAX = 32 +N_RANDOM_SAMPLES = 5 + + +def make_rgb(batch_index: int, item_index: int) -> dict[str, int]: + global_index = batch_index * OBJECTS_PER_BATCH + item_index + return { + "red": batch_index, + "green": item_index, + "blue": global_index, + } + + +def make_batch(batch_index: int) -> list[dict[str, int]]: + return [make_rgb(batch_index, item_index) for item_index in range(OBJECTS_PER_BATCH)] + + +def main() -> None: + # Start clean so the example is reproducible when run multiple times. + blosc2.remove_urlpath(URLPATH) + + storage = blosc2.Storage(urlpath=URLPATH, mode="w", contiguous=True) + with blosc2.BatchStore(storage=storage, blocksize_max=BLOCKSIZE_MAX) as store: + for batch_index in range(NBATCHES): + store.append(make_batch(batch_index)) + + total_objects = sum(len(batch) for batch in store) + print("Created BatchStore") + print(f" batches: {len(store)}") + print(f" objects: {total_objects}") + print(f" blocksize_max: {store.blocksize_max}") + + # Reopen with the same blocksize_max hint so scalar reads can use the + # VL-block path instead of decoding the entire batch. + reopened = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, blocksize_max=BLOCKSIZE_MAX) + + print() + print(reopened.info) + + sample_rng = random.Random(2024) + print("Random scalar reads:") + for _ in range(N_RANDOM_SAMPLES): + batch_index = sample_rng.randrange(len(reopened)) + item_index = sample_rng.randrange(OBJECTS_PER_BATCH) + value = reopened[batch_index][item_index] + print(f" reopened[{batch_index}][{item_index}] -> {value}") + + print(f"BatchStore file at: {reopened.urlpath}") + + +if __name__ == "__main__": + main() diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index c30ef325..86579cc6 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -52,9 +52,24 @@ def _decode_items(self) -> list[Any]: return self._items def __getitem__(self, index: int | slice) -> Any | list[Any]: - items = self._decode_items() if isinstance(index, slice): + items = self._decode_items() + return items[index] + if index < 0: + items = self._decode_items() + index = self._normalize_index(index) return items[index] + blocksize_max = self._parent.blocksize_max + if blocksize_max is not None: + block_index, item_index = divmod(index, blocksize_max) + if block_index >= self._nblocks: + raise IndexError("Batch index out of range") + block = msgpack_unpackb(self._parent.schunk.get_vlblock(self._nbatch, block_index)) + try: + return block[item_index] + except IndexError as exc: + raise IndexError("Batch index out of range") from exc + items = self._decode_items() index = self._normalize_index(index) return items[index] @@ -90,27 +105,17 @@ class BatchStore: @staticmethod def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: - auto_use_dict = cparams is None if cparams is None: cparams = blosc2.CParams() elif isinstance(cparams, blosc2.CParams): cparams = copy.deepcopy(cparams) else: cparams = dict(cparams) - auto_use_dict = "use_dict" not in cparams if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 - if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: - # BatchStore stores many small serialized payloads, where Zstd dicts help materially. - cparams.use_dict = True else: cparams["typesize"] = 1 - codec = cparams.get("codec", blosc2.Codec.ZSTD) - clevel = cparams.get("clevel", 5) - if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: - # BatchStore stores many small serialized payloads, where Zstd dicts help materially. - cparams["use_dict"] = True return cparams @staticmethod diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 4c574015..7b6791fa 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -390,6 +390,8 @@ cdef extern from "blosc2.h": c_bool *needs_free) nogil int blosc2_schunk_get_lazychunk(blosc2_schunk *schunk, int64_t nchunk, uint8_t ** chunk, c_bool *needs_free) nogil + int blosc2_schunk_get_vlblock(blosc2_schunk *schunk, int64_t nchunk, int32_t nblock, + uint8_t **dest, int32_t *destsize) int blosc2_schunk_get_slice_buffer(blosc2_schunk *schunk, int64_t start, int64_t stop, void *buffer) int blosc2_schunk_set_slice_buffer(blosc2_schunk *schunk, int64_t start, int64_t stop, void *buffer) int blosc2_schunk_get_cparams(blosc2_schunk *schunk, blosc2_cparams** cparams) @@ -416,6 +418,9 @@ cdef extern from "blosc2.h": uint8_t **content, int32_t *content_len) int blosc2_vlmeta_delete(blosc2_schunk *schunk, const char *name) int blosc2_vlmeta_get_names(blosc2_schunk *schunk, char **names) + int blosc2_vldecompress_block_ctx(blosc2_context* context, const void* src, + int32_t srcsize, int32_t nblock, uint8_t** dest, + int32_t* destsize) int blosc1_get_blocksize() @@ -1282,6 +1287,40 @@ def vldecompress(src, **kwargs): blosc2_free_ctx(dctx) +def vldecompress_block(src, int32_t nblock, **kwargs): + cdef blosc2_dparams dparams + create_dparams_from_kwargs(&dparams, kwargs) + + cdef blosc2_context *dctx = blosc2_create_dctx(dparams) + if dctx == NULL: + raise RuntimeError("Could not create decompression context") + + cdef const uint8_t[:] typed_view_src + mem_view_src = memoryview(src) + typed_view_src = mem_view_src.cast('B') + _check_comp_length('src', typed_view_src.nbytes) + + cdef uint8_t *dest = NULL + cdef int32_t destsize = 0 + cdef int32_t rc + try: + rc = blosc2_vldecompress_block_ctx( + dctx, + &typed_view_src[0], + typed_view_src.nbytes, + nblock, + &dest, + &destsize, + ) + if rc < 0: + raise RuntimeError("Could not decompress the block") + return PyBytes_FromStringAndSize(dest, destsize) + finally: + if dest != NULL: + free(dest) + blosc2_free_ctx(dctx) + + cdef create_storage(blosc2_storage *storage, kwargs): contiguous = kwargs.get('contiguous', blosc2.storage_dflts['contiguous']) storage.contiguous = contiguous @@ -1687,6 +1726,16 @@ cdef class SChunk: free(chunk) return ret_chunk + def get_vlblock(self, nchunk, nblock): + cdef uint8_t *block + cdef int32_t destsize + cbytes = blosc2_schunk_get_vlblock(self.schunk, nchunk, nblock, &block, &destsize) + if cbytes < 0: + raise RuntimeError("Error while getting the vlblock") + ret_block = PyBytes_FromStringAndSize(block, destsize) + free(block) + return ret_block + def delete_chunk(self, nchunk): rc = blosc2_schunk_delete_chunk(self.schunk, nchunk) if rc < 0: diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index b39524c6..55b4acdf 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -674,6 +674,10 @@ def get_chunk(self, nchunk: int) -> bytes: """ return super().get_chunk(nchunk) + def get_vlblock(self, nchunk: int, nblock: int) -> bytes: + """Return the decompressed payload of one VL block from a chunk.""" + return super().get_vlblock(nchunk, nblock) + def delete_chunk(self, nchunk: int) -> int: """Delete the specified chunk from the SChunk. diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index dbd54cde..13a4b981 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -8,7 +8,7 @@ import pytest import blosc2 -from blosc2._msgpack_utils import msgpack_packb +from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb BATCHES = [ [b"bytes\x00payload", "plain text", 42], @@ -32,8 +32,8 @@ def _storage(contiguous, urlpath, mode="w"): [ (False, None), (True, None), - (True, "test_batchstore.b2frame"), - (False, "test_batchstore_s.b2frame"), + (True, "test_batchstore.b2b"), + (False, "test_batchstore_s.b2b"), ], ) def test_batchstore_roundtrip(contiguous, urlpath): @@ -161,10 +161,10 @@ def test_batchstore_info(): assert "blocksize_max" in text -def test_batchstore_zstd_uses_dict_by_default(): +def test_batchstore_zstd_does_not_use_dict_by_default(): barray = blosc2.BatchStore() assert barray.cparams.codec == blosc2.Codec.ZSTD - assert barray.cparams.use_dict is True + assert barray.cparams.use_dict is False def test_batchstore_explicit_blocksize_max(): @@ -175,6 +175,33 @@ def test_batchstore_explicit_blocksize_max(): assert [batch[:] for batch in barray] == [[1, 2, 3], [4]] +def test_batchstore_get_vlblock_and_scalar_access(): + urlpath = "test_batchstore_vlblock.b2b" + blosc2.remove_urlpath(urlpath) + + batch = [0, 1, 2, 3, 4] + barray = blosc2.BatchStore(storage=_storage(True, urlpath), blocksize_max=2) + barray.append(batch) + + assert barray.blocksize_max == 2 + assert msgpack_unpackb(barray.schunk.get_vlblock(0, 0)) == batch[:2] + assert msgpack_unpackb(barray.schunk.get_vlblock(0, 1)) == batch[2:4] + assert msgpack_unpackb(barray.schunk.get_vlblock(0, 2)) == batch[4:] + + assert barray[0][0] == 0 + assert barray[0][2] == 2 + assert barray[0][4] == 4 + + reopened = blosc2.open(urlpath, mode="r") + assert isinstance(reopened, blosc2.BatchStore) + assert reopened[0][0] == 0 + assert reopened[0][2] == 2 + assert reopened[0][4] == 4 + assert msgpack_unpackb(reopened.schunk.get_vlblock(0, 1)) == batch[2:4] + + blosc2.remove_urlpath(urlpath) + + def test_batchstore_respects_explicit_use_dict_and_non_zstd(): barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 @@ -229,7 +256,7 @@ def test_vlcompress_small_blocks_roundtrip(): def test_batchstore_constructor_kwargs(): - urlpath = "test_batchstore_kwargs.b2frame" + urlpath = "test_batchstore_kwargs.b2b" blosc2.remove_urlpath(urlpath) barray = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True) @@ -246,8 +273,8 @@ def test_batchstore_constructor_kwargs(): [ (False, None), (True, None), - (True, "test_batchstore_list_ops.b2frame"), - (False, "test_batchstore_list_ops_s.b2frame"), + (True, "test_batchstore_list_ops.b2b"), + (False, "test_batchstore_list_ops_s.b2b"), ], ) def test_batchstore_list_like_ops(contiguous, urlpath): @@ -279,8 +306,8 @@ def test_batchstore_list_like_ops(contiguous, urlpath): [ (False, None), (True, None), - (True, "test_batchstore_slices.b2frame"), - (False, "test_batchstore_slices_s.b2frame"), + (True, "test_batchstore_slices.b2b"), + (False, "test_batchstore_slices_s.b2b"), ], ) def test_batchstore_slices(contiguous, urlpath): @@ -329,8 +356,8 @@ def test_batchstore_slice_errors(): def test_batchstore_copy(): - urlpath = "test_batchstore_copy.b2frame" - copy_path = "test_batchstore_copy_out.b2frame" + urlpath = "test_batchstore_copy.b2b" + copy_path = "test_batchstore_copy_out.b2b" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) From 4eed97c7832dc3f871def45095ca2a3da450ba70 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 07:34:17 +0100 Subject: [PATCH 38/69] New cache for the last block read --- src/blosc2/batch_store.py | 12 +++++++++++- tests/test_batch_store.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index 86579cc6..b3de30a2 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -34,6 +34,8 @@ def __init__(self, parent: BatchStore, nbatch: int, lazybatch: bytes) -> None: self._nbatch = nbatch self._lazybatch = lazybatch self._items: list[Any] | None = None + self._cached_block_index: int | None = None + self._cached_block: list[Any] | None = None self._nbytes, self._cbytes, self._nblocks = blosc2.get_cbuffer_sizes(lazybatch) def _normalize_index(self, index: int) -> int: @@ -51,6 +53,14 @@ def _decode_items(self) -> list[Any]: self._items = [item for block in blocks for item in block] return self._items + def _get_block(self, block_index: int) -> list[Any]: + if self._cached_block_index == block_index and self._cached_block is not None: + return self._cached_block + block = msgpack_unpackb(self._parent.schunk.get_vlblock(self._nbatch, block_index)) + self._cached_block_index = block_index + self._cached_block = block + return block + def __getitem__(self, index: int | slice) -> Any | list[Any]: if isinstance(index, slice): items = self._decode_items() @@ -64,7 +74,7 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: block_index, item_index = divmod(index, blocksize_max) if block_index >= self._nblocks: raise IndexError("Batch index out of range") - block = msgpack_unpackb(self._parent.schunk.get_vlblock(self._nbatch, block_index)) + block = self._get_block(block_index) try: return block[item_index] except IndexError as exc: diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index 13a4b981..de56ac58 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -202,6 +202,30 @@ def test_batchstore_get_vlblock_and_scalar_access(): blosc2.remove_urlpath(urlpath) +def test_batchstore_scalar_reads_cache_vlblocks(): + barray = blosc2.BatchStore(blocksize_max=2) + barray.append([0, 1, 2, 3, 4]) + + batch = barray[0] + original_get_vlblock = barray.schunk.get_vlblock + calls = [] + + def wrapped_get_vlblock(nchunk, nblock): + calls.append((nchunk, nblock)) + return original_get_vlblock(nchunk, nblock) + + barray.schunk.get_vlblock = wrapped_get_vlblock + try: + assert batch[0] == 0 + assert batch[1] == 1 + assert batch[0] == 0 + assert batch[2] == 2 + assert batch[3] == 3 + assert calls == [(0, 0), (0, 1)] + finally: + barray.schunk.get_vlblock = original_get_vlblock + + def test_batchstore_respects_explicit_use_dict_and_non_zstd(): barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 From f51c430e2b3e42573ae5cd6bcc453fa2bf60e118 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 07:44:27 +0100 Subject: [PATCH 39/69] New iter_objects for iterating over objects in batch store --- bench/batch_store.py | 10 +++++++ src/blosc2/batch_store.py | 13 ++++++--- tests/test_batch_store.py | 57 ++++++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/bench/batch_store.py b/bench/batch_store.py index 67cc25ed..abc5fbe0 100644 --- a/bench/batch_store.py +++ b/bench/batch_store.py @@ -125,6 +125,13 @@ def main() -> None: else: read_store = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, blocksize_max=BLOCKSIZE_MAX) samples, timings_ns = measure_random_reads(read_store) + t0 = time.perf_counter() + checksum = 0 + nobjects = 0 + for obj in read_store.iter_objects(): + checksum += obj["blue"] + nobjects += 1 + iter_time_s = time.perf_counter() - t0 print() print("BatchStore benchmark") @@ -138,6 +145,9 @@ def main() -> None: print(f" mean: {statistics.fmean(timings_ns) / 1_000:.2f} us") print(f" max: {max(timings_ns) / 1_000:.2f} us") print(f" min: {min(timings_ns) / 1_000:.2f} us") + print(f"Object iteration via iter_objects(): {iter_time_s:.3f} s") + print(f" per object: {iter_time_s * 1_000_000 / nobjects:.2f} us") + print(f" checksum: {checksum}") print("Sample reads:") for timing_ns, batch_index, item_index, value in samples[:5]: print(f" {timing_ns / 1_000:.2f} us -> read_store[{batch_index}][{item_index}] = {value}") diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index b3de30a2..55f5adf6 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -418,10 +418,17 @@ def __delitem__(self, index: int | slice) -> None: def __len__(self) -> int: return self.schunk.nchunks - def __iter__(self) -> Iterator[Batch]: + def iter_batches(self) -> Iterator[Batch]: for i in range(len(self)): yield self[i] + def iter_objects(self) -> Iterator[Any]: + for batch in self.iter_batches(): + yield from batch + + def __iter__(self) -> Iterator[Batch]: + yield from self.iter_batches() + @property def meta(self): return self.schunk.meta @@ -474,7 +481,7 @@ def info(self) -> InfoReporter: @property def info_items(self) -> list: """A list of tuples with summary information about this BatchStore.""" - batch_sizes = [len(batch) for batch in self] + batch_sizes = [len(batch) for batch in self.iter_batches()] if batch_sizes: batch_stats = ( f"mean={statistics.fmean(batch_sizes):.2f}, max={max(batch_sizes)}, min={min(batch_sizes)}" @@ -515,7 +522,7 @@ def copy(self, **kwargs: Any) -> BatchStore: if "storage" not in kwargs and len(self.vlmeta) > 0: for key, value in self.vlmeta.getall().items(): out.vlmeta[key] = value - out.extend(self) + out.extend(self.iter_batches()) return out def __enter__(self) -> BatchStore: diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index de56ac58..daed6145 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -48,9 +48,9 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert len(barray) == len(BATCHES) assert barray.blocksize_max is not None assert 1 <= barray.blocksize_max <= len(BATCHES[0]) - assert [batch[:] for batch in barray] == BATCHES + assert [batch[:] for batch in barray.iter_batches()] == BATCHES assert barray.append([1, 2]) == len(BATCHES) + 1 - assert [batch[:] for batch in barray][-1] == [1, 2] + assert [batch[:] for batch in barray.iter_batches()][-1] == [1, 2] batch0 = barray[0] assert isinstance(batch0, blosc2.Batch) @@ -78,13 +78,13 @@ def test_batchstore_roundtrip(contiguous, urlpath): del expected[2] del barray[-2] del expected[-2] - assert [batch[:] for batch in barray] == expected + assert [batch[:] for batch in barray.iter_batches()] == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchStore) assert reopened.blocksize_max is None - assert [batch[:] for batch in reopened] == expected + assert [batch[:] for batch in reopened.iter_batches()] == expected with pytest.raises(ValueError): reopened.append(["nope"]) with pytest.raises(ValueError): @@ -105,12 +105,12 @@ def test_batchstore_roundtrip(contiguous, urlpath): reopened_rw = blosc2.open(urlpath, mode="a") reopened_rw[0] = ["changed", "batch", 0] expected[0] = ["changed", "batch", 0] - assert [batch[:] for batch in reopened_rw] == expected + assert [batch[:] for batch in reopened_rw.iter_batches()] == expected if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") assert isinstance(reopened_mmap, blosc2.BatchStore) - assert [batch[:] for batch in reopened_mmap] == expected + assert [batch[:] for batch in reopened_mmap.iter_batches()] == expected blosc2.remove_urlpath(urlpath) @@ -126,11 +126,11 @@ def test_batchstore_from_cframe(): restored = blosc2.from_cframe(barray.to_cframe()) assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored] == expected + assert [batch[:] for batch in restored.iter_batches()] == expected restored2 = blosc2.from_cframe(barray.to_cframe()) assert isinstance(restored2, blosc2.BatchStore) - assert [batch[:] for batch in restored2] == expected + assert [batch[:] for batch in restored2.iter_batches()] == expected def test_batchstore_info(): @@ -172,7 +172,7 @@ def test_batchstore_explicit_blocksize_max(): assert barray.blocksize_max == 2 barray.append([1, 2, 3]) barray.append([4]) - assert [batch[:] for batch in barray] == [[1, 2, 3], [4]] + assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [4]] def test_batchstore_get_vlblock_and_scalar_access(): @@ -226,6 +226,15 @@ def wrapped_get_vlblock(nchunk, nblock): barray.schunk.get_vlblock = original_get_vlblock +def test_batchstore_iter_objects(): + barray = blosc2.BatchStore(blocksize_max=2) + batches = [[1, 2, 3], [4], [5, 6]] + barray.extend(batches) + + assert [batch[:] for batch in barray] == batches + assert list(barray.iter_objects()) == [1, 2, 3, 4, 5, 6] + + def test_batchstore_respects_explicit_use_dict_and_non_zstd(): barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 @@ -287,7 +296,7 @@ def test_batchstore_constructor_kwargs(): barray.extend(BATCHES) reopened = blosc2.BatchStore(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") - assert [batch[:] for batch in reopened] == BATCHES + assert [batch[:] for batch in reopened.iter_batches()] == BATCHES blosc2.remove_urlpath(urlpath) @@ -306,21 +315,21 @@ def test_batchstore_list_like_ops(contiguous, urlpath): barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath)) barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] assert barray.pop() == [7, 8, 9] assert barray.pop(0) == [1, 2, 3] - assert [batch[:] for batch in barray] == [[4, 5, 6]] + assert [batch[:] for batch in barray.iter_batches()] == [[4, 5, 6]] barray.clear() assert len(barray) == 0 - assert [batch[:] for batch in barray] == [] + assert [batch[:] for batch in barray.iter_batches()] == [] barray.extend([["a", "b", "c"], ["d", "e", "f"]]) - assert [batch[:] for batch in barray] == [["a", "b", "c"], ["d", "e", "f"]] + assert [batch[:] for batch in barray.iter_batches()] == [["a", "b", "c"], ["d", "e", "f"]] if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert [batch[:] for batch in reopened] == [["a", "b", "c"], ["d", "e", "f"]] + assert [batch[:] for batch in reopened.iter_batches()] == [["a", "b", "c"], ["d", "e", "f"]] blosc2.remove_urlpath(urlpath) @@ -346,15 +355,15 @@ def test_batchstore_slices(contiguous, urlpath): barray[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] expected[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] - assert [batch[:] for batch in barray] == expected + assert [batch[:] for batch in barray.iter_batches()] == expected barray[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] expected[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] - assert [batch[:] for batch in barray] == expected + assert [batch[:] for batch in barray.iter_batches()] == expected del barray[::3] del expected[::3] - assert [batch[:] for batch in barray] == expected + assert [batch[:] for batch in barray.iter_batches()] == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") @@ -392,14 +401,14 @@ def test_batchstore_copy(): copied = original.copy( urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5} ) - assert [batch[:] for batch in copied] == [batch[:] for batch in original] + assert [batch[:] for batch in copied.iter_batches()] == [batch[:] for batch in original.iter_batches()] assert copied.urlpath == copy_path assert copied.schunk.contiguous is False assert copied.cparams.codec == blosc2.Codec.LZ4 assert copied.cparams.clevel == 5 inmem = original.copy() - assert [batch[:] for batch in inmem] == [batch[:] for batch in original] + assert [batch[:] for batch in inmem.iter_batches()] == [batch[:] for batch in original.iter_batches()] assert inmem.urlpath is None with pytest.raises(ValueError, match="meta should not be passed to copy"): @@ -434,7 +443,7 @@ def test_batchstore_multithreaded_inner_vl(contiguous, nthreads): ) barray.extend(batches) - assert [batch[:] for batch in barray] == batches + assert [batch[:] for batch in barray.iter_batches()] == batches assert [barray[i][:] for i in range(len(barray))] == batches @@ -453,7 +462,7 @@ def test_batchstore_validation_errors(): blosc2.BatchStore().pop() barray.extend([[1, 2, 3]]) assert barray.append([2, 3]) == 2 - assert [batch[:] for batch in barray] == [[1, 2, 3], [2, 3]] + assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [2, 3]] with pytest.raises(NotImplementedError): barray.pop(slice(0, 1)) @@ -466,7 +475,7 @@ def test_batchstore_in_embed_store(): estore["/batch"] = barray restored = estore["/batch"] assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored] == BATCHES + assert [batch[:] for batch in restored.iter_batches()] == BATCHES def test_batchstore_in_dict_store(): @@ -481,6 +490,6 @@ def test_batchstore_in_dict_store(): with blosc2.DictStore(path, mode="r") as dstore: restored = dstore["/batch"] assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored] == BATCHES + assert [batch[:] for batch in restored.iter_batches()] == BATCHES blosc2.remove_urlpath(path) From ab8b49586194980d7eca045488d3df58279da1f7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 20:20:51 +0100 Subject: [PATCH 40/69] Recognize .b2b extension as BatchStore in DictStore --- src/blosc2/dict_store.py | 17 ++++++++++++----- src/blosc2/tree_store.py | 11 +++++++++-- tests/test_tree_store.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 0cb5ef63..17f50ac6 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -36,6 +36,8 @@ class DictStore: are stored as .b2nd files. - blosc2.SChunk: super-chunks. When persisted externally they are stored as .b2f files. + - blosc2.BatchStore: batched variable-length containers. When persisted + externally they are stored as .b2b files. - blosc2.C2Array: columnar containers. These are always kept inside the embedded store (never externalized). - numpy.ndarray: converted to blosc2.NDArray on assignment. @@ -91,7 +93,7 @@ class DictStore: Notes ----- - External persistence uses the following file extensions: - .b2nd for NDArray and .b2f for SChunk. + .b2nd for NDArray, .b2f for SChunk, and .b2b for BatchStore. """ def __init__( @@ -181,8 +183,11 @@ def _init_read_mode(self, dparams: blosc2.DParams | None = None): dparams=dparams, ) for filepath in self.offsets: - if filepath.endswith((".b2nd", ".b2f")): - key = "/" + filepath[: -5 if filepath.endswith(".b2nd") else -4] + if filepath.endswith((".b2nd", ".b2f", ".b2b")): + if filepath.endswith(".b2nd"): + key = "/" + filepath[:-5] + else: + key = "/" + filepath[:-4] self.map_tree[key] = filepath else: # .b2d if not os.path.isdir(self.localpath): @@ -228,14 +233,14 @@ def _update_map_tree(self): for root, _, files in os.walk(self.working_dir): for file in files: filepath = os.path.join(root, file) - if filepath.endswith((".b2nd", ".b2f")): + if filepath.endswith((".b2nd", ".b2f", ".b2b")): # Convert filename to key: remove extension and ensure starts with / rel_path = os.path.relpath(filepath, self.working_dir) # Normalize path separators to forward slashes for cross-platform consistency rel_path = rel_path.replace(os.sep, "/") if rel_path.endswith(".b2nd"): key = rel_path[:-5] - elif rel_path.endswith(".b2f"): + elif rel_path.endswith(".b2b") or rel_path.endswith(".b2f"): key = rel_path[:-4] else: continue @@ -264,6 +269,8 @@ def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.Ba def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" + if isinstance(value, blosc2.BatchStore): + return ".b2b" return ".b2f" def __setitem__( diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 7f4fe6ba..9be6672f 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -668,8 +668,15 @@ def _persist_vlmeta(self) -> None: """ if hasattr(self, "_vlmeta_key"): vlmeta_key = self._vlmeta_key - # Only embedded case is expected; handle it safely. - if hasattr(self, "_estore") and vlmeta_key in self._estore: + if vlmeta_key in self.map_tree: + filepath = self.map_tree[vlmeta_key] + dest_path = os.path.join(self.working_dir, filepath) + parent_dir = os.path.dirname(dest_path) + if parent_dir and not os.path.exists(parent_dir): + os.makedirs(parent_dir, exist_ok=True) + with open(dest_path, "wb") as f: + f.write(self._vlmeta.to_cframe()) + elif hasattr(self, "_estore") and vlmeta_key in self._estore: # Replace the stored snapshot with contextlib.suppress(KeyError): del self._estore[vlmeta_key] diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index 5da45f64..bfbf791c 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -654,6 +654,34 @@ def test_external_vlarray_support(): os.remove("test_vlarray_external.b2z") +def test_external_batchstore_support(tmp_path): + store_path = tmp_path / "test_batchstore_external.b2d" + + with TreeStore(str(store_path), mode="w", threshold=0) as tstore: + bstore = blosc2.BatchStore(blocksize_max=2) + bstore.extend([[{"id": 1}, {"id": 2}], [{"id": 3}]]) + tstore["/data/batchstore"] = bstore + + batchstore_path = store_path / "data" / "batchstore.b2b" + assert batchstore_path.exists() + + with TreeStore(str(store_path), mode="r") as tstore: + retrieved = tstore["/data/batchstore"] + assert isinstance(retrieved, blosc2.BatchStore) + assert [batch[:] for batch in retrieved] == [[{"id": 1}, {"id": 2}], [{"id": 3}]] + + +def test_treestore_vlmeta_externalized_b2d(tmp_path): + store_path = tmp_path / "test_vlmeta_externalized.b2d" + + with TreeStore(str(store_path), mode="w", threshold=0) as tstore: + tstore["/data"] = np.array([1, 2, 3]) + tstore.vlmeta["schema_manifest"] = {"version": 1, "fields": {"a": {"kind": "fixed"}}} + + with TreeStore(str(store_path), mode="r") as tstore: + assert tstore.vlmeta["schema_manifest"] == {"version": 1, "fields": {"a": {"kind": "fixed"}}} + + def test_walk_topdown_argument_ordering(): """Ensure walk supports topdown argument mimicking os.walk order semantics.""" with TreeStore("test_walk_topdown.b2z", mode="w") as tstore: From d21ed20c958a9a33795b79abbede47588a07c9b0 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 20:38:00 +0100 Subject: [PATCH 41/69] blocksize_max -> max_blocksize. also, this is persisted in metalayer now. --- bench/batch_store.py | 8 ++--- examples/batch_store.py | 8 ++--- src/blosc2/batch_store.py | 63 +++++++++++++++++++++++++++------------ tests/test_batch_store.py | 25 ++++++++-------- tests/test_tree_store.py | 2 +- 5 files changed, 66 insertions(+), 40 deletions(-) diff --git a/bench/batch_store.py b/bench/batch_store.py index abc5fbe0..ca3d83b0 100644 --- a/bench/batch_store.py +++ b/bench/batch_store.py @@ -61,7 +61,7 @@ def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) storage = blosc2.Storage(mode="w") store = blosc2.BatchStore( storage=storage, - blocksize_max=BLOCKSIZE_MAX, + max_blocksize=BLOCKSIZE_MAX, cparams={ "codec": codec, "clevel": clevel, @@ -79,7 +79,7 @@ def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) "clevel": clevel, "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4), } - with blosc2.BatchStore(storage=storage, blocksize_max=BLOCKSIZE_MAX, cparams=cparams) as store: + with blosc2.BatchStore(storage=storage, max_blocksize=BLOCKSIZE_MAX, cparams=cparams) as store: for batch_index in range(NBATCHES): store.append(make_batch(batch_index)) return None @@ -123,7 +123,7 @@ def main() -> None: assert store is not None read_store = store else: - read_store = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, blocksize_max=BLOCKSIZE_MAX) + read_store = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, max_blocksize=BLOCKSIZE_MAX) samples, timings_ns = measure_random_reads(read_store) t0 = time.perf_counter() checksum = 0 @@ -138,7 +138,7 @@ def main() -> None: print(f" build time: {build_time_s:.3f} s") print(f" batches: {len(read_store)}") print(f" objects: {TOTAL_OBJECTS}") - print(f" blocksize_max: {read_store.blocksize_max}") + print(f" max_blocksize: {read_store.max_blocksize}") print() print(read_store.info) print(f"Random scalar reads: {N_RANDOM_READS}") diff --git a/examples/batch_store.py b/examples/batch_store.py index 5a127576..4a387af5 100644 --- a/examples/batch_store.py +++ b/examples/batch_store.py @@ -36,7 +36,7 @@ def main() -> None: blosc2.remove_urlpath(URLPATH) storage = blosc2.Storage(urlpath=URLPATH, mode="w", contiguous=True) - with blosc2.BatchStore(storage=storage, blocksize_max=BLOCKSIZE_MAX) as store: + with blosc2.BatchStore(storage=storage, max_blocksize=BLOCKSIZE_MAX) as store: for batch_index in range(NBATCHES): store.append(make_batch(batch_index)) @@ -44,11 +44,11 @@ def main() -> None: print("Created BatchStore") print(f" batches: {len(store)}") print(f" objects: {total_objects}") - print(f" blocksize_max: {store.blocksize_max}") + print(f" max_blocksize: {store.max_blocksize}") - # Reopen with the same blocksize_max hint so scalar reads can use the + # Reopen with the same max_blocksize hint so scalar reads can use the # VL-block path instead of decoding the entire batch. - reopened = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, blocksize_max=BLOCKSIZE_MAX) + reopened = blosc2.BatchStore(urlpath=URLPATH, mode="r", contiguous=True, max_blocksize=BLOCKSIZE_MAX) print() print(reopened.info) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index 55f5adf6..78f1b364 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -18,7 +18,7 @@ from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_BATCHSTORE_META = {"version": 1, "serializer": "msgpack"} +_BATCHSTORE_META = {"version": 1, "serializer": "msgpack", "max_blocksize": None} def _check_serialized_size(buffer: bytes) -> None: @@ -69,9 +69,9 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: items = self._decode_items() index = self._normalize_index(index) return items[index] - blocksize_max = self._parent.blocksize_max - if blocksize_max is not None: - block_index, item_index = divmod(index, blocksize_max) + max_blocksize = self._parent.max_blocksize + if max_blocksize is not None: + block_index, item_index = divmod(index, max_blocksize) if block_index >= self._nblocks: raise IndexError("Batch index out of range") block = self._get_block(block_index) @@ -158,6 +158,11 @@ def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self.schunk = schunk self.mode = schunk.mode self.mmap_mode = getattr(schunk, "mmap_mode", None) + try: + batchstore_meta = self.schunk.meta["batchstore"] + except KeyError: + batchstore_meta = {} + self._max_blocksize = batchstore_meta.get("max_blocksize", self._max_blocksize) self._validate_tag() def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: @@ -181,13 +186,13 @@ def _make_storage(self) -> blosc2.Storage: def __init__( self, - blocksize_max: int | None = None, + max_blocksize: int | None = None, _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: - if blocksize_max is not None and blocksize_max <= 0: - raise ValueError("blocksize_max must be a positive integer") - self._blocksize_max: int | None = blocksize_max + if max_blocksize is not None and max_blocksize <= 0: + raise ValueError("max_blocksize must be a positive integer") + self._max_blocksize: int | None = max_blocksize if _from_schunk is not None: if kwargs: unexpected = ", ".join(sorted(kwargs)) @@ -213,7 +218,7 @@ def __init__( return fixed_meta = dict(storage.meta or {}) - fixed_meta["batchstore"] = dict(_BATCHSTORE_META) + fixed_meta["batchstore"] = {**_BATCHSTORE_META, "max_blocksize": self._max_blocksize} storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) @@ -263,9 +268,29 @@ def _normalize_batch(self, value: object) -> list[Any]: return values def _ensure_layout_for_batch(self, batch: list[Any]) -> None: - if self._blocksize_max is None: + if self._max_blocksize is None: payload_sizes = [len(msgpack_packb(item)) for item in batch] - self._blocksize_max = self._guess_blocksize(payload_sizes) + self._max_blocksize = self._guess_blocksize(payload_sizes) + self._persist_max_blocksize() + + def _persist_max_blocksize(self) -> None: + if self._max_blocksize is None or len(self) > 0: + return + storage = self._make_storage() + fixed_meta = dict(storage.meta or {}) + fixed_meta["batchstore"] = { + **dict(fixed_meta.get("batchstore", {})), + "max_blocksize": self._max_blocksize, + } + storage.meta = fixed_meta + schunk = blosc2.SChunk( + chunksize=-1, + data=None, + cparams=copy.deepcopy(self.cparams), + dparams=copy.deepcopy(self.dparams), + storage=storage, + ) + self._attach_schunk(schunk) def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: @@ -301,11 +326,11 @@ def _vl_dparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.dparams) def _compress_batch(self, batch: list[Any]) -> bytes: - if self._blocksize_max is None: - raise RuntimeError("BatchStore blocksize_max is not initialized") + if self._max_blocksize is None: + raise RuntimeError("BatchStore max_blocksize is not initialized") blocks = [ - self._serialize_block(batch[i : i + self._blocksize_max]) - for i in range(0, len(batch), self._blocksize_max) + self._serialize_block(batch[i : i + self._max_blocksize]) + for i in range(0, len(batch), self._max_blocksize) ] return blosc2.blosc2_ext.vlcompress(blocks, **self._vl_cparams_kwargs()) @@ -446,8 +471,8 @@ def dparams(self): return self.schunk.dparams @property - def blocksize_max(self) -> int | None: - return self._blocksize_max + def max_blocksize(self) -> int | None: + return self._max_blocksize @property def typesize(self) -> int: @@ -492,7 +517,7 @@ def info_items(self) -> list: ("type", f"{self.__class__.__name__}"), ("nbatches", len(self)), ("batch stats", batch_stats), - ("blocksize_max", self.blocksize_max), + ("max_blocksize", self.max_blocksize), ("nitems", sum(batch_sizes)), ("nbytes", format_nbytes_info(self.nbytes)), ("cbytes", format_nbytes_info(self.cbytes)), @@ -510,7 +535,7 @@ def copy(self, **kwargs: Any) -> BatchStore: raise ValueError("meta should not be passed to copy") kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) - kwargs["blocksize_max"] = kwargs.get("blocksize_max", self.blocksize_max) + kwargs["max_blocksize"] = kwargs.get("max_blocksize", self.max_blocksize) if "storage" not in kwargs: kwargs["meta"] = self._copy_meta() diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index daed6145..42486257 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -46,8 +46,8 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert barray.append(batch) == i assert len(barray) == len(BATCHES) - assert barray.blocksize_max is not None - assert 1 <= barray.blocksize_max <= len(BATCHES[0]) + assert barray.max_blocksize is not None + assert 1 <= barray.max_blocksize <= len(BATCHES[0]) assert [batch[:] for batch in barray.iter_batches()] == BATCHES assert barray.append([1, 2]) == len(BATCHES) + 1 assert [batch[:] for batch in barray.iter_batches()][-1] == [1, 2] @@ -83,7 +83,7 @@ def test_batchstore_roundtrip(contiguous, urlpath): if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchStore) - assert reopened.blocksize_max is None + assert reopened.max_blocksize == barray.max_blocksize assert [batch[:] for batch in reopened.iter_batches()] == expected with pytest.raises(ValueError): reopened.append(["nope"]) @@ -145,7 +145,7 @@ def test_batchstore_info(): assert items["type"] == "BatchStore" assert items["nbatches"] == len(BATCHES) assert items["batch stats"].startswith("mean=") - assert items["blocksize_max"] == barray.blocksize_max + assert items["max_blocksize"] == barray.max_blocksize assert items["nitems"] == sum(len(batch) for batch in BATCHES) assert "urlpath" not in items assert "contiguous" not in items @@ -158,7 +158,7 @@ def test_batchstore_info(): assert "type" in text assert "BatchStore" in text assert "batch stats" in text - assert "blocksize_max" in text + assert "max_blocksize" in text def test_batchstore_zstd_does_not_use_dict_by_default(): @@ -167,9 +167,9 @@ def test_batchstore_zstd_does_not_use_dict_by_default(): assert barray.cparams.use_dict is False -def test_batchstore_explicit_blocksize_max(): - barray = blosc2.BatchStore(blocksize_max=2) - assert barray.blocksize_max == 2 +def test_batchstore_explicit_max_blocksize(): + barray = blosc2.BatchStore(max_blocksize=2) + assert barray.max_blocksize == 2 barray.append([1, 2, 3]) barray.append([4]) assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [4]] @@ -180,10 +180,10 @@ def test_batchstore_get_vlblock_and_scalar_access(): blosc2.remove_urlpath(urlpath) batch = [0, 1, 2, 3, 4] - barray = blosc2.BatchStore(storage=_storage(True, urlpath), blocksize_max=2) + barray = blosc2.BatchStore(storage=_storage(True, urlpath), max_blocksize=2) barray.append(batch) - assert barray.blocksize_max == 2 + assert barray.max_blocksize == 2 assert msgpack_unpackb(barray.schunk.get_vlblock(0, 0)) == batch[:2] assert msgpack_unpackb(barray.schunk.get_vlblock(0, 1)) == batch[2:4] assert msgpack_unpackb(barray.schunk.get_vlblock(0, 2)) == batch[4:] @@ -194,6 +194,7 @@ def test_batchstore_get_vlblock_and_scalar_access(): reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchStore) + assert reopened.max_blocksize == 2 assert reopened[0][0] == 0 assert reopened[0][2] == 2 assert reopened[0][4] == 4 @@ -203,7 +204,7 @@ def test_batchstore_get_vlblock_and_scalar_access(): def test_batchstore_scalar_reads_cache_vlblocks(): - barray = blosc2.BatchStore(blocksize_max=2) + barray = blosc2.BatchStore(max_blocksize=2) barray.append([0, 1, 2, 3, 4]) batch = barray[0] @@ -227,7 +228,7 @@ def wrapped_get_vlblock(nchunk, nblock): def test_batchstore_iter_objects(): - barray = blosc2.BatchStore(blocksize_max=2) + barray = blosc2.BatchStore(max_blocksize=2) batches = [[1, 2, 3], [4], [5, 6]] barray.extend(batches) diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index bfbf791c..27b86452 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -658,7 +658,7 @@ def test_external_batchstore_support(tmp_path): store_path = tmp_path / "test_batchstore_external.b2d" with TreeStore(str(store_path), mode="w", threshold=0) as tstore: - bstore = blosc2.BatchStore(blocksize_max=2) + bstore = blosc2.BatchStore(max_blocksize=2) bstore.extend([[{"id": 1}, {"id": 2}], [{"id": 3}]]) tstore["/data/batchstore"] = bstore From 1d384cf610dc29d88472ae623d74735edbe7c8e5 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 19 Mar 2026 20:49:56 +0100 Subject: [PATCH 42/69] Too difficult to make general --- src/blosc2/linalg.py | 108 ++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index c050bf11..36437bfa 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -115,61 +115,73 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra # multithreaded matmul # TODO: handle a) type promotion, b) padding (explicitly), c) (improved) >2D ops = (x1, x2, result) - blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) global try_miniexpr # Use a local copy so we don't modify the global use_miniexpr = try_miniexpr - if all_ndarray: - if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition + if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + if all_ndarray: + if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition + use_miniexpr = False + + # Just force same chunk/block shapes + same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) + same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) + same_shape = all(op.shape == result.shape for op in (x1, x2)) + + use_miniexpr &= same_blocks & same_chunks & same_shape + + # TODO: We can relax this to even just load according to result blockshape, but that's difficult. + # Two easier cases are presented below + # Case 1: Might want to restrict loading across chunk boundaries, in which case would require: + # x1.chunks[-2] % result.blocks[-2] == 0 + # x2.chunks[-1] % result.blocks[-1] == 0 + # x2.chunks[-2] % x1.blocks[-1] == 0 + # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] + # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] + + # Case 2: Slightly easier to implement this maybe + # Require that blocks are matmul compatible and broadcastable directly to result + # (M, K) x (K, N) = (M, N) + # so can load block-by-block for inputs and calculate block of output + # Also need to avoid loading across chunk boundaries + # chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + # chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + # chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + # same_blocks = x2.blocks[-2] == x1.blocks[-1] + # same_blocks &= x2.blocks[-1] == result.blocks[-1] + # same_blocks &= result.blocks[-2] == x1.blocks[-2] + # try: + # result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + # if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): + # use_miniexpr = False + # except ValueError: + # use_miniexpr = False + + use_miniexpr &= x1.dtype.kind in ("i", "f") + use_miniexpr &= x2.dtype.kind in ("i", "f") + use_miniexpr &= x1.dtype == x2.dtype + + else: use_miniexpr = False - # TODO: In fact the following can be relaxed too, just need to load across block boundaries - # Might want to restrict loading across chunk boundaries, in which case would require: - # x1.chunks[-2] % result.blocks[-2] == 0 - # x2.chunks[-1] % result.blocks[-1] == 0 - # x2.chunks[-2] % x1.blocks[-1] == 0 - # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] - # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] - - # Require that blocks are matmul compatible and broadcastable directly to result - # (M, K) x (K, N) = (M, N) - # so can load block-by-block for inputs and calculate block of output - # Also need to avoid loading across chunk boundaries - chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 - chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 - chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 - same_blocks = x2.blocks[-2] == x1.blocks[-1] - same_blocks &= x2.blocks[-1] == result.blocks[-1] - same_blocks &= result.blocks[-2] == x1.blocks[-2] - try: - result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) - except ValueError: - use_miniexpr = False - if not (same_blocks and chunks_aligned and result_blocks[:-2] == blocks[:-2]): - use_miniexpr = False - - else: - use_miniexpr = False - - if use_miniexpr: - prefilter_set = False - try: - result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) - prefilter_set = True - # Data to compress is fetched from operands, so it can be uninitialized here - data = np.empty(result.schunk.chunksize, dtype=np.uint8) - for nchunk_out in range(result.schunk.nchunks): - result.schunk.update_data(nchunk_out, data, copy=False) - except Exception as e: - raise Exception from e - finally: - if prefilter_set: - result.schunk.remove_prefilter("miniexpr") - else: # couldn't do multithreading - print("multithreading failed :( ") - if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + if use_miniexpr: + prefilter_set = False + try: + result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) + prefilter_set = True + # Data to compress is fetched from operands, so it can be uninitialized here + data = np.empty(result.schunk.chunksize, dtype=np.uint8) + for nchunk_out in range(result.schunk.nchunks): + result.schunk.update_data(nchunk_out, data, copy=False) + except Exception as e: + raise Exception from e + finally: + if prefilter_set: + result.schunk.remove_prefilter("miniexpr") + else: # couldn't do multithreading + print("multithreading failed :( ") p, q = result.chunks[-2:] r = x2.chunks[-1] From 9c177bd0226e341d0a9b76968d39404c6e931b16 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 19 Mar 2026 21:07:02 +0100 Subject: [PATCH 43/69] Adapt max_blocksize depending on the clevel --- bench/batch_store.py | 8 ++++---- src/blosc2/batch_store.py | 14 +++++++++++--- src/blosc2/storage.py | 4 +++- tests/test_batch_store.py | 25 +++++++++++++++++++++++++ tests/test_vlarray.py | 4 ++++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/bench/batch_store.py b/bench/batch_store.py index ca3d83b0..9c1f992b 100644 --- a/bench/batch_store.py +++ b/bench/batch_store.py @@ -51,7 +51,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--codec", type=str, default="ZSTD", choices=[codec.name for codec in blosc2.Codec]) parser.add_argument("--clevel", type=int, default=5) - parser.add_argument("--use-dict", action="store_true", help="Enable dictionaries for ZSTD/LZ4 codecs.") + parser.add_argument("--use-dict", action="store_true", help="Enable dictionaries for ZSTD/LZ4/LZ4HC codecs.") parser.add_argument("--in-mem", action="store_true", help="Keep the BatchStore purely in memory.") return parser @@ -65,7 +65,7 @@ def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) cparams={ "codec": codec, "clevel": clevel, - "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4), + "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4, blosc2.Codec.LZ4HC), }, ) for batch_index in range(NBATCHES): @@ -77,7 +77,7 @@ def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) cparams = { "codec": codec, "clevel": clevel, - "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4), + "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4, blosc2.Codec.LZ4HC), } with blosc2.BatchStore(storage=storage, max_blocksize=BLOCKSIZE_MAX, cparams=cparams) as store: for batch_index in range(NBATCHES): @@ -107,7 +107,7 @@ def main() -> None: parser = build_parser() args = parser.parse_args() codec = blosc2.Codec[args.codec] - use_dict = args.use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4) + use_dict = args.use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4, blosc2.Codec.LZ4HC) mode_label = "in-memory" if args.in_mem else "persistent" article = "an" if args.in_mem else "a" diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index 78f1b364..71a08e81 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -295,13 +295,21 @@ def _persist_max_blocksize(self) -> None: def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: raise ValueError("BatchStore entries cannot be empty") - l2_cache_size = blosc2.cpu_info.get("l2_cache_size") - if not isinstance(l2_cache_size, int) or l2_cache_size <= 0: + clevel = self.cparams.clevel + if clevel == 9: + return len(payload_sizes) + if 0 < clevel < 6: + budget = blosc2.cpu_info.get("l1_data_cache_size") + elif 6 <= clevel < 9: + budget = blosc2.cpu_info.get("l2_cache_size") + else: + return len(payload_sizes) + if not isinstance(budget, int) or budget <= 0: return len(payload_sizes) total = 0 count = 0 for payload_size in payload_sizes: - if count > 0 and total + payload_size > l2_cache_size: + if count > 0 and total + payload_size > budget: break total += payload_size count += 1 diff --git a/src/blosc2/storage.py b/src/blosc2/storage.py index 43835118..0015aea9 100644 --- a/src/blosc2/storage.py +++ b/src/blosc2/storage.py @@ -46,7 +46,9 @@ class CParams: (maximum compression). Default is 1. use_dict: bool Whether to use dictionaries when compressing - (only for :py:obj:`blosc2.Codec.ZSTD `). Default is `False`. + (supported for :py:obj:`blosc2.Codec.ZSTD `, + :py:obj:`blosc2.Codec.LZ4 `, and + :py:obj:`blosc2.Codec.LZ4HC `). Default is `False`. typesize: int The data type size, ranging from 1 to 255. Default is 8. nthreads: int diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index 42486257..29a2b701 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -241,6 +241,10 @@ def test_batchstore_respects_explicit_use_dict_and_non_zstd(): assert barray.cparams.codec == blosc2.Codec.LZ4 assert barray.cparams.use_dict is False + barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.LZ4HC, "clevel": 1, "use_dict": True}) + assert barray.cparams.codec == blosc2.Codec.LZ4HC + assert barray.cparams.use_dict is True + barray = blosc2.BatchStore(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) assert barray.cparams.codec == blosc2.Codec.ZSTD assert barray.cparams.use_dict is False @@ -252,6 +256,27 @@ def test_batchstore_respects_explicit_use_dict_and_non_zstd(): assert barray.cparams.use_dict is False +def test_batchstore_guess_max_blocksize_uses_l1_for_low_clevel(monkeypatch): + monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) + monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 1000) + barray = blosc2.BatchStore(cparams={"clevel": 5}) + assert barray._guess_blocksize([30, 30, 30, 30]) == 3 + + +def test_batchstore_guess_max_blocksize_uses_l2_for_mid_clevel(monkeypatch): + monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) + monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 150) + barray = blosc2.BatchStore(cparams={"clevel": 6}) + assert barray._guess_blocksize([60, 60, 60, 60]) == 2 + + +def test_batchstore_guess_max_blocksize_uses_full_batch_for_clevel_9(monkeypatch): + monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 1) + monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 1) + barray = blosc2.BatchStore(cparams={"clevel": 9}) + assert barray._guess_blocksize([100, 100, 100, 100]) == 4 + + def test_vlcompress_small_blocks_roundtrip(): values = [ {"value": None}, diff --git a/tests/test_vlarray.py b/tests/test_vlarray.py index 2c792e10..0c1f01f3 100644 --- a/tests/test_vlarray.py +++ b/tests/test_vlarray.py @@ -155,6 +155,10 @@ def test_vlarray_respects_explicit_use_dict_and_non_zstd(): assert vlarray.cparams.codec == blosc2.Codec.LZ4 assert vlarray.cparams.use_dict is False + vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.LZ4HC, "clevel": 1, "use_dict": True}) + assert vlarray.cparams.codec == blosc2.Codec.LZ4HC + assert vlarray.cparams.use_dict is True + vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) assert vlarray.cparams.codec == blosc2.Codec.ZSTD assert vlarray.cparams.use_dict is False From a65cb690c6507d5dd619f7a1bac276cbd6a5d2a1 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 08:13:46 +0100 Subject: [PATCH 44/69] Revamped BatchStore. Add arrow as an optional serializer, and much more, including docs. --- bench/batch_store.py | 29 +- .../tutorials/12.batchstore.ipynb | 392 ++++++++++++++++++ doc/reference/batch_store.rst | 93 +++++ doc/reference/classes.rst | 2 + pyproject.toml | 6 + src/blosc2/batch_store.py | 373 +++++++++++++++-- tests/test_batch_store.py | 134 ++++-- 7 files changed, 945 insertions(+), 84 deletions(-) create mode 100644 doc/getting_started/tutorials/12.batchstore.ipynb create mode 100644 doc/reference/batch_store.rst diff --git a/bench/batch_store.py b/bench/batch_store.py index 9c1f992b..7b84370d 100644 --- a/bench/batch_store.py +++ b/bench/batch_store.py @@ -51,17 +51,21 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--codec", type=str, default="ZSTD", choices=[codec.name for codec in blosc2.Codec]) parser.add_argument("--clevel", type=int, default=5) + parser.add_argument("--serializer", type=str, default="msgpack", choices=["msgpack", "arrow"]) parser.add_argument("--use-dict", action="store_true", help="Enable dictionaries for ZSTD/LZ4/LZ4HC codecs.") parser.add_argument("--in-mem", action="store_true", help="Keep the BatchStore purely in memory.") return parser -def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) -> blosc2.BatchStore | None: +def build_store( + codec: blosc2.Codec, clevel: int, use_dict: bool, serializer: str, in_mem: bool +) -> blosc2.BatchStore | None: if in_mem: storage = blosc2.Storage(mode="w") store = blosc2.BatchStore( storage=storage, max_blocksize=BLOCKSIZE_MAX, + serializer=serializer, cparams={ "codec": codec, "clevel": clevel, @@ -79,7 +83,9 @@ def build_store(codec: blosc2.Codec, clevel: int, use_dict: bool, in_mem: bool) "clevel": clevel, "use_dict": use_dict and codec in (blosc2.Codec.ZSTD, blosc2.Codec.LZ4, blosc2.Codec.LZ4HC), } - with blosc2.BatchStore(storage=storage, max_blocksize=BLOCKSIZE_MAX, cparams=cparams) as store: + with blosc2.BatchStore( + storage=storage, max_blocksize=BLOCKSIZE_MAX, serializer=serializer, cparams=cparams + ) as store: for batch_index in range(NBATCHES): store.append(make_batch(batch_index)) return None @@ -114,10 +120,13 @@ def main() -> None: print(f"Building {article} {mode_label} BatchStore with 1,000,000 RGB dicts and timing 1,000 random scalar reads...") print(f" codec: {codec.name}") print(f" clevel: {args.clevel}") + print(f" serializer: {args.serializer}") print(f" use_dict: {use_dict}") print(f" in_mem: {args.in_mem}") t0 = time.perf_counter() - store = build_store(codec=codec, clevel=args.clevel, use_dict=use_dict, in_mem=args.in_mem) + store = build_store( + codec=codec, clevel=args.clevel, use_dict=use_dict, serializer=args.serializer, in_mem=args.in_mem + ) build_time_s = time.perf_counter() - t0 if args.in_mem: assert store is not None @@ -127,17 +136,17 @@ def main() -> None: samples, timings_ns = measure_random_reads(read_store) t0 = time.perf_counter() checksum = 0 - nobjects = 0 - for obj in read_store.iter_objects(): - checksum += obj["blue"] - nobjects += 1 + nitems = 0 + for item in read_store.iter_items(): + checksum += item["blue"] + nitems += 1 iter_time_s = time.perf_counter() - t0 print() print("BatchStore benchmark") print(f" build time: {build_time_s:.3f} s") print(f" batches: {len(read_store)}") - print(f" objects: {TOTAL_OBJECTS}") + print(f" items: {TOTAL_OBJECTS}") print(f" max_blocksize: {read_store.max_blocksize}") print() print(read_store.info) @@ -145,8 +154,8 @@ def main() -> None: print(f" mean: {statistics.fmean(timings_ns) / 1_000:.2f} us") print(f" max: {max(timings_ns) / 1_000:.2f} us") print(f" min: {min(timings_ns) / 1_000:.2f} us") - print(f"Object iteration via iter_objects(): {iter_time_s:.3f} s") - print(f" per object: {iter_time_s * 1_000_000 / nobjects:.2f} us") + print(f"Item iteration via iter_items(): {iter_time_s:.3f} s") + print(f" per item: {iter_time_s * 1_000_000 / nitems:.2f} us") print(f" checksum: {checksum}") print("Sample reads:") for timing_ns, batch_index, item_index, value in samples[:5]: diff --git a/doc/getting_started/tutorials/12.batchstore.ipynb b/doc/getting_started/tutorials/12.batchstore.ipynb new file mode 100644 index 00000000..b898c455 --- /dev/null +++ b/doc/getting_started/tutorials/12.batchstore.ipynb @@ -0,0 +1,392 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2c822501cae3b91d", + "metadata": {}, + "source": [ + "# Working with BatchStore\n", + "\n", + "A `BatchStore` is a batch-oriented container for variable-length Python items backed by a single `SChunk`. Each batch is stored in one compressed chunk, and each chunk may contain one or more internal variable-length blocks.\n", + "\n", + "This makes `BatchStore` a good fit when data arrives naturally in batches and you want efficient batch append/update operations together with occasional item-level access inside each batch." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "be8591f8f86952e8", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.876287Z", + "start_time": "2026-03-20T07:06:05.661801Z" + } + }, + "outputs": [], + "source": [ + "import blosc2\n", + "\n", + "\n", + "def show(label, value):\n", + " print(f\"{label}: {value}\")\n", + "\n", + "\n", + "urlpath = \"batchstore_tutorial.b2b\"\n", + "copy_path = \"batchstore_tutorial_copy.b2b\"\n", + "blosc2.remove_urlpath(urlpath)\n", + "blosc2.remove_urlpath(copy_path)" + ] + }, + { + "cell_type": "markdown", + "id": "dda38c56e3e63ec1", + "metadata": {}, + "source": [ + "## Creating and populating a BatchStore\n", + "\n", + "A `BatchStore` is indexed by batch. Batches can be appended one by one with `append()` or in bulk with `extend()`. Here we set a small `max_blocksize` just so the internal block structure is easy to observe in `.info`." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f8c8a2b7692e7228", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.904277Z", + "start_time": "2026-03-20T07:06:05.882545Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Batches: [[{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}], [{'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}], [{'name': 'zeta', 'count': 6}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]]\n", + "Number of batches: 4\n" + ] + } + ], + "source": [ + "store = blosc2.BatchStore(urlpath=urlpath, mode=\"w\", contiguous=True, max_blocksize=2)\n", + "store.append(\n", + " [\n", + " {\"name\": \"alpha\", \"count\": 1},\n", + " {\"name\": \"beta\", \"count\": 2},\n", + " {\"name\": \"gamma\", \"count\": 3},\n", + " ]\n", + ")\n", + "store.append(\n", + " [\n", + " {\"name\": \"delta\", \"count\": 4},\n", + " {\"name\": \"epsilon\", \"count\": 5},\n", + " ]\n", + ")\n", + "store.extend(\n", + " [\n", + " [{\"name\": \"zeta\", \"count\": 6}],\n", + " [{\"name\": \"eta\", \"count\": 7}, {\"name\": \"theta\", \"count\": 8}],\n", + " ]\n", + ")\n", + "\n", + "show(\"Batches\", [batch[:] for batch in store])\n", + "show(\"Number of batches\", len(store))" + ] + }, + { + "cell_type": "markdown", + "id": "f57fc5cf2cbaa9ba", + "metadata": {}, + "source": [ + "## Batch and item access\n", + "\n", + "Indexing the store returns a batch. Indexing a batch returns an item inside that batch. Flat item-wise traversal is available through `iter_items()`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "20861d3e348f9df1", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.924634Z", + "start_time": "2026-03-20T07:06:05.905576Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "First batch: [{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}]\n", + "Second item in first batch: {'name': 'beta', 'count': 2}\n", + "Slice of second batch: [{'name': 'delta', 'count': 4}]\n", + "All items: [{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}, {'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}, {'name': 'zeta', 'count': 6}, {'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]\n" + ] + } + ], + "source": [ + "show(\"First batch\", store[0][:])\n", + "show(\"Second item in first batch\", store[0][1])\n", + "show(\"Slice of second batch\", store[1][:1])\n", + "show(\"All items\", list(store.iter_items()))" + ] + }, + { + "cell_type": "markdown", + "id": "eba42acee73bffe3", + "metadata": {}, + "source": [ + "## Updating, inserting, and deleting batches\n", + "\n", + "Mutation is batch-oriented too: you overwrite, insert, delete, and pop whole batches." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "df556f6da8adc369", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.945986Z", + "start_time": "2026-03-20T07:06:05.925866Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Popped batch: [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]\n", + "After updates: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n" + ] + } + ], + "source": [ + "store[1] = [\n", + " {\"name\": \"delta*\", \"count\": 40},\n", + " {\"name\": \"epsilon*\", \"count\": 50},\n", + "]\n", + "store.insert(1, [{\"name\": \"between\", \"count\": 99}])\n", + "removed = store.pop()\n", + "del store[0]\n", + "\n", + "show(\"Popped batch\", removed)\n", + "show(\"After updates\", [batch[:] for batch in store])" + ] + }, + { + "cell_type": "markdown", + "id": "e48791c431156e56", + "metadata": {}, + "source": [ + "## Iteration and summary info\n", + "\n", + "Iterating a `BatchStore` yields batches. The `.info` summary reports both batch-level and internal block-level statistics." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b32d72a68d83673e", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.965086Z", + "start_time": "2026-03-20T07:06:05.947144Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Batches via iteration: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "type : BatchStore\n", + "serializer : msgpack\n", + "nbatches : 3 (items per batch: mean=1.33, max=2, min=1)\n", + "nblocks : 3 (items per block: mean=1.33, max=2, min=1)\n", + "nitems : 4\n", + "nbytes : 84 (84 B)\n", + "cbytes : 468 (468 B)\n", + "cratio : 0.18\n", + "cparams : CParams(codec=, codec_meta=0, clevel=5, use_dict=False, typesize=1,\n", + " : nthreads=8, blocksize=0, splitmode=,\n", + " : filters=[, , ,\n", + " : , , ], filters_meta=[0,\n", + " : 0, 0, 0, 0, 0], tuner=)\n", + "dparams : DParams(nthreads=8)\n", + "\n" + ] + } + ], + "source": [ + "show(\"Batches via iteration\", [batch[:] for batch in store])\n", + "print(store.info)" + ] + }, + { + "cell_type": "markdown", + "id": "1d6abe8fe87d3663", + "metadata": {}, + "source": [ + "## Copying and changing storage settings\n", + "\n", + "Like other Blosc2 containers, `BatchStore.copy()` can write a new persistent store while changing storage or compression settings." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "45f878b8f4414a3b", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:05.990783Z", + "start_time": "2026-03-20T07:06:05.965791Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Copied batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "Copy serializer: msgpack\n", + "Copy codec: Codec.LZ4\n" + ] + } + ], + "source": [ + "store_copy = store.copy(\n", + " urlpath=copy_path,\n", + " contiguous=False,\n", + " cparams={\"codec\": blosc2.Codec.LZ4, \"clevel\": 5},\n", + ")\n", + "\n", + "show(\"Copied batches\", [batch[:] for batch in store_copy])\n", + "show(\"Copy serializer\", store_copy.serializer)\n", + "show(\"Copy codec\", store_copy.cparams.codec)" + ] + }, + { + "cell_type": "markdown", + "id": "19c51a629db1209", + "metadata": {}, + "source": [ + "## Round-tripping through cframes and reopening from disk\n", + "\n", + "Tagged persistent stores automatically reopen as `BatchStore`, and a serialized cframe buffer does too." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "fd4957093f509bd4", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:06.025738Z", + "start_time": "2026-03-20T07:06:05.999799Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "from_cframe type: BatchStore\n", + "from_cframe batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "Reopened type: BatchStore\n", + "Reopened batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n" + ] + } + ], + "source": [ + "cframe = store.to_cframe()\n", + "restored = blosc2.from_cframe(cframe)\n", + "show(\"from_cframe type\", type(restored).__name__)\n", + "show(\"from_cframe batches\", [batch[:] for batch in restored])\n", + "\n", + "reopened = blosc2.open(urlpath, mode=\"r\", mmap_mode=\"r\")\n", + "show(\"Reopened type\", type(reopened).__name__)\n", + "show(\"Reopened batches\", [batch[:] for batch in reopened])" + ] + }, + { + "cell_type": "markdown", + "id": "dc362a1cab78d016", + "metadata": {}, + "source": [ + "## Clearing and reusing a store\n", + "\n", + "Calling `clear()` resets the backing storage so the container remains ready for new batches." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "2214b2be1bfb5bc7", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:06.050975Z", + "start_time": "2026-03-20T07:06:06.034152Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "After clear + extend: [[{'name': 'fresh', 'count': 1}], [{'name': 'again', 'count': 2}, {'name': 'done', 'count': 3}]]\n" + ] + } + ], + "source": [ + "scratch = store.copy()\n", + "scratch.clear()\n", + "scratch.extend(\n", + " [\n", + " [{\"name\": \"fresh\", \"count\": 1}],\n", + " [{\"name\": \"again\", \"count\": 2}, {\"name\": \"done\", \"count\": 3}],\n", + " ]\n", + ")\n", + "show(\"After clear + extend\", [batch[:] for batch in scratch])\n", + "\n", + "blosc2.remove_urlpath(urlpath)\n", + "blosc2.remove_urlpath(copy_path)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "27c47e4fd1332b48", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T07:06:06.061727Z", + "start_time": "2026-03-20T07:06:06.051400Z" + } + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/reference/batch_store.rst b/doc/reference/batch_store.rst new file mode 100644 index 00000000..6b1edc8d --- /dev/null +++ b/doc/reference/batch_store.rst @@ -0,0 +1,93 @@ +.. _BatchStore: + +BatchStore +========== + +Overview +-------- +BatchStore is a batch-oriented container for variable-length Python items +backed by a single Blosc2 ``SChunk``. + +Each batch is stored in one compressed chunk: + +- batches contain one or more Python items +- each chunk may contain one or more internal variable-length blocks +- the store itself is indexed by batch +- item-wise traversal is available via :meth:`BatchStore.iter_items` + +BatchStore is a good fit when data arrives naturally in batches and you want: + +- efficient batch append/update operations +- persistent ``.b2b`` stores +- item-level reads inside a batch +- compact summary information about batches and internal blocks via ``.info`` + +Serializer support +------------------ + +BatchStore currently supports two serializers: + +- ``"msgpack"``: the default and general-purpose choice for Python items +- ``"arrow"``: optional and requires ``pyarrow``; mainly useful when data is + already Arrow-shaped before ingestion + +Quick example +------------- + +.. code-block:: python + + import blosc2 + + store = blosc2.BatchStore(urlpath="example_batch_store.b2b", mode="w", contiguous=True) + store.append([{"red": 1, "green": 2, "blue": 3}, {"red": 4, "green": 5, "blue": 6}]) + store.append([{"red": 7, "green": 8, "blue": 9}]) + + print(store[0]) # first batch + print(store[0][1]) # second item in first batch + print(list(store.iter_items())) + + reopened = blosc2.open("example_batch_store.b2b", mode="r") + print(type(reopened).__name__) + print(reopened.info) + +.. note:: + BatchStore is batch-oriented by design. ``store[i]`` returns a batch, not a + single item. Use :meth:`BatchStore.iter_items` for flat item-wise traversal. + +.. currentmodule:: blosc2 + +.. autoclass:: BatchStore + + Constructors + ------------ + .. automethod:: __init__ + + Batch Interface + --------------- + .. automethod:: __getitem__ + .. automethod:: __setitem__ + .. automethod:: __delitem__ + .. automethod:: __len__ + .. automethod:: __iter__ + .. automethod:: iter_items + + Mutation + -------- + .. automethod:: append + .. automethod:: extend + .. automethod:: insert + .. automethod:: pop + .. automethod:: delete + .. automethod:: clear + .. automethod:: copy + + Context Manager + --------------- + .. automethod:: __enter__ + .. automethod:: __exit__ + + Public Members + -------------- + .. automethod:: to_cframe + +.. autoclass:: Batch diff --git a/doc/reference/classes.rst b/doc/reference/classes.rst index 84af533c..83733b2f 100644 --- a/doc/reference/classes.rst +++ b/doc/reference/classes.rst @@ -16,6 +16,7 @@ Main Classes DictStore TreeStore EmbedStore + BatchStore VLArray Proxy ProxySource @@ -34,6 +35,7 @@ Main Classes dict_store tree_store embed_store + batch_store vlarray proxy proxysource diff --git a/pyproject.toml b/pyproject.toml index 81c8d52a..c25612d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,12 @@ dependencies = [ "requests", ] version = "4.1.3.dev0" + +[project.optional-dependencies] +recommended = [ + "pyarrow", +] + [project.entry-points."array_api"] blosc2 = "blosc2" diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index 71a08e81..f0736b3e 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -12,13 +12,16 @@ import statistics from collections.abc import Iterator, Sequence from dataclasses import asdict +from functools import lru_cache from typing import Any import blosc2 from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info -_BATCHSTORE_META = {"version": 1, "serializer": "msgpack", "max_blocksize": None} +_BATCHSTORE_META = {"version": 1, "serializer": "msgpack", "max_blocksize": None, "arrow_schema": None} +_SUPPORTED_SERIALIZERS = {"msgpack", "arrow"} +_BATCHSTORE_VLMETA_KEY = "_batch_store_metadata" def _check_serialized_size(buffer: bytes) -> None: @@ -27,7 +30,15 @@ def _check_serialized_size(buffer: bytes) -> None: class Batch(Sequence[Any]): - """A lazy sequence of Python objects stored in one BatchStore batch.""" + """A lazy sequence representing one batch in a :class:`BatchStore`. + + ``Batch`` provides sequence-style access to the items stored in a single + batch. Integer indexing can use block-local reads when possible, while + slicing materializes the full batch into Python items. + + Batch instances are normally obtained via :class:`BatchStore` indexing or + iteration rather than constructed directly. + """ def __init__(self, parent: BatchStore, nbatch: int, lazybatch: bytes) -> None: self._parent = parent @@ -56,7 +67,7 @@ def _decode_items(self) -> list[Any]: def _get_block(self, block_index: int) -> list[Any]: if self._cached_block_index == block_index and self._cached_block is not None: return self._cached_block - block = msgpack_unpackb(self._parent.schunk.get_vlblock(self._nbatch, block_index)) + block = self._parent._deserialize_block(self._parent.schunk.get_vlblock(self._nbatch, block_index)) self._cached_block_index = block_index self._cached_block = block return block @@ -84,6 +95,9 @@ def __getitem__(self, index: int | slice) -> Any | list[Any]: return items[index] def __len__(self) -> int: + batch_length = self._parent._batch_length(self._nbatch) + if batch_length is not None: + return batch_length return len(self._decode_items()) def __iter__(self) -> Iterator[Any]: @@ -111,7 +125,40 @@ def __repr__(self) -> str: class BatchStore: - """A batched variable-length array backed by an :class:`blosc2.SChunk`.""" + """A batched container for variable-length Python items. + + BatchStore stores data as a sequence of *batches*, where each batch contains + one or more Python items. Each batch is stored in one compressed chunk, and + each chunk is internally split into one or more variable-length blocks for + efficient item access. + + The main abstraction is batch-oriented: + + - indexing the store returns batches + - iterating the store yields batches + - :meth:`iter_items` provides flat item-wise traversal + + BatchStore is a good fit when: + + - data arrives naturally in batches + - batch-level append/update operations are important + - occasional item-level reads are needed inside a batch + + Parameters + ---------- + max_blocksize : int, optional + Maximum number of items stored in each internal variable-length block. + If not provided, a value is inferred from the first batch. + serializer : {"msgpack", "arrow"}, optional + Serializer used for batch payloads. ``"msgpack"`` is the default and is + the general-purpose choice for Python items. ``"arrow"`` is optional and + requires ``pyarrow``. + _from_schunk : blosc2.SChunk, optional + Internal hook used when reopening an already-tagged BatchStore. + **kwargs + Storage, compression, and decompression arguments accepted by the + constructor. + """ @staticmethod def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | dict: @@ -162,7 +209,11 @@ def _attach_schunk(self, schunk: blosc2.SChunk) -> None: batchstore_meta = self.schunk.meta["batchstore"] except KeyError: batchstore_meta = {} + self._serializer = batchstore_meta.get("serializer", self._serializer) self._max_blocksize = batchstore_meta.get("max_blocksize", self._max_blocksize) + self._arrow_schema = batchstore_meta.get("arrow_schema", self._arrow_schema) + self._arrow_schema_obj = None + self._batch_lengths = self._load_batch_lengths() self._validate_tag() def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: @@ -187,12 +238,25 @@ def _make_storage(self) -> blosc2.Storage: def __init__( self, max_blocksize: int | None = None, + serializer: str = "msgpack", _from_schunk: blosc2.SChunk | None = None, **kwargs: Any, ) -> None: + """Create a new BatchStore or reopen an existing one. + + When a persistent ``urlpath`` points to an existing BatchStore and the + mode is ``"r"`` or ``"a"``, the container is reopened automatically. + Otherwise a new empty store is created. + """ if max_blocksize is not None and max_blocksize <= 0: raise ValueError("max_blocksize must be a positive integer") + if serializer not in _SUPPORTED_SERIALIZERS: + raise ValueError(f"Unsupported BatchStore serializer: {serializer!r}") self._max_blocksize: int | None = max_blocksize + self._serializer = serializer + self._arrow_schema: bytes | None = None + self._arrow_schema_obj = None + self._batch_lengths: list[int] | None = None if _from_schunk is not None: if kwargs: unexpected = ", ".join(sorted(kwargs)) @@ -218,7 +282,12 @@ def __init__( return fixed_meta = dict(storage.meta or {}) - fixed_meta["batchstore"] = {**_BATCHSTORE_META, "max_blocksize": self._max_blocksize} + fixed_meta["batchstore"] = { + **_BATCHSTORE_META, + "serializer": self._serializer, + "max_blocksize": self._max_blocksize, + "arrow_schema": self._arrow_schema, + } storage.meta = fixed_meta schunk = blosc2.SChunk(chunksize=-1, data=None, cparams=cparams, dparams=dparams, storage=storage) self._attach_schunk(schunk) @@ -226,6 +295,20 @@ def __init__( def _validate_tag(self) -> None: if "batchstore" not in self.schunk.meta: raise ValueError("The supplied SChunk is not tagged as a BatchStore") + if self._serializer not in _SUPPORTED_SERIALIZERS: + raise ValueError(f"Unsupported BatchStore serializer in metadata: {self._serializer!r}") + if self._serializer == "arrow": + self._require_pyarrow() + + @staticmethod + @lru_cache(maxsize=1) + def _require_pyarrow(): + try: + import pyarrow as pa + import pyarrow.ipc as pa_ipc + except ImportError as exc: + raise ImportError("BatchStore serializer='arrow' requires pyarrow") from exc + return pa, pa_ipc def _check_writable(self) -> None: if self.mode == "r": @@ -257,7 +340,72 @@ def _slice_indices(self, index: slice) -> list[int]: def _copy_meta(self) -> dict[str, Any]: return {name: self.meta[name] for name in self.meta} - def _normalize_batch(self, value: object) -> list[Any]: + def _load_batch_lengths(self) -> list[int] | None: + try: + metadata = self.schunk.vlmeta[_BATCHSTORE_VLMETA_KEY] + except KeyError: + return None + batch_lengths = metadata.get("batch_lengths") + if not isinstance(batch_lengths, list): + return None + return [int(length) for length in batch_lengths] + + def _persist_batch_lengths(self) -> None: + if self._batch_lengths is None: + return + self.schunk.vlmeta[_BATCHSTORE_VLMETA_KEY] = {"batch_lengths": list(self._batch_lengths)} + + def _get_batch_lengths(self) -> list[int] | None: + return self._batch_lengths + + def _ensure_batch_lengths(self) -> list[int]: + if self._batch_lengths is None: + self._batch_lengths = [] + return self._batch_lengths + + def _batch_length(self, index: int) -> int | None: + if self._batch_lengths is None: + return None + return self._batch_lengths[index] + + def _block_sizes_from_batch_length(self, batch_length: int, nblocks: int) -> list[int]: + if self._max_blocksize is None or nblocks <= 0: + return [] + full_blocks, remainder = divmod(batch_length, self._max_blocksize) + block_sizes = [self._max_blocksize] * full_blocks + if remainder: + block_sizes.append(remainder) + if not block_sizes and batch_length > 0: + block_sizes.append(batch_length) + if len(block_sizes) != nblocks: + return [] + return block_sizes + + def _get_block_sizes(self, batch_sizes: list[int]) -> list[int] | None: + if self._max_blocksize is None: + return None + block_sizes: list[int] = [] + for index, batch_length in enumerate(batch_sizes): + lazychunk = self.schunk.get_lazychunk(index) + _, _, nblocks = blosc2.get_cbuffer_sizes(lazychunk) + sizes = self._block_sizes_from_batch_length(batch_length, nblocks) + if not sizes: + return None + block_sizes.extend(sizes) + return block_sizes + + def _total_nblocks(self) -> int: + total = 0 + for index in range(len(self)): + lazychunk = self.schunk.get_lazychunk(index) + _, _, nblocks = blosc2.get_cbuffer_sizes(lazychunk) + total += nblocks + return total + + def _user_vlmeta_items(self) -> dict[str, Any]: + return {key: value for key, value in self.vlmeta.getall().items() if key != _BATCHSTORE_VLMETA_KEY} + + def _normalize_msgpack_batch(self, value: object) -> list[Any]: if isinstance(value, (str, bytes, bytearray, memoryview)): raise TypeError("BatchStore entries must be sequences of Python objects") if not isinstance(value, Sequence): @@ -267,20 +415,85 @@ def _normalize_batch(self, value: object) -> list[Any]: raise ValueError("BatchStore entries cannot be empty") return values - def _ensure_layout_for_batch(self, batch: list[Any]) -> None: + def _normalize_arrow_batch(self, value: object): + pa, _ = self._require_pyarrow() + if isinstance(value, pa.ChunkedArray): + value = value.combine_chunks() + elif isinstance(value, pa.RecordBatch): + if value.num_columns != 1: + raise TypeError("Arrow RecordBatch inputs for BatchStore must have exactly one column") + value = value.column(0) + elif not isinstance(value, pa.Array): + if isinstance(value, (str, bytes, bytearray, memoryview)): + raise TypeError("BatchStore entries must be Arrow arrays or sequences of Python objects") + if not isinstance(value, Sequence): + raise TypeError("BatchStore entries must be Arrow arrays or sequences of Python objects") + value = pa.array(list(value)) + if len(value) == 0: + raise ValueError("BatchStore entries cannot be empty") + self._ensure_arrow_schema(value) + return value + + def _ensure_arrow_schema(self, batch) -> None: + if self._serializer != "arrow": + return + pa, _ = self._require_pyarrow() + schema = pa.schema([pa.field("values", batch.type)]) + if self._arrow_schema is None: + self._arrow_schema = schema.serialize().to_pybytes() + self._arrow_schema_obj = schema + return + existing_schema = self._get_arrow_schema() + if not existing_schema.equals(schema): + raise TypeError("All Arrow batches in a BatchStore must share the same schema") + + def _get_arrow_schema(self): + if self._serializer != "arrow": + return None + if self._arrow_schema is None: + raise RuntimeError("Arrow schema is not initialized") + if self._arrow_schema_obj is None: + pa, pa_ipc = self._require_pyarrow() + self._arrow_schema_obj = pa_ipc.read_schema(pa.BufferReader(self._arrow_schema)) + return self._arrow_schema_obj + + def _normalize_batch(self, value: object) -> Any: + if self._serializer == "arrow": + return self._normalize_arrow_batch(value) + return self._normalize_msgpack_batch(value) + + def _batch_len(self, batch: Any) -> int: + return len(batch) + + def _payload_sizes_for_batch(self, batch: Any) -> list[int]: + if self._serializer == "arrow": + total_size = batch.get_total_buffer_size() + avg_size = max(1, total_size // max(1, len(batch))) + return [avg_size] * len(batch) + return [len(msgpack_packb(item)) for item in batch] + + def _ensure_layout_for_batch(self, batch: Any) -> None: + layout_changed = False if self._max_blocksize is None: - payload_sizes = [len(msgpack_packb(item)) for item in batch] + payload_sizes = self._payload_sizes_for_batch(batch) self._max_blocksize = self._guess_blocksize(payload_sizes) - self._persist_max_blocksize() - - def _persist_max_blocksize(self) -> None: - if self._max_blocksize is None or len(self) > 0: + layout_changed = True + if self._serializer == "arrow" and self._arrow_schema is not None: + layout_changed = layout_changed or len(self) == 0 + if layout_changed: + self._persist_layout_metadata() + + def _persist_layout_metadata(self) -> None: + if len(self) > 0: return + batch_lengths = None if self._batch_lengths is None else list(self._batch_lengths) storage = self._make_storage() fixed_meta = dict(storage.meta or {}) fixed_meta["batchstore"] = { **dict(fixed_meta.get("batchstore", {})), "max_blocksize": self._max_blocksize, + "serializer": self._serializer, + "arrow_schema": self._arrow_schema, } storage.meta = fixed_meta schunk = blosc2.SChunk( @@ -291,6 +504,8 @@ def _persist_max_blocksize(self) -> None: storage=storage, ) self._attach_schunk(schunk) + if batch_lengths is not None and self._batch_lengths is None: + self._batch_lengths = batch_lengths def _guess_blocksize(self, payload_sizes: list[int]) -> int: if not payload_sizes: @@ -317,28 +532,53 @@ def _guess_blocksize(self, payload_sizes: list[int]) -> int: count = 1 return min(count, len(payload_sizes)) - def _serialize_batch(self, value: object) -> list[Any]: + def _serialize_batch(self, value: object) -> Any: batch = self._normalize_batch(value) self._ensure_layout_for_batch(batch) return batch - def _serialize_block(self, items: list[Any]) -> bytes: + def _serialize_msgpack_block(self, items: list[Any]) -> bytes: payload = msgpack_packb(items) _check_serialized_size(payload) return payload + def _serialize_arrow_block(self, items) -> bytes: + pa, _ = self._require_pyarrow() + batch = pa.record_batch([items], schema=self._get_arrow_schema()) + payload = batch.serialize().to_pybytes() + _check_serialized_size(payload) + return payload + + def _serialize_block(self, items: Any) -> bytes: + if self._serializer == "arrow": + return self._serialize_arrow_block(items) + return self._serialize_msgpack_block(items) + + def _deserialize_msgpack_block(self, payload: bytes) -> list[Any]: + return msgpack_unpackb(payload) + + def _deserialize_arrow_block(self, payload: bytes) -> list[Any]: + pa, pa_ipc = self._require_pyarrow() + batch = pa_ipc.read_record_batch(pa.BufferReader(payload), self._get_arrow_schema()) + return batch.column(0).to_pylist() + + def _deserialize_block(self, payload: bytes) -> list[Any]: + if self._serializer == "arrow": + return self._deserialize_arrow_block(payload) + return self._deserialize_msgpack_block(payload) + def _vl_cparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.cparams) def _vl_dparams_kwargs(self) -> dict[str, Any]: return asdict(self.schunk.dparams) - def _compress_batch(self, batch: list[Any]) -> bytes: + def _compress_batch(self, batch: Any) -> bytes: if self._max_blocksize is None: raise RuntimeError("BatchStore max_blocksize is not initialized") blocks = [ self._serialize_block(batch[i : i + self._max_blocksize]) - for i in range(0, len(batch), self._max_blocksize) + for i in range(0, self._batch_len(batch), self._max_blocksize) ] return blosc2.blosc2_ext.vlcompress(blocks, **self._vl_cparams_kwargs()) @@ -346,53 +586,70 @@ def _decode_blocks(self, nbatch: int) -> list[list[Any]]: block_payloads = blosc2.blosc2_ext.vldecompress( self.schunk.get_chunk(nbatch), **self._vl_dparams_kwargs() ) - return [msgpack_unpackb(payload) for payload in block_payloads] + return [self._deserialize_block(payload) for payload in block_payloads] def _get_batch(self, index: int) -> Batch: return Batch(self, index, self.schunk.get_lazychunk(index)) def append(self, value: object) -> int: - """Append one batch and return the new number of entries.""" + """Append one batch and return the new number of batches.""" self._check_writable() batch = self._serialize_batch(value) batch_payload = self._compress_batch(batch) - return self.schunk.append_chunk(batch_payload) + length = self._batch_len(batch) + new_len = self.schunk.append_chunk(batch_payload) + self._ensure_batch_lengths().append(length) + self._persist_batch_lengths() + return new_len def insert(self, index: int, value: object) -> int: - """Insert one batch at ``index`` and return the new number of entries.""" + """Insert one batch at ``index`` and return the new number of batches.""" self._check_writable() index = self._normalize_insert_index(index) batch = self._serialize_batch(value) batch_payload = self._compress_batch(batch) - return self.schunk.insert_chunk(index, batch_payload) + length = self._batch_len(batch) + new_len = self.schunk.insert_chunk(index, batch_payload) + self._ensure_batch_lengths().insert(index, length) + self._persist_batch_lengths() + return new_len def delete(self, index: int | slice) -> int: - """Delete the batch at ``index`` and return the new number of entries.""" + """Delete the batch at ``index`` and return the new number of batches.""" self._check_writable() if isinstance(index, slice): for idx in reversed(self._slice_indices(index)): self.schunk.delete_chunk(idx) + if self._batch_lengths is not None: + del self._batch_lengths[idx] + self._persist_batch_lengths() return len(self) index = self._normalize_index(index) - return self.schunk.delete_chunk(index) + new_len = self.schunk.delete_chunk(index) + if self._batch_lengths is not None: + del self._batch_lengths[index] + self._persist_batch_lengths() + return new_len def pop(self, index: int = -1) -> list[Any]: - """Remove and return the batch at ``index``.""" + """Remove and return the batch at ``index`` as a Python list.""" self._check_writable() if isinstance(index, slice): raise NotImplementedError("Slicing is not supported for BatchStore") index = self._normalize_index(index) value = self[index][:] - self.schunk.delete_chunk(index) + self.delete(index) return value def extend(self, values: object) -> None: - """Append all batches from an iterable.""" + """Append all batches from an iterable of batches.""" self._check_writable() for value in values: batch = self._serialize_batch(value) batch_payload = self._compress_batch(batch) self.schunk.append_chunk(batch_payload) + self._ensure_batch_lengths().append(self._batch_len(batch)) + self._persist_batch_lengths() def clear(self) -> None: """Remove all entries from the container.""" @@ -408,8 +665,11 @@ def clear(self) -> None: storage=storage, ) self._attach_schunk(schunk) + self._batch_lengths = [] + self._persist_batch_lengths() def __getitem__(self, index: int | slice) -> Batch | list[Batch]: + """Return one batch or a list of batches.""" if isinstance(index, slice): return [self[i] for i in self._slice_indices(index)] index = self._normalize_index(index) @@ -425,10 +685,14 @@ def __setitem__(self, index: int | slice, value: object) -> None: start = self._normalize_insert_index(0 if index.start is None else index.start) for idx in reversed(indices): self.schunk.delete_chunk(idx) + if self._batch_lengths is not None: + del self._batch_lengths[idx] for offset, item in enumerate(values): batch = self._serialize_batch(item) batch_payload = self._compress_batch(batch) self.schunk.insert_chunk(start + offset, batch_payload) + self._ensure_batch_lengths().insert(start + offset, self._batch_len(batch)) + self._persist_batch_lengths() return if len(values) != len(indices): raise ValueError( @@ -438,29 +702,34 @@ def __setitem__(self, index: int | slice, value: object) -> None: batch = self._serialize_batch(item) batch_payload = self._compress_batch(batch) self.schunk.update_chunk(idx, batch_payload) + if self._batch_lengths is not None: + self._batch_lengths[idx] = self._batch_len(batch) + self._persist_batch_lengths() return self._check_writable() index = self._normalize_index(index) batch = self._serialize_batch(value) batch_payload = self._compress_batch(batch) self.schunk.update_chunk(index, batch_payload) + if self._batch_lengths is not None: + self._batch_lengths[index] = self._batch_len(batch) + self._persist_batch_lengths() def __delitem__(self, index: int | slice) -> None: self.delete(index) def __len__(self) -> int: + """Return the number of batches stored in the container.""" return self.schunk.nchunks - def iter_batches(self) -> Iterator[Batch]: - for i in range(len(self)): - yield self[i] - - def iter_objects(self) -> Iterator[Any]: - for batch in self.iter_batches(): + def iter_items(self) -> Iterator[Any]: + """Iterate over all items across all batches in order.""" + for batch in self: yield from batch def __iter__(self) -> Iterator[Batch]: - yield from self.iter_batches() + for i in range(len(self)): + yield self[i] @property def meta(self): @@ -508,24 +777,35 @@ def contiguous(self) -> bool: @property def info(self) -> InfoReporter: - """Print information about this BatchStore.""" + """Return an info reporter with a compact summary of the store.""" return InfoReporter(self) @property def info_items(self) -> list: - """A list of tuples with summary information about this BatchStore.""" - batch_sizes = [len(batch) for batch in self.iter_batches()] + """Return summary information as ``(name, value)`` pairs.""" + batch_sizes = self._get_batch_lengths() + if batch_sizes is None: + batch_sizes = [len(batch) for batch in self] + block_sizes = self._get_block_sizes(batch_sizes) if batch_sizes: batch_stats = ( f"mean={statistics.fmean(batch_sizes):.2f}, max={max(batch_sizes)}, min={min(batch_sizes)}" ) + nbatches_value = f"{len(self)} (items per batch: {batch_stats})" else: - batch_stats = "n/a" + nbatches_value = f"{len(self)} (items per batch: n/a)" + if block_sizes: + block_stats = ( + f"mean={statistics.fmean(block_sizes):.2f}, max={max(block_sizes)}, min={min(block_sizes)}" + ) + nblocks_value = f"{self._total_nblocks()} (items per block: {block_stats})" + else: + nblocks_value = f"{self._total_nblocks()} (items per block: n/a)" return [ ("type", f"{self.__class__.__name__}"), - ("nbatches", len(self)), - ("batch stats", batch_stats), - ("max_blocksize", self.max_blocksize), + ("serializer", self.serializer), + ("nbatches", nbatches_value), + ("nblocks", nblocks_value), ("nitems", sum(batch_sizes)), ("nbytes", format_nbytes_info(self.nbytes)), ("cbytes", format_nbytes_info(self.cbytes)), @@ -535,15 +815,17 @@ def info_items(self) -> list: ] def to_cframe(self) -> bytes: + """Serialize the full store to a Blosc2 cframe buffer.""" return self.schunk.to_cframe() def copy(self, **kwargs: Any) -> BatchStore: - """Create a copy of the container with optional constructor overrides.""" + """Create a copy of the store with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") kwargs["cparams"] = kwargs.get("cparams", copy.deepcopy(self.cparams)) kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) kwargs["max_blocksize"] = kwargs.get("max_blocksize", self.max_blocksize) + kwargs["serializer"] = kwargs.get("serializer", self.serializer) if "storage" not in kwargs: kwargs["meta"] = self._copy_meta() @@ -553,9 +835,9 @@ def copy(self, **kwargs: Any) -> BatchStore: out = BatchStore(**kwargs) if "storage" not in kwargs and len(self.vlmeta) > 0: - for key, value in self.vlmeta.getall().items(): + for key, value in self._user_vlmeta_items().items(): out.vlmeta[key] = value - out.extend(self.iter_batches()) + out.extend(self) return out def __enter__(self) -> BatchStore: @@ -566,3 +848,8 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> bool: def __repr__(self) -> str: return f"BatchStore(len={len(self)}, urlpath={self.urlpath!r})" + + @property + def serializer(self) -> str: + """Serializer name used for batch payloads.""" + return self._serializer diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index 29a2b701..c9f520ed 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -48,9 +48,9 @@ def test_batchstore_roundtrip(contiguous, urlpath): assert len(barray) == len(BATCHES) assert barray.max_blocksize is not None assert 1 <= barray.max_blocksize <= len(BATCHES[0]) - assert [batch[:] for batch in barray.iter_batches()] == BATCHES + assert [batch[:] for batch in barray] == BATCHES assert barray.append([1, 2]) == len(BATCHES) + 1 - assert [batch[:] for batch in barray.iter_batches()][-1] == [1, 2] + assert [batch[:] for batch in barray][-1] == [1, 2] batch0 = barray[0] assert isinstance(batch0, blosc2.Batch) @@ -78,13 +78,13 @@ def test_batchstore_roundtrip(contiguous, urlpath): del expected[2] del barray[-2] del expected[-2] - assert [batch[:] for batch in barray.iter_batches()] == expected + assert [batch[:] for batch in barray] == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") assert isinstance(reopened, blosc2.BatchStore) assert reopened.max_blocksize == barray.max_blocksize - assert [batch[:] for batch in reopened.iter_batches()] == expected + assert [batch[:] for batch in reopened] == expected with pytest.raises(ValueError): reopened.append(["nope"]) with pytest.raises(ValueError): @@ -105,12 +105,40 @@ def test_batchstore_roundtrip(contiguous, urlpath): reopened_rw = blosc2.open(urlpath, mode="a") reopened_rw[0] = ["changed", "batch", 0] expected[0] = ["changed", "batch", 0] - assert [batch[:] for batch in reopened_rw.iter_batches()] == expected + assert [batch[:] for batch in reopened_rw] == expected if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") assert isinstance(reopened_mmap, blosc2.BatchStore) - assert [batch[:] for batch in reopened_mmap.iter_batches()] == expected + assert [batch[:] for batch in reopened_mmap] == expected + + blosc2.remove_urlpath(urlpath) + + +def test_batchstore_arrow_ipc_roundtrip(): + pa = pytest.importorskip("pyarrow") + urlpath = "test_batchstore_arrow_ipc.b2b" + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchStore(storage=_storage(True, urlpath), serializer="arrow") + assert barray.serializer == "arrow" + assert barray.meta["batchstore"]["serializer"] == "arrow" + + batch1 = pa.array([[1, 2], None, [3]]) + batch2 = pa.array([[4], [5, 6]]) + barray.append(batch1) + barray.append(batch2) + + assert barray[0][:] == [[1, 2], None, [3]] + assert barray[1][:] == [[4], [5, 6]] + assert barray.meta["batchstore"]["arrow_schema"] is not None + + reopened = blosc2.open(urlpath, mode="r") + assert isinstance(reopened, blosc2.BatchStore) + assert reopened.serializer == "arrow" + assert reopened.meta["batchstore"]["serializer"] == "arrow" + assert reopened[0][:] == [[1, 2], None, [3]] + assert reopened[1][:] == [[4], [5, 6]] blosc2.remove_urlpath(urlpath) @@ -126,11 +154,11 @@ def test_batchstore_from_cframe(): restored = blosc2.from_cframe(barray.to_cframe()) assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored.iter_batches()] == expected + assert [batch[:] for batch in restored] == expected restored2 = blosc2.from_cframe(barray.to_cframe()) assert isinstance(restored2, blosc2.BatchStore) - assert [batch[:] for batch in restored2.iter_batches()] == expected + assert [batch[:] for batch in restored2] == expected def test_batchstore_info(): @@ -143,9 +171,9 @@ def test_batchstore_info(): items = dict(barray.info_items) assert items["type"] == "BatchStore" - assert items["nbatches"] == len(BATCHES) - assert items["batch stats"].startswith("mean=") - assert items["max_blocksize"] == barray.max_blocksize + assert items["serializer"] == "msgpack" + assert items["nbatches"].startswith(f"{len(BATCHES)} (items per batch: mean=") + assert items["nblocks"].startswith(str(len(BATCHES))) assert items["nitems"] == sum(len(batch) for batch in BATCHES) assert "urlpath" not in items assert "contiguous" not in items @@ -156,9 +184,53 @@ def test_batchstore_info(): text = repr(barray.info) assert "type" in text + assert "serializer" in text assert "BatchStore" in text - assert "batch stats" in text - assert "max_blocksize" in text + assert "items per batch" in text + assert "items per block" in text + + +def test_batchstore_info_uses_persisted_batch_lengths(): + barray = blosc2.BatchStore() + barray.extend(BATCHES) + + assert barray.vlmeta["_batch_store_metadata"]["batch_lengths"] == [len(batch) for batch in BATCHES] + + def fail_decode(*args, **kwargs): + raise AssertionError( + "info() should not deserialize batches when batch_lengths metadata is available" + ) + + original_decode_blocks = barray._decode_blocks + barray._decode_blocks = fail_decode + try: + items = dict(barray.info_items) + finally: + barray._decode_blocks = original_decode_blocks + + assert items["nitems"] == sum(len(batch) for batch in BATCHES) + assert "items per batch: mean=" in items["nbatches"] + + +def test_batchstore_info_reports_exact_block_stats_from_lazy_chunks(): + barray = blosc2.BatchStore(max_blocksize=2) + barray.extend([[1, 2, 3, 4, 5], [6, 7], [8]]) + + items = dict(barray.info_items) + assert items["nblocks"] == "5 (items per block: mean=1.60, max=2, min=1)" + + +def test_batchstore_pop_keeps_batch_lengths_metadata_in_sync(): + barray = blosc2.BatchStore(max_blocksize=2) + barray.extend([[1, 2, 3], [4, 5], [6]]) + + removed = barray.pop(1) + + assert removed == [4, 5] + assert [batch[:] for batch in barray] == [[1, 2, 3], [6]] + assert barray.vlmeta["_batch_store_metadata"]["batch_lengths"] == [3, 1] + items = dict(barray.info_items) + assert items["nbatches"].startswith("2 (items per batch: mean=2.00") def test_batchstore_zstd_does_not_use_dict_by_default(): @@ -172,7 +244,7 @@ def test_batchstore_explicit_max_blocksize(): assert barray.max_blocksize == 2 barray.append([1, 2, 3]) barray.append([4]) - assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [4]] + assert [batch[:] for batch in barray] == [[1, 2, 3], [4]] def test_batchstore_get_vlblock_and_scalar_access(): @@ -227,13 +299,13 @@ def wrapped_get_vlblock(nchunk, nblock): barray.schunk.get_vlblock = original_get_vlblock -def test_batchstore_iter_objects(): +def test_batchstore_iter_items(): barray = blosc2.BatchStore(max_blocksize=2) batches = [[1, 2, 3], [4], [5, 6]] barray.extend(batches) assert [batch[:] for batch in barray] == batches - assert list(barray.iter_objects()) == [1, 2, 3, 4, 5, 6] + assert list(barray.iter_items()) == [1, 2, 3, 4, 5, 6] def test_batchstore_respects_explicit_use_dict_and_non_zstd(): @@ -322,7 +394,7 @@ def test_batchstore_constructor_kwargs(): barray.extend(BATCHES) reopened = blosc2.BatchStore(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") - assert [batch[:] for batch in reopened.iter_batches()] == BATCHES + assert [batch[:] for batch in reopened] == BATCHES blosc2.remove_urlpath(urlpath) @@ -341,21 +413,21 @@ def test_batchstore_list_like_ops(contiguous, urlpath): barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath)) barray.extend([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + assert [batch[:] for batch in barray] == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] assert barray.pop() == [7, 8, 9] assert barray.pop(0) == [1, 2, 3] - assert [batch[:] for batch in barray.iter_batches()] == [[4, 5, 6]] + assert [batch[:] for batch in barray] == [[4, 5, 6]] barray.clear() assert len(barray) == 0 - assert [batch[:] for batch in barray.iter_batches()] == [] + assert [batch[:] for batch in barray] == [] barray.extend([["a", "b", "c"], ["d", "e", "f"]]) - assert [batch[:] for batch in barray.iter_batches()] == [["a", "b", "c"], ["d", "e", "f"]] + assert [batch[:] for batch in barray] == [["a", "b", "c"], ["d", "e", "f"]] if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert [batch[:] for batch in reopened.iter_batches()] == [["a", "b", "c"], ["d", "e", "f"]] + assert [batch[:] for batch in reopened] == [["a", "b", "c"], ["d", "e", "f"]] blosc2.remove_urlpath(urlpath) @@ -381,15 +453,15 @@ def test_batchstore_slices(contiguous, urlpath): barray[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] expected[2:5] = [["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]] - assert [batch[:] for batch in barray.iter_batches()] == expected + assert [batch[:] for batch in barray] == expected barray[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] expected[1:6:2] = [[100, 101, 102], [103, 104, 105], [106, 107, 108]] - assert [batch[:] for batch in barray.iter_batches()] == expected + assert [batch[:] for batch in barray] == expected del barray[::3] del expected[::3] - assert [batch[:] for batch in barray.iter_batches()] == expected + assert [batch[:] for batch in barray] == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") @@ -427,14 +499,14 @@ def test_batchstore_copy(): copied = original.copy( urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5} ) - assert [batch[:] for batch in copied.iter_batches()] == [batch[:] for batch in original.iter_batches()] + assert [batch[:] for batch in copied] == [batch[:] for batch in original] assert copied.urlpath == copy_path assert copied.schunk.contiguous is False assert copied.cparams.codec == blosc2.Codec.LZ4 assert copied.cparams.clevel == 5 inmem = original.copy() - assert [batch[:] for batch in inmem.iter_batches()] == [batch[:] for batch in original.iter_batches()] + assert [batch[:] for batch in inmem] == [batch[:] for batch in original] assert inmem.urlpath is None with pytest.raises(ValueError, match="meta should not be passed to copy"): @@ -469,7 +541,7 @@ def test_batchstore_multithreaded_inner_vl(contiguous, nthreads): ) barray.extend(batches) - assert [batch[:] for batch in barray.iter_batches()] == batches + assert [batch[:] for batch in barray] == batches assert [barray[i][:] for i in range(len(barray))] == batches @@ -488,7 +560,7 @@ def test_batchstore_validation_errors(): blosc2.BatchStore().pop() barray.extend([[1, 2, 3]]) assert barray.append([2, 3]) == 2 - assert [batch[:] for batch in barray.iter_batches()] == [[1, 2, 3], [2, 3]] + assert [batch[:] for batch in barray] == [[1, 2, 3], [2, 3]] with pytest.raises(NotImplementedError): barray.pop(slice(0, 1)) @@ -501,7 +573,7 @@ def test_batchstore_in_embed_store(): estore["/batch"] = barray restored = estore["/batch"] assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored.iter_batches()] == BATCHES + assert [batch[:] for batch in restored] == BATCHES def test_batchstore_in_dict_store(): @@ -516,6 +588,6 @@ def test_batchstore_in_dict_store(): with blosc2.DictStore(path, mode="r") as dstore: restored = dstore["/batch"] assert isinstance(restored, blosc2.BatchStore) - assert [batch[:] for batch in restored.iter_batches()] == BATCHES + assert [batch[:] for batch in restored] == BATCHES blosc2.remove_urlpath(path) From 0ab340d592862a02f2e9770e606574baa831779e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 08:38:40 +0100 Subject: [PATCH 45/69] Use metadata-based DictStore discovery and warn on leaf mismatches --- src/blosc2/dict_store.py | 159 ++++++++++++++++++++++++++++++--------- tests/test_dict_store.py | 88 ++++++++++++++++++++++ tests/test_tree_store.py | 39 ++++++++++ 3 files changed, 249 insertions(+), 37 deletions(-) diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 17f50ac6..81b577b4 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -10,6 +10,7 @@ import os import shutil import tempfile +import warnings import zipfile from typing import TYPE_CHECKING, Any @@ -94,6 +95,9 @@ class DictStore: ----- - External persistence uses the following file extensions: .b2nd for NDArray, .b2f for SChunk, and .b2b for BatchStore. + These suffixes are a naming convention for newly written leaves; when + reopening an existing store, leaf typing is resolved from object + metadata instead of trusting the suffix alone. """ def __init__( @@ -112,7 +116,7 @@ def __init__( """ See :class:`DictStore` for full documentation of parameters. """ - self.localpath = localpath if isinstance(localpath, (str, bytes)) else str(localpath) + self.localpath = localpath if isinstance(localpath, str | bytes) else str(localpath) if not self.localpath.endswith((".b2z", ".b2d")): raise ValueError(f"localpath must have a .b2z or .b2d extension; you passed: {self.localpath}") if mode not in ("r", "w", "a"): @@ -182,13 +186,7 @@ def _init_read_mode(self, dparams: blosc2.DParams | None = None): mmap_mode=self.mmap_mode, dparams=dparams, ) - for filepath in self.offsets: - if filepath.endswith((".b2nd", ".b2f", ".b2b")): - if filepath.endswith(".b2nd"): - key = "/" + filepath[:-5] - else: - key = "/" + filepath[:-4] - self.map_tree[key] = filepath + self._update_map_tree_from_offsets() else: # .b2d if not os.path.isdir(self.localpath): raise FileNotFoundError(f"Directory {self.localpath} does not exist for reading.") @@ -204,6 +202,90 @@ def _init_read_mode(self, dparams: blosc2.DParams | None = None): self._estore = EmbedStore(_from_schunk=schunk) self.storage.meta = self._estore.storage.meta + @staticmethod + def _logical_key_from_relpath(rel_path: str) -> str: + """Map an external leaf path to its logical tree key.""" + rel_path = rel_path.replace(os.sep, "/") + key = os.path.splitext(rel_path)[0] + if not key.startswith("/"): + key = "/" + key + return key + + @staticmethod + def _expected_ext_from_kind(kind: str) -> str: + """Return the canonical write-time suffix for a supported external leaf kind.""" + if kind == "ndarray": + return ".b2nd" + if kind == "batchstore": + return ".b2b" + return ".b2f" + + @classmethod + def _opened_external_kind( + cls, + opened: blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore | C2Array | Any, + rel_path: str, + ) -> str | None: + """Return the supported external leaf kind for an already opened object.""" + processed = _process_opened_object(opened) + if isinstance(processed, blosc2.BatchStore): + kind = "batchstore" + elif isinstance(processed, blosc2.VLArray): + kind = "vlarray" + elif isinstance(processed, blosc2.NDArray): + kind = "ndarray" + elif isinstance(processed, SChunk): + kind = "schunk" + else: + warnings.warn( + f"Ignoring unsupported Blosc2 object at '{rel_path}' during DictStore discovery: " + f"{type(processed).__name__}", + UserWarning, + stacklevel=2, + ) + return None + + expected_ext = cls._expected_ext_from_kind(kind) + found_ext = os.path.splitext(rel_path)[1] + if found_ext != expected_ext: + warnings.warn( + f"External leaf '{rel_path}' uses extension '{found_ext}' but metadata resolves to " + f"{type(processed).__name__}; expected '{expected_ext}'.", + UserWarning, + stacklevel=2, + ) + return kind + + def _probe_external_leaf_path(self, rel_path: str) -> bool: + """Return whether a working-dir file is a supported external leaf.""" + urlpath = os.path.join(self.working_dir, rel_path) + try: + opened = blosc2.blosc2_ext.open( + urlpath, + mode="r", + offset=0, + mmap_mode=self.mmap_mode, + dparams=self.dparams, + ) + except Exception: + return False + return self._opened_external_kind(opened, rel_path) is not None + + def _probe_external_leaf_offset(self, filepath: str) -> bool: + """Return whether a zip member is a supported external leaf.""" + offset = self.offsets[filepath]["offset"] + try: + opened = blosc2.blosc2_ext.open( + self.b2z_path, + mode="r", + offset=offset, + mmap_mode=self.mmap_mode, + dparams=self.dparams, + ) + except Exception: + return False + return self._opened_external_kind(opened, filepath) is not None + def _init_write_append_mode( self, cparams: blosc2.CParams | None, @@ -229,24 +311,23 @@ def _init_write_append_mode( self._update_map_tree() def _update_map_tree(self): - # Build map_tree from .b2nd and .b2f files in working dir + # Build map_tree from supported external leaves in working dir. for root, _, files in os.walk(self.working_dir): for file in files: filepath = os.path.join(root, file) - if filepath.endswith((".b2nd", ".b2f", ".b2b")): - # Convert filename to key: remove extension and ensure starts with / - rel_path = os.path.relpath(filepath, self.working_dir) - # Normalize path separators to forward slashes for cross-platform consistency - rel_path = rel_path.replace(os.sep, "/") - if rel_path.endswith(".b2nd"): - key = rel_path[:-5] - elif rel_path.endswith(".b2b") or rel_path.endswith(".b2f"): - key = rel_path[:-4] - else: - continue - if not key.startswith("/"): - key = "/" + key - self.map_tree[key] = rel_path + if os.path.abspath(filepath) == os.path.abspath(self.estore_path): + continue + rel_path = os.path.relpath(filepath, self.working_dir).replace(os.sep, "/") + if self._probe_external_leaf_path(rel_path): + self.map_tree[self._logical_key_from_relpath(rel_path)] = rel_path + + def _update_map_tree_from_offsets(self): + """Build map_tree from supported external leaves in a zip store.""" + for filepath in self.offsets: + if filepath == "embed.b2e": + continue + if self._probe_external_leaf_offset(filepath): + self.map_tree[self._logical_key_from_relpath(filepath)] = filepath @property def estore(self) -> EmbedStore: @@ -255,13 +336,13 @@ def estore(self) -> EmbedStore: @staticmethod def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> int: - if isinstance(value, (blosc2.VLArray, blosc2.BatchStore)): + if isinstance(value, blosc2.VLArray | blosc2.BatchStore): return value.schunk.nbytes return value.nbytes @staticmethod def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchStore) -> bool: - return isinstance(value, (blosc2.NDArray, SChunk, blosc2.VLArray, blosc2.BatchStore)) and bool( + return isinstance(value, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchStore) and bool( getattr(value, "urlpath", None) ) @@ -406,12 +487,14 @@ def values(self) -> Iterator[blosc2.NDArray | SChunk | C2Array]: if self.is_zip_store: if filepath in self.offsets: offset = self.offsets[filepath]["offset"] - yield blosc2.blosc2_ext.open( - self.b2z_path, - mode="r", - offset=offset, - mmap_mode=self.mmap_mode, - dparams=self.dparams, + yield _process_opened_object( + blosc2.blosc2_ext.open( + self.b2z_path, + mode="r", + offset=offset, + mmap_mode=self.mmap_mode, + dparams=self.dparams, + ) ) else: urlpath = os.path.join(self.working_dir, filepath) @@ -438,12 +521,14 @@ def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | C2Array]]: offset = self.offsets[filepath]["offset"] yield ( key, - blosc2.blosc2_ext.open( - self.b2z_path, - mode="r", - offset=offset, - mmap_mode=self.mmap_mode, - dparams=self.dparams, + _process_opened_object( + blosc2.blosc2_ext.open( + self.b2z_path, + mode="r", + offset=offset, + mmap_mode=self.mmap_mode, + dparams=self.dparams, + ) ), ) else: diff --git a/tests/test_dict_store.py b/tests/test_dict_store.py index 74122424..6a157f72 100644 --- a/tests/test_dict_store.py +++ b/tests/test_dict_store.py @@ -16,6 +16,22 @@ from blosc2.dict_store import DictStore +def _rename_store_member(store_path, old_name, new_name): + """Rename an external leaf inside a .b2d/.b2z store without changing its contents.""" + if str(store_path).endswith(".b2d"): + old_path = os.path.join(store_path, old_name.replace("/", os.sep)) + new_path = os.path.join(store_path, new_name.replace("/", os.sep)) + os.rename(old_path, new_path) + return + + tmp_zip = f"{store_path}.tmp" + with zipfile.ZipFile(store_path, "r") as src, zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_STORED) as dst: + for info in src.infolist(): + arcname = new_name if info.filename == old_name else info.filename + dst.writestr(arcname, src.read(info.filename), compress_type=zipfile.ZIP_STORED) + os.replace(tmp_zip, store_path) + + @pytest.fixture(params=["b2d", "b2z"]) def populated_dict_store(request): """Create and populate a DictStore for tests. @@ -266,6 +282,78 @@ def test_external_vlarray_file_and_reopen(tmp_path): assert value.vlmeta["description"] == "External VLArray" +@pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) +def test_metadata_discovery_reopens_renamed_external_ndarray(storage_type, tmp_path): + path = tmp_path / f"test_renamed_ndarray.{storage_type}" + ext_path = tmp_path / "renamed_array_source.b2nd" + + with DictStore(str(path), mode="w", threshold=None) as dstore: + arr_external = blosc2.arange(5, urlpath=str(ext_path), mode="w") + arr_external.vlmeta["description"] = "Renamed NDArray" + dstore["/dir1/node3"] = arr_external + + old_name = "dir1/node3.b2nd" + new_name = "dir1/node3.weird" + _rename_store_member(str(path), old_name, new_name) + + with pytest.warns(UserWarning, match=r"node3\.weird'.*NDArray.*expected '\.b2nd'"): + dstore_read = DictStore(str(path), mode="r") + with dstore_read: + assert dstore_read.map_tree["/dir1/node3"] == new_name + node3 = dstore_read["/dir1/node3"] + assert isinstance(node3, blosc2.NDArray) + assert np.array_equal(node3[:], np.arange(5)) + assert node3.vlmeta["description"] == "Renamed NDArray" + + +@pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) +def test_metadata_discovery_reopens_renamed_external_vlarray(storage_type, tmp_path): + path = tmp_path / f"test_renamed_vlarray.{storage_type}" + ext_path = tmp_path / "renamed_vlarray_source.b2frame" + values = ["alpha", {"nested": True}, None, (1, 2, 3)] + + vlarray = blosc2.VLArray(urlpath=str(ext_path), mode="w", contiguous=True) + vlarray.extend(values) + vlarray.vlmeta["description"] = "Renamed VLArray" + + with DictStore(str(path), mode="w", threshold=None) as dstore: + dstore["/dir1/vlarray_ext"] = vlarray + + old_name = "dir1/vlarray_ext.b2f" + new_name = "dir1/vlarray_ext.renamed" + _rename_store_member(str(path), old_name, new_name) + + with pytest.warns(UserWarning, match=r"vlarray_ext\.renamed'.*VLArray.*expected '\.b2f'"): + dstore_read = DictStore(str(path), mode="r") + with dstore_read: + assert dstore_read.map_tree["/dir1/vlarray_ext"] == new_name + value = dstore_read["/dir1/vlarray_ext"] + assert isinstance(value, blosc2.VLArray) + assert list(value) == values + assert value.vlmeta["description"] == "Renamed VLArray" + + +def test_metadata_discovery_warns_and_skips_unsupported_blosc2_leaf(tmp_path): + path = tmp_path / "test_unsupported_lazyexpr.b2d" + + with DictStore(str(path), mode="w") as dstore: + dstore["/embedded"] = np.arange(3) + + a = blosc2.asarray(np.arange(5), urlpath=str(tmp_path / "a.b2nd"), mode="w") + b = blosc2.asarray(np.arange(5), urlpath=str(tmp_path / "b.b2nd"), mode="w") + expr = a + b + expr_path = path / "unsupported_lazyexpr.b2nd" + expr.save(str(expr_path)) + + with pytest.warns( + UserWarning, match=r"Ignoring unsupported Blosc2 object.*unsupported_lazyexpr\.b2nd.*LazyExpr" + ): + dstore_read = DictStore(str(path), mode="r") + with dstore_read: + assert "/unsupported_lazyexpr" not in dstore_read + assert "/embedded" in dstore_read + + def _digest_value(value): """Return a bytes digest of a stored value.""" if isinstance(value, blosc2.SChunk): diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index 27b86452..780d6cf5 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -7,6 +7,7 @@ import os import shutil +import zipfile import numpy as np import pytest @@ -15,6 +16,22 @@ from blosc2.tree_store import TreeStore +def _rename_store_member(store_path, old_name, new_name): + """Rename an external leaf inside a .b2d/.b2z store without changing its contents.""" + if str(store_path).endswith(".b2d"): + old_path = os.path.join(store_path, old_name.replace("/", os.sep)) + new_path = os.path.join(store_path, new_name.replace("/", os.sep)) + os.rename(old_path, new_path) + return + + tmp_zip = f"{store_path}.tmp" + with zipfile.ZipFile(store_path, "r") as src, zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_STORED) as dst: + for info in src.infolist(): + arcname = new_name if info.filename == old_name else info.filename + dst.writestr(arcname, src.read(info.filename), compress_type=zipfile.ZIP_STORED) + os.replace(tmp_zip, store_path) + + @pytest.fixture(params=["b2d", "b2z"]) def populated_tree_store(request): """A fixture that creates and populates a TreeStore.""" @@ -671,6 +688,28 @@ def test_external_batchstore_support(tmp_path): assert [batch[:] for batch in retrieved] == [[{"id": 1}, {"id": 2}], [{"id": 3}]] +@pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) +def test_metadata_discovery_reopens_renamed_batchstore_leaf(storage_type, tmp_path): + store_path = tmp_path / f"test_batchstore_renamed.{storage_type}" + + with TreeStore(str(store_path), mode="w", threshold=0) as tstore: + bstore = blosc2.BatchStore(max_blocksize=2) + bstore.extend([[{"id": 1}, {"id": 2}], [{"id": 3}]]) + tstore["/data/batchstore"] = bstore + + old_name = "data/batchstore.b2b" + new_name = "data/batchstore.odd" + _rename_store_member(str(store_path), old_name, new_name) + + with pytest.warns(UserWarning, match=r"batchstore\.odd'.*BatchStore.*expected '\.b2b'"): + tstore = TreeStore(str(store_path), mode="r") + with tstore: + assert tstore.map_tree["/data/batchstore"] == new_name + retrieved = tstore["/data/batchstore"] + assert isinstance(retrieved, blosc2.BatchStore) + assert [batch[:] for batch in retrieved] == [[{"id": 1}, {"id": 2}], [{"id": 3}]] + + def test_treestore_vlmeta_externalized_b2d(tmp_path): store_path = tmp_path / "test_vlmeta_externalized.b2d" From 79cd7ab00f85c03ccc8827858575a9431ac3ba5d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 11:36:30 +0100 Subject: [PATCH 46/69] Add a BatchStore.items accessor --- .../tutorials/12.batchstore.ipynb | 189 ++++++++++++++---- examples/batch_store.py | 5 + src/blosc2/batch_store.py | 68 +++++++ src/blosc2/schunk.py | 10 +- tests/test_batch_store.py | 57 ++++++ 5 files changed, 285 insertions(+), 44 deletions(-) diff --git a/doc/getting_started/tutorials/12.batchstore.ipynb b/doc/getting_started/tutorials/12.batchstore.ipynb index b898c455..52bcc660 100644 --- a/doc/getting_started/tutorials/12.batchstore.ipynb +++ b/doc/getting_started/tutorials/12.batchstore.ipynb @@ -18,8 +18,14 @@ "id": "be8591f8f86952e8", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.876287Z", - "start_time": "2026-03-20T07:06:05.661801Z" + "end_time": "2026-03-20T10:24:10.190550Z", + "start_time": "2026-03-20T10:24:10.014859Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.329739Z", + "iopub.status.busy": "2026-03-20T10:23:51.329437Z", + "iopub.status.idle": "2026-03-20T10:23:51.556056Z", + "shell.execute_reply": "2026-03-20T10:23:51.555614Z" } }, "outputs": [], @@ -53,8 +59,14 @@ "id": "f8c8a2b7692e7228", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.904277Z", - "start_time": "2026-03-20T07:06:05.882545Z" + "end_time": "2026-03-20T10:24:10.211954Z", + "start_time": "2026-03-20T10:24:10.191296Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.557338Z", + "iopub.status.busy": "2026-03-20T10:23:51.557245Z", + "iopub.status.idle": "2026-03-20T10:23:51.564920Z", + "shell.execute_reply": "2026-03-20T10:23:51.564578Z" } }, "outputs": [ @@ -62,8 +74,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Batches: [[{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}], [{'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}], [{'name': 'zeta', 'count': 6}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]]\n", - "Number of batches: 4\n" + "Batches: [[{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}], [{'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}], [{'name': 'zeta', 'count': 6}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n", + "Number of batches: 5\n" ] } ], @@ -86,6 +98,11 @@ " [\n", " [{\"name\": \"zeta\", \"count\": 6}],\n", " [{\"name\": \"eta\", \"count\": 7}, {\"name\": \"theta\", \"count\": 8}],\n", + " [\n", + " {\"name\": \"iota\", \"count\": 9},\n", + " {\"name\": \"kappa\", \"count\": 10},\n", + " {\"name\": \"lambda\", \"count\": 11},\n", + " ],\n", " ]\n", ")\n", "\n", @@ -109,8 +126,14 @@ "id": "20861d3e348f9df1", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.924634Z", - "start_time": "2026-03-20T07:06:05.905576Z" + "end_time": "2026-03-20T10:24:10.229980Z", + "start_time": "2026-03-20T10:24:10.213198Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.566000Z", + "iopub.status.busy": "2026-03-20T10:23:51.565919Z", + "iopub.status.idle": "2026-03-20T10:23:51.569765Z", + "shell.execute_reply": "2026-03-20T10:23:51.569439Z" } }, "outputs": [ @@ -121,7 +144,7 @@ "First batch: [{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}]\n", "Second item in first batch: {'name': 'beta', 'count': 2}\n", "Slice of second batch: [{'name': 'delta', 'count': 4}]\n", - "All items: [{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}, {'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}, {'name': 'zeta', 'count': 6}, {'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]\n" + "All items: [{'name': 'alpha', 'count': 1}, {'name': 'beta', 'count': 2}, {'name': 'gamma', 'count': 3}, {'name': 'delta', 'count': 4}, {'name': 'epsilon', 'count': 5}, {'name': 'zeta', 'count': 6}, {'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}, {'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]\n" ] } ], @@ -148,8 +171,14 @@ "id": "df556f6da8adc369", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.945986Z", - "start_time": "2026-03-20T07:06:05.925866Z" + "end_time": "2026-03-20T10:24:10.259055Z", + "start_time": "2026-03-20T10:24:10.231589Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.570823Z", + "iopub.status.busy": "2026-03-20T10:23:51.570763Z", + "iopub.status.idle": "2026-03-20T10:23:51.577607Z", + "shell.execute_reply": "2026-03-20T10:23:51.577269Z" } }, "outputs": [ @@ -157,8 +186,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Popped batch: [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]\n", - "After updates: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n" + "Popped batch: [{'name': 'zeta', 'count': 6}]\n", + "After updates: [[{'name': 'alpha*', 'count': 10}, {'name': 'beta*', 'count': 20}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n" ] } ], @@ -167,9 +196,10 @@ " {\"name\": \"delta*\", \"count\": 40},\n", " {\"name\": \"epsilon*\", \"count\": 50},\n", "]\n", - "store.insert(1, [{\"name\": \"between\", \"count\": 99}])\n", - "removed = store.pop()\n", + "store.insert(2, [{\"name\": \"between-a\", \"count\": 99}, {\"name\": \"between-b\", \"count\": 100}])\n", + "removed = store.pop(3)\n", "del store[0]\n", + "store.insert(0, [{\"name\": \"alpha*\", \"count\": 10}, {\"name\": \"beta*\", \"count\": 20}])\n", "\n", "show(\"Popped batch\", removed)\n", "show(\"After updates\", [batch[:] for batch in store])" @@ -191,8 +221,14 @@ "id": "b32d72a68d83673e", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.965086Z", - "start_time": "2026-03-20T07:06:05.947144Z" + "end_time": "2026-03-20T10:24:10.300526Z", + "start_time": "2026-03-20T10:24:10.259712Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.578504Z", + "iopub.status.busy": "2026-03-20T10:23:51.578433Z", + "iopub.status.idle": "2026-03-20T10:23:51.581563Z", + "shell.execute_reply": "2026-03-20T10:23:51.581191Z" } }, "outputs": [ @@ -200,21 +236,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "Batches via iteration: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "Batches via iteration: [[{'name': 'alpha*', 'count': 10}, {'name': 'beta*', 'count': 20}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n", "type : BatchStore\n", "serializer : msgpack\n", - "nbatches : 3 (items per batch: mean=1.33, max=2, min=1)\n", - "nblocks : 3 (items per block: mean=1.33, max=2, min=1)\n", - "nitems : 4\n", - "nbytes : 84 (84 B)\n", - "cbytes : 468 (468 B)\n", - "cratio : 0.18\n", + "nbatches : 5 (items per batch: mean=2.20, max=3, min=2)\n", + "nblocks : 6 (items per block: mean=1.83, max=2, min=1)\n", + "nitems : 11\n", + "nbytes : 226 (226 B)\n", + "cbytes : 680 (680 B)\n", + "cratio : 0.33\n", "cparams : CParams(codec=, codec_meta=0, clevel=5, use_dict=False, typesize=1,\n", - " : nthreads=8, blocksize=0, splitmode=,\n", + " : nthreads=12, blocksize=0, splitmode=,\n", " : filters=[, , ,\n", " : , , ], filters_meta=[0,\n", " : 0, 0, 0, 0, 0], tuner=)\n", - "dparams : DParams(nthreads=8)\n", + "dparams : DParams(nthreads=12)\n", "\n" ] } @@ -240,8 +276,14 @@ "id": "45f878b8f4414a3b", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:05.990783Z", - "start_time": "2026-03-20T07:06:05.965791Z" + "end_time": "2026-03-20T10:24:10.334099Z", + "start_time": "2026-03-20T10:24:10.301619Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.582437Z", + "iopub.status.busy": "2026-03-20T10:23:51.582372Z", + "iopub.status.idle": "2026-03-20T10:23:51.590494Z", + "shell.execute_reply": "2026-03-20T10:23:51.590186Z" } }, "outputs": [ @@ -249,7 +291,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Copied batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "Copied batches: [[{'name': 'alpha*', 'count': 10}, {'name': 'beta*', 'count': 20}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n", "Copy serializer: msgpack\n", "Copy codec: Codec.LZ4\n" ] @@ -283,8 +325,14 @@ "id": "fd4957093f509bd4", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:06.025738Z", - "start_time": "2026-03-20T07:06:05.999799Z" + "end_time": "2026-03-20T10:24:10.359063Z", + "start_time": "2026-03-20T10:24:10.343012Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.591475Z", + "iopub.status.busy": "2026-03-20T10:23:51.591415Z", + "iopub.status.idle": "2026-03-20T10:23:51.594839Z", + "shell.execute_reply": "2026-03-20T10:23:51.594553Z" } }, "outputs": [ @@ -293,9 +341,9 @@ "output_type": "stream", "text": [ "from_cframe type: BatchStore\n", - "from_cframe batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n", + "from_cframe batches: [[{'name': 'alpha*', 'count': 10}, {'name': 'beta*', 'count': 20}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n", "Reopened type: BatchStore\n", - "Reopened batches: [[{'name': 'between', 'count': 99}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'zeta', 'count': 6}]]\n" + "Reopened batches: [[{'name': 'alpha*', 'count': 10}, {'name': 'beta*', 'count': 20}], [{'name': 'delta*', 'count': 40}, {'name': 'epsilon*', 'count': 50}], [{'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}], [{'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}], [{'name': 'iota', 'count': 9}, {'name': 'kappa', 'count': 10}, {'name': 'lambda', 'count': 11}]]\n" ] } ], @@ -326,8 +374,14 @@ "id": "2214b2be1bfb5bc7", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:06.050975Z", - "start_time": "2026-03-20T07:06:06.034152Z" + "end_time": "2026-03-20T10:24:10.386442Z", + "start_time": "2026-03-20T10:24:10.365740Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.595854Z", + "iopub.status.busy": "2026-03-20T10:23:51.595778Z", + "iopub.status.idle": "2026-03-20T10:23:51.601478Z", + "shell.execute_reply": "2026-03-20T10:23:51.601232Z" } }, "outputs": [ @@ -348,24 +402,73 @@ " [{\"name\": \"again\", \"count\": 2}, {\"name\": \"done\", \"count\": 3}],\n", " ]\n", ")\n", - "show(\"After clear + extend\", [batch[:] for batch in scratch])\n", + "show(\"After clear + extend\", [batch[:] for batch in scratch])" + ] + }, + { + "cell_type": "markdown", + "id": "8d8f9df58a46c4c1", + "metadata": {}, + "source": [ + "## Flat item access with `.items`\n", "\n", - "blosc2.remove_urlpath(urlpath)\n", - "blosc2.remove_urlpath(copy_path)" + "The main `BatchStore` API remains batch-oriented, but the `.items` accessor offers a read-only flat view across all items. Integer indexing returns one item and slicing returns a Python list." ] }, { "cell_type": "code", - "execution_count": 8, - "id": "27c47e4fd1332b48", + "execution_count": 9, + "id": "4f5c4e5a1b8f92d4", + "metadata": { + "ExecuteTime": { + "end_time": "2026-03-20T10:24:10.403443Z", + "start_time": "2026-03-20T10:24:10.387808Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.602502Z", + "iopub.status.busy": "2026-03-20T10:23:51.602451Z", + "iopub.status.idle": "2026-03-20T10:23:51.606267Z", + "shell.execute_reply": "2026-03-20T10:23:51.605893Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Flat item 0: {'name': 'alpha*', 'count': 10}\n", + "Flat item 6: {'name': 'eta', 'count': 7}\n", + "Flat slice 3:8: [{'name': 'epsilon*', 'count': 50}, {'name': 'between-a', 'count': 99}, {'name': 'between-b', 'count': 100}, {'name': 'eta', 'count': 7}, {'name': 'theta', 'count': 8}]\n" + ] + } + ], + "source": [ + "show(\"Flat item 0\", store.items[0])\n", + "show(\"Flat item 6\", store.items[6])\n", + "show(\"Flat slice 3:8\", store.items[3:8])" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "2a355a3fc8673692", "metadata": { "ExecuteTime": { - "end_time": "2026-03-20T07:06:06.061727Z", - "start_time": "2026-03-20T07:06:06.051400Z" + "end_time": "2026-03-20T10:24:10.420064Z", + "start_time": "2026-03-20T10:24:10.403926Z" + }, + "execution": { + "iopub.execute_input": "2026-03-20T10:23:51.607247Z", + "iopub.status.busy": "2026-03-20T10:23:51.607185Z", + "iopub.status.idle": "2026-03-20T10:23:51.608877Z", + "shell.execute_reply": "2026-03-20T10:23:51.608598Z" } }, "outputs": [], - "source": [] + "source": [ + "blosc2.remove_urlpath(urlpath)\n", + "blosc2.remove_urlpath(copy_path)" + ] } ], "metadata": { diff --git a/examples/batch_store.py b/examples/batch_store.py index 4a387af5..9a809fec 100644 --- a/examples/batch_store.py +++ b/examples/batch_store.py @@ -61,6 +61,11 @@ def main() -> None: value = reopened[batch_index][item_index] print(f" reopened[{batch_index}][{item_index}] -> {value}") + print() + print("Flat item reads via .items:") + print(f" reopened.items[0] -> {reopened.items[0]}") + print(f" reopened.items[150:153] -> {reopened.items[150:153]}") + print(f"BatchStore file at: {reopened.urlpath}") diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index f0736b3e..cf90ba78 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -15,6 +15,8 @@ from functools import lru_cache from typing import Any +import numpy as np + import blosc2 from blosc2._msgpack_utils import msgpack_packb, msgpack_unpackb from blosc2.info import InfoReporter, format_nbytes_info @@ -124,6 +126,19 @@ def __repr__(self) -> str: return f"Batch(len={len(self)}, nbytes={self.nbytes}, cbytes={self.cbytes})" +class BatchStoreItems(Sequence[Any]): + """A read-only flat view over the items stored in a :class:`BatchStore`.""" + + def __init__(self, parent: BatchStore) -> None: + self._parent = parent + + def __getitem__(self, index: int | slice) -> Any | list[Any]: + return self._parent._get_flat_item(index) + + def __len__(self) -> int: + return self._parent._get_total_item_count() + + class BatchStore: """A batched container for variable-length Python items. @@ -214,6 +229,8 @@ def _attach_schunk(self, schunk: blosc2.SChunk) -> None: self._arrow_schema = batchstore_meta.get("arrow_schema", self._arrow_schema) self._arrow_schema_obj = None self._batch_lengths = self._load_batch_lengths() + self._items = BatchStoreItems(self) + self._item_prefix_sums: np.ndarray | None = None self._validate_tag() def _maybe_open_existing(self, storage: blosc2.Storage) -> bool: @@ -363,11 +380,49 @@ def _ensure_batch_lengths(self) -> list[int]: self._batch_lengths = [] return self._batch_lengths + def _load_or_compute_batch_lengths(self) -> list[int]: + if self._batch_lengths is None: + self._batch_lengths = [len(self._get_batch(i)) for i in range(len(self))] + if self.mode != "r": + self._persist_batch_lengths() + return self._batch_lengths + def _batch_length(self, index: int) -> int | None: if self._batch_lengths is None: return None return self._batch_lengths[index] + def _invalidate_item_cache(self) -> None: + self._item_prefix_sums = None + + def _get_item_prefix_sums(self) -> np.ndarray: + if self._item_prefix_sums is None: + batch_lengths = np.asarray(self._load_or_compute_batch_lengths(), dtype=np.int64) + prefix_sums = np.empty(len(batch_lengths) + 1, dtype=np.int64) + prefix_sums[0] = 0 + prefix_sums[1:] = np.cumsum(batch_lengths, dtype=np.int64) + self._item_prefix_sums = prefix_sums + return self._item_prefix_sums + + def _get_total_item_count(self) -> int: + return int(self._get_item_prefix_sums()[-1]) + + def _get_flat_item(self, index: int | slice) -> Any | list[Any]: + if isinstance(index, slice): + return [self._get_flat_item(i) for i in range(*index.indices(self._get_total_item_count()))] + if not isinstance(index, int): + raise TypeError("BatchStore item indices must be integers") + nitems = self._get_total_item_count() + if index < 0: + index += nitems + if index < 0 or index >= nitems: + raise IndexError("BatchStore item index out of range") + + prefix_sums = self._get_item_prefix_sums() + batch_index = int(np.searchsorted(prefix_sums, index, side="right") - 1) + item_index = int(index - prefix_sums[batch_index]) + return self[batch_index][item_index] + def _block_sizes_from_batch_length(self, batch_length: int, nblocks: int) -> list[int]: if self._max_blocksize is None or nblocks <= 0: return [] @@ -600,6 +655,7 @@ def append(self, value: object) -> int: new_len = self.schunk.append_chunk(batch_payload) self._ensure_batch_lengths().append(length) self._persist_batch_lengths() + self._invalidate_item_cache() return new_len def insert(self, index: int, value: object) -> int: @@ -612,6 +668,7 @@ def insert(self, index: int, value: object) -> int: new_len = self.schunk.insert_chunk(index, batch_payload) self._ensure_batch_lengths().insert(index, length) self._persist_batch_lengths() + self._invalidate_item_cache() return new_len def delete(self, index: int | slice) -> int: @@ -623,12 +680,14 @@ def delete(self, index: int | slice) -> int: if self._batch_lengths is not None: del self._batch_lengths[idx] self._persist_batch_lengths() + self._invalidate_item_cache() return len(self) index = self._normalize_index(index) new_len = self.schunk.delete_chunk(index) if self._batch_lengths is not None: del self._batch_lengths[index] self._persist_batch_lengths() + self._invalidate_item_cache() return new_len def pop(self, index: int = -1) -> list[Any]: @@ -650,6 +709,7 @@ def extend(self, values: object) -> None: self.schunk.append_chunk(batch_payload) self._ensure_batch_lengths().append(self._batch_len(batch)) self._persist_batch_lengths() + self._invalidate_item_cache() def clear(self) -> None: """Remove all entries from the container.""" @@ -667,6 +727,7 @@ def clear(self) -> None: self._attach_schunk(schunk) self._batch_lengths = [] self._persist_batch_lengths() + self._invalidate_item_cache() def __getitem__(self, index: int | slice) -> Batch | list[Batch]: """Return one batch or a list of batches.""" @@ -693,6 +754,7 @@ def __setitem__(self, index: int | slice, value: object) -> None: self.schunk.insert_chunk(start + offset, batch_payload) self._ensure_batch_lengths().insert(start + offset, self._batch_len(batch)) self._persist_batch_lengths() + self._invalidate_item_cache() return if len(values) != len(indices): raise ValueError( @@ -705,6 +767,7 @@ def __setitem__(self, index: int | slice, value: object) -> None: if self._batch_lengths is not None: self._batch_lengths[idx] = self._batch_len(batch) self._persist_batch_lengths() + self._invalidate_item_cache() return self._check_writable() index = self._normalize_index(index) @@ -714,6 +777,7 @@ def __setitem__(self, index: int | slice, value: object) -> None: if self._batch_lengths is not None: self._batch_lengths[index] = self._batch_len(batch) self._persist_batch_lengths() + self._invalidate_item_cache() def __delitem__(self, index: int | slice) -> None: self.delete(index) @@ -751,6 +815,10 @@ def dparams(self): def max_blocksize(self) -> int | None: return self._max_blocksize + @property + def items(self) -> BatchStoreItems: + return self._items + @property def typesize(self) -> int: return self.schunk.typesize diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 55b4acdf..5da599ca 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -676,7 +676,15 @@ def get_chunk(self, nchunk: int) -> bytes: def get_vlblock(self, nchunk: int, nblock: int) -> bytes: """Return the decompressed payload of one VL block from a chunk.""" - return super().get_vlblock(nchunk, nblock) + get_vlblock = getattr(super(), "get_vlblock", None) + if get_vlblock is not None: + return get_vlblock(nchunk, nblock) + + block_payloads = blosc2_ext.vldecompress(self.get_chunk(nchunk), **asdict(self.dparams)) + try: + return block_payloads[nblock] + except IndexError as exc: + raise IndexError("VL block index out of range") from exc def delete_chunk(self, nchunk: int) -> int: """Delete the specified chunk from the SChunk. diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index c9f520ed..a567f625 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -486,6 +486,63 @@ def test_batchstore_slice_errors(): _ = barray[::0] +@pytest.mark.parametrize( + ("contiguous", "urlpath"), + [ + (False, None), + (True, None), + (True, "test_batchstore_items.b2b"), + (False, "test_batchstore_items_s.b2b"), + ], +) +def test_batchstore_items_accessor(contiguous, urlpath): + blosc2.remove_urlpath(urlpath) + + batches = [["a", "b"], [10, 11, 12], [{"x": 1}], [None, True]] + flat = [item for batch in batches for item in batch] + barray = blosc2.BatchStore(storage=_storage(contiguous, urlpath), max_blocksize=2) + barray.extend(batches) + + assert len(barray.items) == len(flat) + assert barray.items[0] == flat[0] + assert barray.items[3] == flat[3] + assert barray.items[-1] == flat[-1] + assert barray.items[1:6] == flat[1:6] + assert barray.items[::-2] == flat[::-2] + + barray.append(["tail0", "tail1"]) + flat.extend(["tail0", "tail1"]) + assert len(barray.items) == len(flat) + assert barray.items[-2:] == flat[-2:] + + barray.insert(1, ["mid0", "mid1"]) + flat[2:2] = ["mid0", "mid1"] + assert barray.items[:] == flat + + barray[2] = ["replaced"] + batch_start = len(batches[0]) + 2 + flat[batch_start : batch_start + 3] = ["replaced"] + assert barray.items[:] == flat + + del barray[0] + del flat[:2] + assert barray.items[:] == flat + + with pytest.raises(IndexError, match="item index out of range"): + _ = barray.items[len(flat)] + with pytest.raises(TypeError, match="item indices must be integers"): + _ = barray.items[1.5] + with pytest.raises(ValueError): + _ = barray.items[::0] + + if urlpath is not None: + reopened = blosc2.open(urlpath, mode="r") + assert reopened.items[:] == flat + assert reopened.items[2] == flat[2] + + blosc2.remove_urlpath(urlpath) + + def test_batchstore_copy(): urlpath = "test_batchstore_copy.b2b" copy_path = "test_batchstore_copy_out.b2b" From c6cccdd35f0080003a995e5513e9bfd6a7e41e38 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 11:46:11 +0100 Subject: [PATCH 47/69] Undo an unneceesary workaround --- src/blosc2/schunk.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 5da599ca..55b4acdf 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -676,15 +676,7 @@ def get_chunk(self, nchunk: int) -> bytes: def get_vlblock(self, nchunk: int, nblock: int) -> bytes: """Return the decompressed payload of one VL block from a chunk.""" - get_vlblock = getattr(super(), "get_vlblock", None) - if get_vlblock is not None: - return get_vlblock(nchunk, nblock) - - block_payloads = blosc2_ext.vldecompress(self.get_chunk(nchunk), **asdict(self.dparams)) - try: - return block_payloads[nblock] - except IndexError as exc: - raise IndexError("VL block index out of range") from exc + return super().get_vlblock(nchunk, nblock) def delete_chunk(self, nchunk: int) -> int: """Delete the specified chunk from the SChunk. From 20a958ca7fbf9bdc3d34147f82d694a46dd7f3c9 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 12:05:25 +0100 Subject: [PATCH 48/69] Start using L2 cache size for clevel==5 --- src/blosc2/batch_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index cf90ba78..cd71684f 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -568,9 +568,9 @@ def _guess_blocksize(self, payload_sizes: list[int]) -> int: clevel = self.cparams.clevel if clevel == 9: return len(payload_sizes) - if 0 < clevel < 6: + if 0 < clevel < 5: budget = blosc2.cpu_info.get("l1_data_cache_size") - elif 6 <= clevel < 9: + elif 5 <= clevel < 9: budget = blosc2.cpu_info.get("l2_cache_size") else: return len(payload_sizes) From c8c16327b29ec38b2360bf9642ced9c5b701945c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 13:35:27 +0100 Subject: [PATCH 49/69] Allow use a filename in .b2z as a single argument --- src/blosc2/dict_store.py | 15 ++++++--- tests/test_dict_store.py | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 81b577b4..6fe9e7ee 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -85,7 +85,7 @@ class DictStore: >>> schunk.append_data(b"abcd") 4 >>> dstore["/dir1/schunk1"] = schunk # externalized as .b2f if above threshold - >>> dstore.to_b2z() # persist to the zip file; external files are copied in + >>> dstore.to_b2z(filename="my_dstore.b2z") # persist to the zip file; external files are copied in >>> print(sorted(dstore.keys())) ['/dir1/node3', '/dir1/schunk1', '/node1', '/node2'] >>> print(dstore["/node1"][:])) @@ -555,14 +555,19 @@ def to_b2z(self, overwrite=False, filename=None) -> os.PathLike[Any] | str: If True, overwrite the existing b2z file if it exists. Default is False. filename : str, optional If provided, use this filename instead of the default b2z file path. + Keyword use is recommended for clarity. Returns ------- filename : str The absolute path to the created b2z file. """ - if self.mode == "r": - raise ValueError("Cannot call to_b2z() on a DictStore opened in read mode.") + if isinstance(overwrite, str | os.PathLike) and filename is None: + filename = overwrite + overwrite = False + + if self.mode == "r" and self.is_zip_store: + raise ValueError("Cannot call to_b2z() on a .b2z DictStore opened in read mode.") b2z_path = self.b2z_path if filename is None else filename if not b2z_path.endswith(".b2z"): @@ -582,7 +587,7 @@ def to_b2z(self, overwrite=False, filename=None) -> os.PathLike[Any] | str: # Sort filepaths by file size from largest to smallest filepaths.sort(key=os.path.getsize, reverse=True) - with zipfile.ZipFile(self.b2z_path, "w", zipfile.ZIP_STORED) as zf: + with zipfile.ZipFile(b2z_path, "w", zipfile.ZIP_STORED) as zf: # Write all files (except estore_path) first (sorted by size) for filepath in filepaths: arcname = os.path.relpath(filepath, self.working_dir) @@ -591,7 +596,7 @@ def to_b2z(self, overwrite=False, filename=None) -> os.PathLike[Any] | str: if os.path.exists(self.estore_path): arcname = os.path.relpath(self.estore_path, self.working_dir) zf.write(self.estore_path, arcname) - return os.path.abspath(self.b2z_path) + return os.path.abspath(b2z_path) def _get_zip_offsets(self) -> dict[str, dict[str, int]]: """Get offset and length of all files in the zip archive.""" diff --git a/tests/test_dict_store.py b/tests/test_dict_store.py index 6a157f72..337ace30 100644 --- a/tests/test_dict_store.py +++ b/tests/test_dict_store.py @@ -114,6 +114,74 @@ def test_to_b2z_and_reopen(populated_dict_store): assert np.all(dstore_read["/nodeB"][:] == np.arange(6)) +def test_to_b2z_from_readonly_b2d(): + b2d_path = "test_to_b2z_from_readonly.b2d" + b2z_path = "test_to_b2z_from_readonly.b2z" + + if os.path.exists(b2d_path): + shutil.rmtree(b2d_path) + if os.path.exists(b2z_path): + os.remove(b2z_path) + + with DictStore(b2d_path, mode="w") as dstore: + dstore["/nodeA"] = np.arange(5) + dstore["/nodeB"] = np.arange(6) + + with DictStore(b2d_path, mode="r") as dstore: + packed = dstore.to_b2z(filename=b2z_path) + assert packed.endswith(b2z_path) + + with DictStore(b2z_path, mode="r") as dstore: + assert np.all(dstore["/nodeA"][:] == np.arange(5)) + assert np.all(dstore["/nodeB"][:] == np.arange(6)) + + shutil.rmtree(b2d_path) + os.remove(b2z_path) + + +def test_to_b2z_accepts_positional_filename(): + b2d_path = "test_to_b2z_positional_filename.b2d" + b2z_path = "test_to_b2z_positional_filename.b2z" + + if os.path.exists(b2d_path): + shutil.rmtree(b2d_path) + if os.path.exists(b2z_path): + os.remove(b2z_path) + + with DictStore(b2d_path, mode="w") as dstore: + dstore["/nodeA"] = np.arange(5) + + with DictStore(b2d_path, mode="r") as dstore: + packed = dstore.to_b2z(b2z_path) + assert packed.endswith(b2z_path) + + with DictStore(b2z_path, mode="r") as dstore: + assert np.all(dstore["/nodeA"][:] == np.arange(5)) + + shutil.rmtree(b2d_path) + os.remove(b2z_path) + + +def test_to_b2z_from_readonly_b2z_raises(): + b2z_path = "test_to_b2z_readonly_zip.b2z" + out_path = "test_to_b2z_readonly_zip_out.b2z" + + for path in (b2z_path, out_path): + if os.path.exists(path): + os.remove(path) + + with DictStore(b2z_path, mode="w") as dstore: + dstore["/nodeA"] = np.arange(5) + + with ( + DictStore(b2z_path, mode="r") as dstore, + pytest.raises(ValueError, match=r"\.b2z DictStore opened in read mode"), + ): + dstore.to_b2z(filename=out_path) + + os.remove(b2z_path) + + def test_map_tree_precedence(populated_dict_store): dstore, path = populated_dict_store # Create external file and add to dstore From 5a1cd0fa31c0f34717c40ea15941c23b999fd846 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 14:43:55 +0100 Subject: [PATCH 50/69] Adapt test to new blocksize thresholds --- tests/test_batch_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index a567f625..b5299a0c 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -328,11 +328,11 @@ def test_batchstore_respects_explicit_use_dict_and_non_zstd(): assert barray.cparams.use_dict is False -def test_batchstore_guess_max_blocksize_uses_l1_for_low_clevel(monkeypatch): +def test_batchstore_guess_max_blocksize_uses_l2_for_clevel_5(monkeypatch): monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 1000) barray = blosc2.BatchStore(cparams={"clevel": 5}) - assert barray._guess_blocksize([30, 30, 30, 30]) == 3 + assert barray._guess_blocksize([30, 30, 30, 30]) == 4 def test_batchstore_guess_max_blocksize_uses_l2_for_mid_clevel(monkeypatch): From 62dc7172e3e45bada0dfe2cc9372992181af04fd Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 18:28:24 +0100 Subject: [PATCH 51/69] Fix BatchStore metadata preservation paths Preserve user vlmeta when BatchStore recreates its empty backing SChunk during initial layout inference, avoid persisting empty batch_lengths metadata that breaks vlmeta.getall() on empty stores, and keep user meta/vlmeta when copy(storage=...) is used. Add BatchStore regression tests covering: - vlmeta preservation during inferred layout initialization - clear()/delete-last on empty stores - metadata preservation on copy(storage=...) --- src/blosc2/batch_store.py | 24 ++++++++++--- tests/test_batch_store.py | 72 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/blosc2/batch_store.py b/src/blosc2/batch_store.py index cd71684f..984f043e 100644 --- a/src/blosc2/batch_store.py +++ b/src/blosc2/batch_store.py @@ -370,6 +370,10 @@ def _load_batch_lengths(self) -> list[int] | None: def _persist_batch_lengths(self) -> None: if self._batch_lengths is None: return + if len(self._batch_lengths) == 0: + if _BATCHSTORE_VLMETA_KEY in self.vlmeta: + del self.vlmeta[_BATCHSTORE_VLMETA_KEY] + return self.schunk.vlmeta[_BATCHSTORE_VLMETA_KEY] = {"batch_lengths": list(self._batch_lengths)} def _get_batch_lengths(self) -> list[int] | None: @@ -542,6 +546,7 @@ def _persist_layout_metadata(self) -> None: if len(self) > 0: return batch_lengths = None if self._batch_lengths is None else list(self._batch_lengths) + user_vlmeta = self._user_vlmeta_items() if len(self.vlmeta) > 0 else {} storage = self._make_storage() fixed_meta = dict(storage.meta or {}) fixed_meta["batchstore"] = { @@ -559,6 +564,8 @@ def _persist_layout_metadata(self) -> None: storage=storage, ) self._attach_schunk(schunk) + for key, value in user_vlmeta.items(): + self.vlmeta[key] = value if batch_lengths is not None and self._batch_lengths is None: self._batch_lengths = batch_lengths @@ -894,17 +901,24 @@ def copy(self, **kwargs: Any) -> BatchStore: kwargs["dparams"] = kwargs.get("dparams", copy.deepcopy(self.dparams)) kwargs["max_blocksize"] = kwargs.get("max_blocksize", self.max_blocksize) kwargs["serializer"] = kwargs.get("serializer", self.serializer) - - if "storage" not in kwargs: + user_vlmeta = self._user_vlmeta_items() if len(self.vlmeta) > 0 else {} + + if "storage" in kwargs: + storage = self._coerce_storage(kwargs["storage"], {}) + fixed_meta = self._copy_meta() + if storage.meta is not None: + fixed_meta.update(storage.meta) + storage.meta = fixed_meta + kwargs["storage"] = storage + else: kwargs["meta"] = self._copy_meta() kwargs["contiguous"] = kwargs.get("contiguous", self.schunk.contiguous) if "urlpath" in kwargs and "mode" not in kwargs: kwargs["mode"] = "w" out = BatchStore(**kwargs) - if "storage" not in kwargs and len(self.vlmeta) > 0: - for key, value in self._user_vlmeta_items().items(): - out.vlmeta[key] = value + for key, value in user_vlmeta.items(): + out.vlmeta[key] = value out.extend(self) return out diff --git a/tests/test_batch_store.py b/tests/test_batch_store.py index b5299a0c..9ae83de5 100644 --- a/tests/test_batch_store.py +++ b/tests/test_batch_store.py @@ -143,6 +143,26 @@ def test_batchstore_arrow_ipc_roundtrip(): blosc2.remove_urlpath(urlpath) +def test_batchstore_inferred_layout_preserves_user_vlmeta(): + barray = blosc2.BatchStore() + barray.vlmeta["user"] = {"x": 1} + + barray.append([1, 2, 3]) + + assert barray.vlmeta["user"] == {"x": 1} + + +def test_batchstore_arrow_layout_persistence_preserves_user_vlmeta(): + pa = pytest.importorskip("pyarrow") + + barray = blosc2.BatchStore(serializer="arrow") + barray.vlmeta["user"] = {"x": 1} + + barray.append(pa.array([[1], [2, 3]])) + + assert barray.vlmeta["user"] == {"x": 1} + + def test_batchstore_from_cframe(): barray = blosc2.BatchStore() barray.extend(BATCHES) @@ -233,6 +253,38 @@ def test_batchstore_pop_keeps_batch_lengths_metadata_in_sync(): assert items["nbatches"].startswith("2 (items per batch: mean=2.00") +def test_batchstore_clear_keeps_empty_store_vlmeta_readable(): + urlpath = "test_batchstore_clear_empty_vlmeta.b2b" + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True) + barray.append([1, 2, 3]) + barray.clear() + + assert barray.vlmeta.getall() == {} + + reopened = blosc2.open(urlpath, mode="r") + assert reopened.vlmeta.getall() == {} + + blosc2.remove_urlpath(urlpath) + + +def test_batchstore_delete_last_keeps_empty_store_vlmeta_readable(): + urlpath = "test_batchstore_delete_last_empty_vlmeta.b2b" + blosc2.remove_urlpath(urlpath) + + barray = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True) + barray.append([1, 2, 3]) + barray.delete(0) + + assert barray.vlmeta.getall() == {} + + reopened = blosc2.open(urlpath, mode="r") + assert reopened.vlmeta.getall() == {} + + blosc2.remove_urlpath(urlpath) + + def test_batchstore_zstd_does_not_use_dict_by_default(): barray = blosc2.BatchStore() assert barray.cparams.codec == blosc2.Codec.ZSTD @@ -573,6 +625,26 @@ def test_batchstore_copy(): blosc2.remove_urlpath(copy_path) +def test_batchstore_copy_with_storage_preserves_user_metadata(): + urlpath = "test_batchstore_copy_storage.b2b" + copy_path = "test_batchstore_copy_storage_out.b2b" + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + original = blosc2.BatchStore(urlpath=urlpath, mode="w", contiguous=True, meta={"user_meta": {"a": 1}}) + original.vlmeta["user_vlmeta"] = {"b": 2} + original.extend(BATCHES) + + copied = original.copy(storage=blosc2.Storage(contiguous=False, urlpath=copy_path, mode="w")) + + assert [batch[:] for batch in copied] == [batch[:] for batch in original] + assert copied.meta["user_meta"] == {"a": 1} + assert copied.vlmeta["user_vlmeta"] == {"b": 2} + + blosc2.remove_urlpath(urlpath) + blosc2.remove_urlpath(copy_path) + + @pytest.mark.parametrize(("contiguous", "nthreads"), [(False, 2), (True, 4)]) def test_batchstore_multithreaded_inner_vl(contiguous, nthreads): batches = [] From b379a2c2ae643e329945916e3926956f89d8531e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 18:31:53 +0100 Subject: [PATCH 52/69] Update to latest c-blosc2 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ed060c9..a1a23c22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG c0f5416f55662fccad861aa0387e965f73f644b4 # variable-length chunks/blocks + GIT_TAG 7cec94ba9d4243ff7d7eb397ef669ec5dd501711 # variable-length chunks/blocks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 3f30f7e997b2ba75a91ce15671223e63a4be21c4 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Fri, 20 Mar 2026 18:55:17 +0100 Subject: [PATCH 53/69] Update to latest c-blosc2 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b748c794..94f91139 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 25197eb96d05318c939b3252a6b373ccd6ae49fe # variable-length chunks support in schunks + GIT_TAG 8a1af55c510fac5f5f20a8b8d0853f4dfaf9e438 # variable-length chunks support in schunks # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) From 637ce8dfc9402a39f4a22bc5bac1f63af1bf3a87 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Sun, 22 Mar 2026 15:22:36 +0100 Subject: [PATCH 54/69] Extended allowable cases --- src/blosc2/blosc2_ext.pyx | 47 ++++++++++++++++++++++----------------- src/blosc2/linalg.py | 35 ++++++++++++++--------------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 322f4d2b..4b2b9f1e 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2182,12 +2182,13 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int blocknitems[2] cdef int startA, startB, expected_blocknitems cdef blosc2_context* dctx - cdef int i, j, block_i, block_j, ncols, block_ncols, Bblock_ncols, Bncols + cdef int i, j, block_i, block_j, chunk_i, chunk_j, ncols, block_ncols, Bblock_ncols, Bncols, Ablock_ncols, Ancols cdef int nchunkA = 0, nchunkB = 0, nblockA = 0, nblockB = 0, offsetA = 0, offsetB = 0, offset = 0 out_arr = udata.array cdef int ndim = out_arr.ndim cdef int nchunk_ = nchunk cdef int coord, batch, batch_, batches = 1 + cdef int out_chunk_nrows, out_chunk_ncols, out_block_nrows, out_block_ncols # batches = sum(strides[i]*elcoords[i]) for i in range(ndim - 2): @@ -2201,12 +2202,10 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param nchunkB += coord * udata.chunks_strides[2][i] ncols = udata.chunks_strides[0][ndim - 2] + Ancols = udata.chunks_strides[1][ndim - 2] Bncols = udata.chunks_strides[2][ndim - 2] - - i = nchunk_ // ncols # ncols * i + j - j = nchunk_ % ncols - chunk_startA = nchunkA + i * ncols - chunk_startB = nchunkB + j + out_chunk_nrows = out_arr.chunkshape[ndim - 2] + out_chunk_ncols = out_arr.chunkshape[ndim - 1] # nblock = sum(strides[i]*blockcoords[i]) cdef int nblock_ = nblock @@ -2217,18 +2216,14 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param nblockB += coord * udata.blocks_strides[2][i] block_ncols = udata.blocks_strides[0][ndim - 2] + Ablock_ncols = udata.blocks_strides[1][ndim - 2] Bblock_ncols = udata.blocks_strides[2][ndim - 2] - - block_i = nblock_ // block_ncols - block_j = nblock_ % block_ncols - block_startA = nblockA + block_i * block_ncols - block_startB = nblockB + block_j + out_block_nrows = out_arr.blockshape[ndim - 2] + out_block_ncols = out_arr.blockshape[ndim - 1] dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True - nchunkA = chunk_startA - nchunkB = chunk_startB while True: # chunk loop for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB @@ -2244,16 +2239,28 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param if i == 0: q = ndarr.blockshape[ndim - 1] p = ndarr.blockshape[ndim - 2] + # nchunk_ = chunks_in_row * chunk_row + chunk_col + # convert from chunk_idx to element idx chunk_i (row) + chunk_i = nchunk_ // ncols * out_chunk_nrows + chunk_startA = nchunkA + chunk_i // ndarr.chunkshape[ndim - 2] * Ancols + nchunkA = chunk_startA + # nblock_ = blocks_in_chunkrow * block_row + block_col + # convert from block_idx to element idx block_i (row) + block_i = nblock_ // block_ncols * out_block_nrows + block_startA = nblockA + block_i // p * Ablock_ncols else: # i = 1 r = ndarr.blockshape[ndim - 1] + # convert from chunk_idx to element idx chunk_j (col) + chunk_j = nchunk_ % ncols * out_chunk_ncols + chunk_startB = nchunkB + chunk_j // ndarr.chunkshape[ndim - 1] + nchunkB = chunk_startB + # convert from block_idx to element idx block_j (col) + block_j = nblock_ % block_ncols * out_block_ncols + block_startB = nblockB + block_j // r input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize - if i == 0: - expected_blocknitems = blocknitems[i] - elif blocknitems[i] != expected_blocknitems: - raise ValueError("miniexpr: inconsistent block element counts across inputs") first_run = False nblockA = block_startA @@ -2297,11 +2304,11 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param batch += 1 nblockA += 1 nblockB += Bblock_ncols - if (nblockA % block_ncols == 0): + if (nblockA % Ablock_ncols == 0): break nchunkA += 1 nchunkB += Bncols - if (nchunkA % ncols == 0): + if (nchunkA % Ancols == 0): break @@ -3280,7 +3287,7 @@ cdef class NDArray: cstrides = bstrides = estrides = 1 for idx in range(2, self.array.ndim + 1): i = inp.ndim - idx - if inp.shape[i + 1] == 1 or i < 0: + if (inp.shape[i + 1] == 1 and i < inp.ndim - 3) or i < 0: udata.chunks_strides[j][i] = 0 udata.blocks_strides[j][i] = 0 udata.el_strides[j][i] = 0 diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 36437bfa..58b81333 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -125,14 +125,13 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False + # TODO: We can relax this to even just load according to result blockshape, but that's difficult. # Just force same chunk/block shapes - same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) - same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) - same_shape = all(op.shape == result.shape for op in (x1, x2)) - - use_miniexpr &= same_blocks & same_chunks & same_shape + # same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) + # same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) + # same_shape = all(op.shape == result.shape for op in (x1, x2)) - # TODO: We can relax this to even just load according to result blockshape, but that's difficult. + # use_miniexpr &= same_blocks & same_chunks & same_shape # Two easier cases are presented below # Case 1: Might want to restrict loading across chunk boundaries, in which case would require: # x1.chunks[-2] % result.blocks[-2] == 0 @@ -146,18 +145,18 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra # (M, K) x (K, N) = (M, N) # so can load block-by-block for inputs and calculate block of output # Also need to avoid loading across chunk boundaries - # chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 - # chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 - # chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 - # same_blocks = x2.blocks[-2] == x1.blocks[-1] - # same_blocks &= x2.blocks[-1] == result.blocks[-1] - # same_blocks &= result.blocks[-2] == x1.blocks[-2] - # try: - # result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) - # if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): - # use_miniexpr = False - # except ValueError: - # use_miniexpr = False + chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + same_blocks = x2.blocks[-2] == x1.blocks[-1] + same_blocks &= x2.blocks[-1] == result.blocks[-1] + same_blocks &= result.blocks[-2] == x1.blocks[-2] + try: + result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): + use_miniexpr = False + except ValueError: + use_miniexpr = False use_miniexpr &= x1.dtype.kind in ("i", "f") use_miniexpr &= x2.dtype.kind in ("i", "f") From 470fe8a0623f576e325b033c1ee343bc0d18434d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 06:16:36 +0100 Subject: [PATCH 55/69] Use latest c-blosc2 sources to fix contiguous-frame b2nd resize growth --- CMakeLists.txt | 2 +- bench/ndarray/resize.py | 131 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 bench/ndarray/resize.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 94f91139..6a105a1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,7 +119,7 @@ else() include(FetchContent) FetchContent_Declare(blosc2 GIT_REPOSITORY https://github.com/Blosc/c-blosc2 - GIT_TAG 8a1af55c510fac5f5f20a8b8d0853f4dfaf9e438 # variable-length chunks support in schunks + GIT_TAG 9200990b189c8357e5517860cfa9ef09cb117eae # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../c-blosc2 ) FetchContent_MakeAvailable(blosc2) diff --git a/bench/ndarray/resize.py b/bench/ndarray/resize.py new file mode 100644 index 00000000..24525d80 --- /dev/null +++ b/bench/ndarray/resize.py @@ -0,0 +1,131 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import argparse +import os +import time + +import numpy as np + +import blosc2 + + +def parse_nitems(text: str) -> int: + suffixes = {"k": 1_000, "m": 1_000_000, "g": 1_000_000_000} + text = text.strip().lower() + if text[-1:] in suffixes: + return int(float(text[:-1]) * suffixes[text[-1]]) + return int(text) + + +def sizeof_path(path: str) -> int: + if os.path.isdir(path): + total = 0 + for root, _, files in os.walk(path): + for name in files: + total += os.path.getsize(os.path.join(root, name)) + return total + return os.path.getsize(path) + + +def format_bytes(nbytes: int) -> str: + units = ["B", "KiB", "MiB", "GiB", "TiB"] + value = float(nbytes) + for unit in units: + if value < 1024 or unit == units[-1]: + return f"{value:.2f} {unit}" + value /= 1024 + return f"{nbytes} B" + + +def pick_layout(nitems: int) -> tuple[tuple[int], tuple[int]]: + chunks = (max(1, min(nitems, 16_384)),) + blocks = (max(1, min(chunks[0], 256)),) + return chunks, blocks + + +def create_extended_array( + path: str, nitems: int, dtype: np.dtype, chunks: tuple[int], blocks: tuple[int], bsize: int +) -> blosc2.NDArray: + array = blosc2.empty((0,), dtype=dtype, chunks=chunks, blocks=blocks, urlpath=path, mode="w") + for start in range(0, nitems, bsize): + stop = min(start + bsize, nitems) + array.resize((stop,)) + array[start:stop] = np.arange(start, stop, dtype=dtype) + return array + + +def create_full_array(path: str, data: np.ndarray, chunks: tuple[int], blocks: tuple[int]) -> blosc2.NDArray: + return blosc2.asarray(data, chunks=chunks, blocks=blocks, urlpath=path, mode="w") + + +def time_random_access(array: blosc2.NDArray, indices: np.ndarray) -> tuple[float, int]: + total = 0 + t0 = time.perf_counter_ns() + for index in indices: + total += int(array[int(index)]) + elapsed_ns = time.perf_counter_ns() - t0 + return elapsed_ns / len(indices) / 1_000_000, total + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compare resizing an on-disk NDArray in batches vs creating it in one go." + ) + parser.add_argument("--nitems", type=parse_nitems, default=parse_nitems("1M")) + parser.add_argument("--bsize", type=parse_nitems, default=parse_nitems("1K")) + parser.add_argument("--samples", type=int, default=10_000) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--dtype", default="int64") + parser.add_argument("--extended-path", default="resize-batched.b2nd") + parser.add_argument("--full-path", default="resize-one-go.b2nd") + args = parser.parse_args() + + dtype = np.dtype(args.dtype) + chunks, blocks = pick_layout(args.nitems) + data = np.arange(args.nitems, dtype=dtype) + rng = np.random.default_rng(args.seed) + indices = rng.integers(0, args.nitems, size=args.samples) + + for path in (args.extended_path, args.full_path): + blosc2.remove_urlpath(path) + + t0 = time.perf_counter() + extended = create_extended_array(args.extended_path, args.nitems, dtype, chunks, blocks, args.bsize) + extend_time = time.perf_counter() - t0 + + t0 = time.perf_counter() + full = create_full_array(args.full_path, data, chunks, blocks) + full_time = time.perf_counter() - t0 + + extended_size = sizeof_path(args.extended_path) + full_size = sizeof_path(args.full_path) + + extended_access_ns, extended_checksum = time_random_access(extended, indices) + full_access_ns, full_checksum = time_random_access(full, indices) + + print(f"nitems: {args.nitems:_}") + print(f"dtype: {dtype}") + print(f"chunks: {chunks}") + print(f"blocks: {blocks}") + print(f"batch size: {args.bsize:_}") + print(f"resize build time: {extend_time:.3f} s") + print(f"one-go build time: {full_time:.3f} s") + print(f"resized array file size: {extended_size} bytes ({format_bytes(extended_size)})") + print(f"one-go array file size: {full_size} bytes ({format_bytes(full_size)})") + print(f"random access samples: {args.samples:_}") + print(f"resized array random access: {extended_access_ns:.6f} ms/item") + print(f"one-go array random access: {full_access_ns:.6f} ms/item") + + if extended_checksum != full_checksum: + raise RuntimeError("Random-access checksums differ between arrays") + + +if __name__ == "__main__": + main() From 99cb321a58f6196bd0e97e6e1470420e4dc73955 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Mon, 2 Mar 2026 15:04:00 +0100 Subject: [PATCH 56/69] Getting ready for release 4.1.1 # Conflicts: # ANNOUNCE.rst # RELEASE_NOTES.md # pyproject.toml # src/blosc2/version.py --- ANNOUNCE.rst | 4 ++-- RELEASE_NOTES.md | 4 ++-- pyproject.toml | 8 +------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/ANNOUNCE.rst b/ANNOUNCE.rst index f1551f82..69d06955 100644 --- a/ANNOUNCE.rst +++ b/ANNOUNCE.rst @@ -1,7 +1,7 @@ -Announcing Python-Blosc2 4.1.2 +Announcing Python-Blosc2 4.0.0 =============================== -This is patch release which updates the ``c-blosc2`` version to fix some memory leaks. +This is patch release which updates the ``miniexpr`` version to fix a bug for ubuntu ARM64 failure. You can think of Python-Blosc2 4.x as an extension of NumPy/numexpr that: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 84602cf0..74c71229 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ # Release notes - ## Changes from 4.1.2 to 4.1.3 +## Changes from 4.1.0 to 4.1.1 - XXX version-specific blurb XXX +XXX version-specific blurb XXX ## Changes from 4.1.1 to 4.1.2 diff --git a/pyproject.toml b/pyproject.toml index c25612d5..6244b0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,13 +39,7 @@ dependencies = [ "numexpr>=2.14.1; platform_machine != 'wasm32'", "requests", ] -version = "4.1.3.dev0" - -[project.optional-dependencies] -recommended = [ - "pyarrow", -] - +version = "4.1.1.dev0" [project.entry-points."array_api"] blosc2 = "blosc2" From a1274ba6b7591817e56099b4ef1a4b020bf3ba02 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 5 Mar 2026 18:20:11 +0100 Subject: [PATCH 57/69] Extension compiles with multithreaded matmul --- src/blosc2/blosc2_ext.pyx | 159 +++++++++++++++++++++++++++++++++++++- src/blosc2/linalg.py | 84 ++++++++++++++------ 2 files changed, 216 insertions(+), 27 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 7b6791fa..594a1b9d 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -43,7 +43,6 @@ cimport numpy as np np.import_array() - cdef extern from "": ctypedef signed char int8_t ctypedef signed short int16_t @@ -54,6 +53,12 @@ cdef extern from "": ctypedef unsigned int uint32_t ctypedef unsigned long long uint64_t +ctypedef fused T: + float + double + int32_t + int64_t + cdef extern from "": int printf(const char *format, ...) nogil @@ -2204,7 +2209,6 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, cdef b2nd_array_t* ndarr cdef int rc cdef void** input_buffers = malloc(udata.ninputs * sizeof(uint8_t*)) - cdef float *buf cdef uint8_t* src cdef uint8_t* chunk cdef c_bool needs_free @@ -2330,6 +2334,124 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, return 0 +cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: + cdef int r, c, k + cdef T a + cdef int rowA, rowC, rowB + for r in range(M): + rowA = r * K + rowC = r * N + for k in range(K): + a = A[rowA + k] + rowB = k * N + for c in range(N): + C[rowC + c] += (a * B[rowB + c]) + +cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: + # Declare all C variables at the beginning + cdef b2nd_array_t* out_arr + cdef b2nd_array_t* ndarr + cdef int rc + cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) + cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) + cdef int32_t chunk_nbytes[2] + cdef int32_t chunk_cbytes[2] + cdef int32_t block_nbytes[2] + cdef int blocknitems[2] + cdef int startA, startB, expected_blocknitems + cdef blosc2_context* dctx + cdef int base, i, j, nchunkA, nchunkB, nblockA, nblockB, chunk_startA, chunk_startB, block_base, block_i, block_j, block_startA, block_startB, idx, chunk_idx, block_ncols, block_nrows, nblocks_per_2d + + out_arr = udata.array + cdef int ndim = out_arr.ndim + cdef int ncols = udata.chunks_in_array[ndim - 1] + cdef int nrows = udata.chunks_in_array[ndim - 2] + cdef int nchunks_per_2d = ncols * nrows + + block_ncols = udata.blocks_in_chunk[ndim - 1] + block_nrows = udata.blocks_in_chunk[ndim - 2] + nblocks_per_2d = block_ncols * block_nrows + + # nchunk = base * nchunks_per2d + i * ncols + j + base = nchunk // nchunks_per_2d + i = (nchunk % nchunks_per_2d) // ncols + j = nchunk % ncols + nchunkA = chunk_startA = nchunk - j + nchunkB = chunk_startB = nchunk - i * ncols + + # nblock = block_base * nblocks_per_2d + block_i * ncols + block_j + block_base = nblock // nblocks_per_2d + block_i = (nblock % nblocks_per_2d) // block_ncols + block_j = nblock % block_ncols + block_startA = nblock - j + block_startB = nblock - i * block_ncols + dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) + + cdef void* bufA = input_buffers[0] + cdef void* bufB = input_buffers[1] + + while True: # chunk loop + nblockA = block_startA + nblockB = block_startB + for i in range(2): + chunk_idx = nchunkA if i == 0 else nchunkB + ndarr = udata.inputs[i] + src[i] = ndarr.sc.data[chunk_idx] + rc = blosc2_cbuffer_sizes(src[i], &chunk_nbytes[i], &chunk_cbytes[i], &block_nbytes[i]) + if rc < 0: + raise ValueError("miniexpr: error getting cbuffer sizes") + if block_nbytes[i] <= 0: + raise ValueError("miniexpr: invalid block size") + input_buffers[i] = malloc(block_nbytes[i]) + if input_buffers[i] == NULL: + raise MemoryError("miniexpr: cannot allocate input block buffer") + blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize + if i == 0: + expected_blocknitems = blocknitems[i] + elif blocknitems[i] != expected_blocknitems: + raise ValueError("miniexpr: inconsistent block element counts across inputs") + while True: # block loop + startA = nblockA * blocknitems[0] + startB = nblockB * blocknitems[1] + rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], + input_buffers[0], block_nbytes[0]) + if rc < 0: + raise ValueError("matmul: error decompressing the A chunk") + rc = blosc2_getitem_ctx(dctx, src[1], chunk_cbytes[1], startB, blocknitems[1], + input_buffers[1], block_nbytes[1]) + if rc < 0: + raise ValueError("matmul: error decompressing the B chunk") + if typecode == 0: + if typesize == 4: + rc = matmul_block_kernel[float](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + rc = matmul_block_kernel[double](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + elif typecode == 1: + if typesize == 4: + rc = matmul_block_kernel[int32_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + rc =matmul_block_kernel[int64_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + else: + with gil: + raise ValueError("Unsupported dtype") + nblockA += 1 + nblockB += ncols + if (nblockA % block_ncols != 0): + break + nchunkA += 1 + nchunkB += ncols + if (nchunkA % ncols != 0): + break + + blosc2_free_ctx(dctx) + # Free resources + for i in range(2): + free(input_buffers[i]) + free(src[i]) + free(input_buffers) + free(src) + + return 0 # Aux function for prefilter and postfilter udf cdef int aux_udf(udf_udata *udata, int64_t nchunk, int32_t nblock, @@ -2408,6 +2530,20 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): return aux_miniexpr( params.user_data, params.nchunk, params.nblock, False, params.output, params.output_typesize) +cdef int matmul_prefilter(blosc2_prefilter_params *params): + cdef int typecode + cdef b2nd_array_t* out_arr + + cdef me_udata* udata = params.user_data + out_arr = udata.array + cdef np.dtype out_type = np.dtype(out_arr.dtype) + if out_type.kind == 'f': + typecode = 0 + elif out_type.kind == 'f': + typecode = 1 + else: + raise ValueError("Unsupported dtype") + return aux_matmul(udata, params.nchunk, params.nblock, params.output, params.output_typesize, typecode) cdef int general_udf_prefilter(blosc2_prefilter_params *params): cdef udf_udata *udata = params.user_data @@ -3345,6 +3481,25 @@ cdef class NDArray: if self.array.sc.cctx == NULL: raise RuntimeError("Could not create compression context") + def _set_pref_matmul(self, inputs, fp_accuracy): + # Set prefilter for miniexpr + cdef blosc2_cparams* cparams = self.array.sc.storage.cparams + cparams.prefilter = matmul_prefilter + + cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, False) + cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) + preparams.user_data = udata + preparams.output_is_disposable = False + cparams.preparams = preparams + _check_cparams(cparams) + + if self.array.sc.cctx != NULL: + # Freeing NULL context can lead to segmentation fault + blosc2_free_ctx(self.array.sc.cctx) + self.array.sc.cctx = blosc2_create_cctx(dereference(cparams)) + if self.array.sc.cctx == NULL: + raise RuntimeError("Could not create compression context") + def _set_pref_udf(self, func, inputs_id): if self.array.sc.storage.cparams.nthreads > 1: raise AttributeError("compress `nthreads` must be 1 when assigning a prefilter") diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index b1bda5e7..57889a32 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -23,7 +23,7 @@ from collections.abc import Sequence -def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: +def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: # noqa: C901 """ Computes the matrix product between two Blosc2 NDArrays. @@ -112,30 +112,64 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra kwargs["_chunksize_reduc_factor"] = 1 result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) - if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s - p, q = result.chunks[-2:] - r = x2.chunks[-1] - - intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) - for chunk in intersecting_chunks: - chunk = chunk.raw - for row in range(0, n, p): - row_end = builtins.min(row + p, n) - for col in range(0, m, q): - col_end = builtins.min(col + q, m) - for aux in range(0, k, r): - aux_end = builtins.min(aux + r, k) - bx1 = ( - x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] - if x1.ndim > 2 - else x1[row:row_end, aux:aux_end] - ) - bx2 = ( - x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] - if x2.ndim > 2 - else x2[aux:aux_end, col:col_end] - ) - result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) + # multithreaded matmul + # TODO: handle a) type promotion, b) non-square blocks, c) and >2D + ops = (x1, x2, result) + shape, chunks, blocks = result.shape, result.chunks, result.blocks + all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) + use_miniexpr = True + if all_ndarray: + # can maybe relax this to just have A.blocks[-1] == B.blocks[-2] + # Require aligned NDArray operands with identical chunk/block grid, and square matrices/chunks/blocks + same_shape = all(op.shape[-1] == op.shape[-2] and op.shape == shape for op in ops) + same_chunks = all(op.shape[-1] == op.shape[-2] and op.chunks == chunks for op in ops) + same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) + if not (same_shape and same_chunks and same_blocks): + use_miniexpr = False + if any(op.dtype != ops[0].dtype for op in ops): + use_miniexpr = False + else: + use_miniexpr = False + + if use_miniexpr: + prefilter_set = False + try: + result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) + prefilter_set = True + # Data to compress is fetched from operands, so it can be uninitialized here + data = np.empty(result.schunk.chunksize, dtype=np.uint8) + for nchunk_out in range(result.schunk.nchunks): + result.schunk.update_data(nchunk_out, data, copy=False) + except Exception as e: + raise Exception from e + finally: + if prefilter_set: + result.schunk.remove_prefilter("miniexpr") + else: # couldn't do multithreading + if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + p, q = result.chunks[-2:] + r = x2.chunks[-1] + + intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) + for chunk in intersecting_chunks: + chunk = chunk.raw + for row in range(0, n, p): + row_end = builtins.min(row + p, n) + for col in range(0, m, q): + col_end = builtins.min(col + q, m) + for aux in range(0, k, r): + aux_end = builtins.min(aux + r, k) + bx1 = ( + x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] + if x1.ndim > 2 + else x1[row:row_end, aux:aux_end] + ) + bx2 = ( + x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] + if x2.ndim > 2 + else x2[aux:aux_end, col:col_end] + ) + result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) if x1_is_vector: result = result.squeeze(axis=-2) From 6945bab188ffc3c7a918b6d4736c6e599875523a Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Fri, 6 Mar 2026 13:52:59 +0100 Subject: [PATCH 58/69] Fix indexing bug and segmentation faults --- src/blosc2/blosc2_ext.pyx | 56 ++++++++++++++++++++++++--------------- src/blosc2/linalg.py | 2 +- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 594a1b9d..b476933a 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -1001,7 +1001,7 @@ cdef _check_cparams(blosc2_cparams *cparams): if ufilters[i] and cparams.filters[i] in blosc2.ufilters_registry.keys(): raise ValueError("Cannot use multi-threading with user defined Python filters") - if cparams.prefilter != NULL and cparams.prefilter != miniexpr_prefilter: + if cparams.prefilter != NULL and (cparams.prefilter != miniexpr_prefilter and cparams.prefilter != matmul_prefilter): # Note: miniexpr_prefilter uses miniexpr C API which is thread-friendly, raise ValueError("`nthreads` must be 1 when a prefilter is set") @@ -2346,12 +2346,14 @@ cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: rowB = k * N for c in range(N): C[rowC + c] += (a * B[rowB + c]) + return 0 cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: # Declare all C variables at the beginning cdef b2nd_array_t* out_arr cdef b2nd_array_t* ndarr - cdef int rc + cdef c_bool first_run + cdef int rc, M, K, N cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) cdef int32_t chunk_nbytes[2] @@ -2379,18 +2381,18 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param nchunkA = chunk_startA = nchunk - j nchunkB = chunk_startB = nchunk - i * ncols - # nblock = block_base * nblocks_per_2d + block_i * ncols + block_j + # nblock = block_base * nblocks_per_2d + block_i * block_ncols + block_j block_base = nblock // nblocks_per_2d block_i = (nblock % nblocks_per_2d) // block_ncols block_j = nblock % block_ncols - block_startA = nblock - j - block_startB = nblock - i * block_ncols + block_startA = nblock - block_j + block_startB = nblock - block_i * block_ncols dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) - cdef void* bufA = input_buffers[0] - cdef void* bufB = input_buffers[1] + first_run = True while True: # chunk loop + printf("chunks: %i, %i\n", nchunkA, nchunkB) nblockA = block_startA nblockB = block_startB for i in range(2): @@ -2402,7 +2404,13 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: error getting cbuffer sizes") if block_nbytes[i] <= 0: raise ValueError("miniexpr: invalid block size") - input_buffers[i] = malloc(block_nbytes[i]) + if first_run: + if i == 0: + K = ndarr.blockshape[ndim - 1] + M = ndarr.blockshape[ndim - 2] + else: # i = 1 + N = ndarr.blockshape[ndim - 1] + input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize @@ -2410,7 +2418,10 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param expected_blocknitems = blocknitems[i] elif blocknitems[i] != expected_blocknitems: raise ValueError("miniexpr: inconsistent block element counts across inputs") + + first_run = False while True: # block loop + printf("blocks: %i, %i\n", nblockA, nblockB) startA = nblockA * blocknitems[0] startB = nblockB * blocknitems[1] rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], @@ -2423,31 +2434,32 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("matmul: error decompressing the B chunk") if typecode == 0: if typesize == 4: - rc = matmul_block_kernel[float](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[float](input_buffers[0], input_buffers[1], params_output, M, K, N) else: - rc = matmul_block_kernel[double](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[double](input_buffers[0], input_buffers[1], params_output, M, K, N) elif typecode == 1: if typesize == 4: - rc = matmul_block_kernel[int32_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[int32_t](input_buffers[0], input_buffers[1], params_output, M, K, N) else: - rc =matmul_block_kernel[int64_t](bufA, bufB, params_output, block_nrows, block_ncols, block_ncols) + rc = matmul_block_kernel[int64_t](input_buffers[0], input_buffers[1], params_output, M, K, N) else: with gil: raise ValueError("Unsupported dtype") nblockA += 1 - nblockB += ncols - if (nblockA % block_ncols != 0): + nblockB += block_ncols + if (nblockA % block_ncols == 0): break nchunkA += 1 nchunkB += ncols - if (nchunkA % ncols != 0): + if (nchunkA % ncols == 0): break + printf("finished block %i for chunk %i\n", nblock, nchunk) + blosc2_free_ctx(dctx) # Free resources for i in range(2): free(input_buffers[i]) - free(src[i]) free(input_buffers) free(src) @@ -2532,14 +2544,13 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): cdef int matmul_prefilter(blosc2_prefilter_params *params): cdef int typecode - cdef b2nd_array_t* out_arr cdef me_udata* udata = params.user_data - out_arr = udata.array - cdef np.dtype out_type = np.dtype(out_arr.dtype) - if out_type.kind == 'f': + cdef b2nd_array_t* out_arr = udata.array + cdef char dtype_kind = out_arr.dtype[1] + if dtype_kind == 'f': typecode = 0 - elif out_type.kind == 'f': + elif dtype_kind == 'i': typecode = 1 else: raise ValueError("Unsupported dtype") @@ -3486,7 +3497,8 @@ cdef class NDArray: cdef blosc2_cparams* cparams = self.array.sc.storage.cparams cparams.prefilter = matmul_prefilter - cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, False) + cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, aux_reduc=None) + cdef b2nd_array_t* out_arr = udata.array cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) preparams.user_data = udata preparams.output_is_disposable = False diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 57889a32..3b258ea7 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -126,7 +126,7 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) if not (same_shape and same_chunks and same_blocks): use_miniexpr = False - if any(op.dtype != ops[0].dtype for op in ops): + if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False else: use_miniexpr = False From 4dae4da879ea60359786e3cee844d925611b09a5 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Mon, 16 Mar 2026 22:59:00 +0100 Subject: [PATCH 59/69] Adding ND support --- src/blosc2/blosc2_ext.pyx | 171 +++++++++++++++++++++++++++----------- src/blosc2/linalg.py | 37 +++++++-- 2 files changed, 150 insertions(+), 58 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index b476933a..b5b09561 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -688,6 +688,13 @@ ctypedef struct me_udata: int64_t blocks_in_chunk[B2ND_MAX_DIM] me_expr* miniexpr_handle +ctypedef struct mm_udata: + b2nd_array_t** inputs + b2nd_array_t* array + int64_t chunks_strides[3][B2ND_MAX_DIM] + int64_t blocks_strides[3][B2ND_MAX_DIM] + int64_t el_strides[3][B2ND_MAX_DIM] + MAX_TYPESIZE = BLOSC2_MAXTYPESIZE MAX_BUFFERSIZE = BLOSC2_MAX_BUFFERSIZE MAX_BLOCKSIZE = BLOSC2_MAXBLOCKSIZE @@ -2348,12 +2355,12 @@ cdef int matmul_block_kernel(T* A, T* B, T* C, int M, int K, int N) nogil: C[rowC + c] += (a * B[rowB + c]) return 0 -cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: +cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *params_output, int32_t typesize, int typecode) nogil: # Declare all C variables at the beginning cdef b2nd_array_t* out_arr cdef b2nd_array_t* ndarr cdef c_bool first_run - cdef int rc, M, K, N + cdef int rc, p, q, r cdef void** input_buffers = malloc(2 * sizeof(uint8_t*)) cdef uint8_t** src = malloc(2 * sizeof(uint8_t*)) cdef int32_t chunk_nbytes[2] @@ -2362,42 +2369,56 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int blocknitems[2] cdef int startA, startB, expected_blocknitems cdef blosc2_context* dctx - cdef int base, i, j, nchunkA, nchunkB, nblockA, nblockB, chunk_startA, chunk_startB, block_base, block_i, block_j, block_startA, block_startB, idx, chunk_idx, block_ncols, block_nrows, nblocks_per_2d - + cdef int i, j, block_i, block_j, ncols, block_ncols, Bblock_ncols, Bncols + cdef int nchunkA = 0, nchunkB = 0, nblockA = 0, nblockB = 0, offsetA = 0, offsetB = 0, offset = 0 out_arr = udata.array cdef int ndim = out_arr.ndim - cdef int ncols = udata.chunks_in_array[ndim - 1] - cdef int nrows = udata.chunks_in_array[ndim - 2] - cdef int nchunks_per_2d = ncols * nrows - - block_ncols = udata.blocks_in_chunk[ndim - 1] - block_nrows = udata.blocks_in_chunk[ndim - 2] - nblocks_per_2d = block_ncols * block_nrows - - # nchunk = base * nchunks_per2d + i * ncols + j - base = nchunk // nchunks_per_2d - i = (nchunk % nchunks_per_2d) // ncols - j = nchunk % ncols - nchunkA = chunk_startA = nchunk - j - nchunkB = chunk_startB = nchunk - i * ncols - - # nblock = block_base * nblocks_per_2d + block_i * block_ncols + block_j - block_base = nblock // nblocks_per_2d - block_i = (nblock % nblocks_per_2d) // block_ncols - block_j = nblock % block_ncols - block_startA = nblock - block_j - block_startB = nblock - block_i * block_ncols + cdef int nchunk_ = nchunk + cdef int coord, batch, batch_, batches = 1 + for i in range(ndim - 2): + batches *= out_arr.shape[i] + + # nchunk = sum(strides[i]*chunkcoords[i]) + for i in range(ndim - 2): + coord = nchunk_ // udata.chunks_strides[0][i] + nchunk_ = nchunk_ % udata.chunks_strides[0][i] + nchunkA += coord * udata.chunks_strides[1][i] + nchunkB += coord * udata.chunks_strides[2][i] + + ncols = udata.chunks_strides[0][ndim - 2] + Bncols = udata.chunks_strides[2][ndim - 2] + + i = nchunk_ // ncols # ncols * i + j + j = nchunk_ % ncols + nchunkA = chunk_startA = nchunkA + i * ncols + nchunkB = chunk_startB = nchunkB + j + + # nblock = sum(strides[i]*blockcoords[i]) + cdef int nblock_ = nblock + for i in range(ndim - 2): + coord = nblock_ // udata.blocks_strides[0][i] + nblock_ = nblock_ % udata.blocks_strides[0][i] + nblockA += coord * udata.blocks_strides[1][i] + nblockB += coord * udata.blocks_strides[2][i] + + block_ncols = udata.blocks_strides[0][ndim - 2] + Bblock_ncols = udata.blocks_strides[2][ndim - 2] + + block_i = nblock_ // block_ncols + block_j = nblock_ % block_ncols + block_startA = nblockA = nblockA + i * block_ncols + block_startB = nblockB = nblockB + j + + # batches = sum(strides[i]*elcoords[i]) dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True while True: # chunk loop - printf("chunks: %i, %i\n", nchunkA, nchunkB) - nblockA = block_startA - nblockB = block_startB for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB ndarr = udata.inputs[i] + ndim = ndarr.ndim src[i] = ndarr.sc.data[chunk_idx] rc = blosc2_cbuffer_sizes(src[i], &chunk_nbytes[i], &chunk_cbytes[i], &block_nbytes[i]) if rc < 0: @@ -2406,10 +2427,10 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: invalid block size") if first_run: if i == 0: - K = ndarr.blockshape[ndim - 1] - M = ndarr.blockshape[ndim - 2] + q = ndarr.blockshape[ndim - 1] + p = ndarr.blockshape[ndim - 2] else: # i = 1 - N = ndarr.blockshape[ndim - 1] + r = ndarr.blockshape[ndim - 1] input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") @@ -2420,8 +2441,9 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param raise ValueError("miniexpr: inconsistent block element counts across inputs") first_run = False + nblockA = block_startA + nblockB = block_startB while True: # block loop - printf("blocks: %i, %i\n", nblockA, nblockB) startA = nblockA * blocknitems[0] startB = nblockB * blocknitems[1] rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], @@ -2432,28 +2454,37 @@ cdef int aux_matmul(me_udata *udata, int64_t nchunk, int32_t nblock, void *param input_buffers[1], block_nbytes[1]) if rc < 0: raise ValueError("matmul: error decompressing the B chunk") - if typecode == 0: - if typesize == 4: - rc = matmul_block_kernel[float](input_buffers[0], input_buffers[1], params_output, M, K, N) - else: - rc = matmul_block_kernel[double](input_buffers[0], input_buffers[1], params_output, M, K, N) - elif typecode == 1: - if typesize == 4: - rc = matmul_block_kernel[int32_t](input_buffers[0], input_buffers[1], params_output, M, K, N) + batch = 0 + while batch < batches: + batch_ = batch + for i in range(ndim - 2): + coord = batch // udata.el_strides[0][i] + batch_ = batch_ % udata.el_strides[0][i] + offsetA += coord * udata.el_strides[1][i] + offsetB += coord * udata.el_strides[2][i] + offset += coord * udata.el_strides[0][i] + if typecode == 0: + if typesize == 4: + rc = matmul_block_kernel[float](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + else: + rc = matmul_block_kernel[double](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + elif typecode == 1: + if typesize == 4: + rc = matmul_block_kernel[int32_t](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) + else: + rc = matmul_block_kernel[int64_t](input_buffers[0] + offsetA, input_buffers[1] + offsetB, params_output + offset, p, q, r) else: - rc = matmul_block_kernel[int64_t](input_buffers[0], input_buffers[1], params_output, M, K, N) - else: - with gil: - raise ValueError("Unsupported dtype") + with gil: + raise ValueError("Unsupported dtype") + batch += 1 nblockA += 1 - nblockB += block_ncols + nblockB += Bblock_ncols if (nblockA % block_ncols == 0): break nchunkA += 1 - nchunkB += ncols + nchunkB += Bncols if (nchunkA % ncols == 0): break - printf("finished block %i for chunk %i\n", nblock, nchunk) blosc2_free_ctx(dctx) @@ -2545,7 +2576,7 @@ cdef int miniexpr_prefilter(blosc2_prefilter_params *params): cdef int matmul_prefilter(blosc2_prefilter_params *params): cdef int typecode - cdef me_udata* udata = params.user_data + cdef mm_udata* udata = params.user_data cdef b2nd_array_t* out_arr = udata.array cdef char dtype_kind = out_arr.dtype[1] if dtype_kind == 'f': @@ -3407,6 +3438,48 @@ cdef class NDArray: return udata + cdef mm_udata *_fill_mm_udata(self, inputs): + cdef mm_udata *udata = malloc(sizeof(mm_udata)) + cdef int cstrides, bstrides, estrides + cdef b2nd_array_t* inp + cdef b2nd_array_t** inputs_ = malloc(2 * sizeof(b2nd_array_t*)) + for i in range(2): + operand = inputs['x1'] if i == 0 else inputs['x2'] + inputs_[i] = operand.c_array + inputs_[i].chunk_cache.nchunk = -1 + inputs_[i].chunk_cache.data = NULL + udata.inputs = inputs_ + udata.array = self.array + + # Save these in udf_udata to avoid computing them for each block + for i in range(3): + udata.chunks_strides[i][self.array.ndim - 1] = 1 + udata.blocks_strides[i][self.array.ndim - 1] = 1 + udata.el_strides[i][self.array.ndim - 1] = 1 + for idx in range(2, self.array.ndim + 1): + i = self.array.ndim - idx + udata.chunks_strides[0][i] = udata.chunks_strides[0][i + 1] * udata.array.extshape[i + 1] // udata.array.chunkshape[i + 1] + udata.blocks_strides[0][i] = udata.blocks_strides[0][i + 1] * udata.array.extchunkshape[i + 1] // udata.array.blockshape[i + 1] + + for j in range(2): + inp = inputs_[j] + cstrides = bstrides = estrides = 1 + for idx in range(2, self.array.ndim + 1): + i = inp.ndim - idx + if inp.shape[i + 1] == 1 or i < 0: + udata.chunks_strides[j][i] = 0 + udata.blocks_strides[j][i] = 0 + udata.el_strides[j][i] = 0 + else: + bstrides *= inp.extchunkshape[i + 1] // inp.blockshape[i + 1] + cstrides *= inp.extshape[i + 1] // inp.chunkshape[i + 1] + estrides *= inp.blockshape[i + 1] + udata.chunks_strides[j][i] = cstrides + udata.blocks_strides[j][i] = bstrides + udata.el_strides[j][i] = estrides + + return udata + def _set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, jit=None): # Set prefilter for miniexpr cdef blosc2_cparams* cparams = self.array.sc.storage.cparams @@ -3497,7 +3570,7 @@ cdef class NDArray: cdef blosc2_cparams* cparams = self.array.sc.storage.cparams cparams.prefilter = matmul_prefilter - cdef me_udata* udata = self._fill_me_udata(inputs, fp_accuracy, aux_reduc=None) + cdef mm_udata* udata = self._fill_mm_udata(inputs) cdef b2nd_array_t* out_arr = udata.array cdef blosc2_prefilter_params* preparams = calloc(1, sizeof(blosc2_prefilter_params)) preparams.user_data = udata diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 3b258ea7..dc0b64b3 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -113,21 +113,40 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) # multithreaded matmul - # TODO: handle a) type promotion, b) non-square blocks, c) and >2D + # TODO: handle a) type promotion, b) padding, c) (improved) >2D ops = (x1, x2, result) - shape, chunks, blocks = result.shape, result.chunks, result.blocks + blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) use_miniexpr = True if all_ndarray: - # can maybe relax this to just have A.blocks[-1] == B.blocks[-2] - # Require aligned NDArray operands with identical chunk/block grid, and square matrices/chunks/blocks - same_shape = all(op.shape[-1] == op.shape[-2] and op.shape == shape for op in ops) - same_chunks = all(op.shape[-1] == op.shape[-2] and op.chunks == chunks for op in ops) - same_blocks = all(op.shape[-1] == op.shape[-2] and op.blocks == blocks for op in ops) - if not (same_shape and same_chunks and same_blocks): - use_miniexpr = False if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False + + # TODO: In fact the following can be relaxed too, just need to load across block boundaries + # Might want to restrict loading across chunk boundaries, in which case would require: + # x1.chunks[-2] % result.blocks[-2] == 0 + # x2.chunks[-1] % result.blocks[-1] == 0 + # x2.chunks[-2] % x1.blocks[-1] == 0 + # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] + # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] + + # Require that blocks are matmul compatible and broadcastable directly to result + # (M, K) x (K, N) = (M, N) + # so can load block-by-block for inputs and calculate block of output + # Also need to avoid loading across chunk boundaries + chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + same_blocks = x2.blocks[-2] == x1.blocks[-1] + same_blocks &= x2.blocks[-1] == result.blocks[-1] + same_blocks &= result.blocks[-2] == x1.blocks[-2] + try: + result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + except ValueError: + use_miniexpr = False + if not (same_blocks and chunks_aligned and result_blocks[:-2] == blocks[:-2]): + use_miniexpr = False + else: use_miniexpr = False From 6997ec90826a6f95fb9d48c5d346782459ea4b0e Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Tue, 17 Mar 2026 20:22:15 +0100 Subject: [PATCH 60/69] Multithreaded matmul supported for ND --- bench/ndarray/stringops_bench.py | 2 +- src/blosc2/blosc2_ext.pyx | 24 +++++++++++++++--------- src/blosc2/lazyexpr.py | 9 +-------- src/blosc2/linalg.py | 10 +++++++--- src/blosc2/utils.py | 9 +++++++++ 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/bench/ndarray/stringops_bench.py b/bench/ndarray/stringops_bench.py index c983a0f2..5f97aac3 100644 --- a/bench/ndarray/stringops_bench.py +++ b/bench/ndarray/stringops_bench.py @@ -12,7 +12,7 @@ import time import numpy as np import blosc2 -from blosc2.lazyexpr import _toggle_miniexpr +from blosc2.utils import _toggle_miniexpr # nparr = np.random.randint(low=0, high=128, size=(N, 10), dtype=np.uint32) # nparr = nparr.view('S40').astype('U10') diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index b5b09561..df9802f1 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2375,8 +2375,10 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int ndim = out_arr.ndim cdef int nchunk_ = nchunk cdef int coord, batch, batch_, batches = 1 + + # batches = sum(strides[i]*elcoords[i]) for i in range(ndim - 2): - batches *= out_arr.shape[i] + batches *= out_arr.blockshape[i] # nchunk = sum(strides[i]*chunkcoords[i]) for i in range(ndim - 2): @@ -2390,8 +2392,8 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param i = nchunk_ // ncols # ncols * i + j j = nchunk_ % ncols - nchunkA = chunk_startA = nchunkA + i * ncols - nchunkB = chunk_startB = nchunkB + j + chunk_startA = nchunkA + i * ncols + chunk_startB = nchunkB + j # nblock = sum(strides[i]*blockcoords[i]) cdef int nblock_ = nblock @@ -2406,14 +2408,14 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param block_i = nblock_ // block_ncols block_j = nblock_ % block_ncols - block_startA = nblockA = nblockA + i * block_ncols - block_startB = nblockB = nblockB + j + block_startA = nblockA + block_i * block_ncols + block_startB = nblockB + block_j - # batches = sum(strides[i]*elcoords[i]) dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True - + nchunkA = chunk_startA + nchunkB = chunk_startB while True: # chunk loop for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB @@ -2455,6 +2457,9 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param if rc < 0: raise ValueError("matmul: error decompressing the B chunk") batch = 0 + offsetA = 0 + offsetB = 0 + offset = 0 while batch < batches: batch_ = batch for i in range(ndim - 2): @@ -3460,9 +3465,10 @@ cdef class NDArray: i = self.array.ndim - idx udata.chunks_strides[0][i] = udata.chunks_strides[0][i + 1] * udata.array.extshape[i + 1] // udata.array.chunkshape[i + 1] udata.blocks_strides[0][i] = udata.blocks_strides[0][i + 1] * udata.array.extchunkshape[i + 1] // udata.array.blockshape[i + 1] + udata.el_strides[0][i] = udata.el_strides[0][i + 1] * udata.array.blockshape[i + 1] - for j in range(2): - inp = inputs_[j] + for j in range(1, 3): + inp = inputs_[j - 1] cstrides = bstrides = estrides = 1 for idx in range(2, self.array.ndim + 1): i = inp.ndim - idx diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 9b88930c..c6893686 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -68,6 +68,7 @@ process_key, reducers, safe_numpy_globals, + try_miniexpr, ) if not blosc2.IS_WASM: @@ -76,14 +77,6 @@ global safe_blosc2_globals safe_blosc2_globals = {} -# Set this to False if miniexpr should not be tried out -try_miniexpr = not blosc2.IS_WASM or getattr(blosc2, "_WASM_MINIEXPR_ENABLED", False) - - -def _toggle_miniexpr(FLAG): - global try_miniexpr - try_miniexpr = FLAG - def ne_evaluate(expression, local_dict=None, **kwargs): """Safely evaluate expressions using numexpr when possible, falling back to numpy.""" diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index dc0b64b3..c050bf11 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -17,7 +17,7 @@ import blosc2 -from .utils import get_intersecting_chunks, nptranspose, npvecdot, slice_to_chunktuple +from .utils import get_intersecting_chunks, nptranspose, npvecdot, slice_to_chunktuple, try_miniexpr if TYPE_CHECKING: from collections.abc import Sequence @@ -113,11 +113,14 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) # multithreaded matmul - # TODO: handle a) type promotion, b) padding, c) (improved) >2D + # TODO: handle a) type promotion, b) padding (explicitly), c) (improved) >2D ops = (x1, x2, result) blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) - use_miniexpr = True + global try_miniexpr + + # Use a local copy so we don't modify the global + use_miniexpr = try_miniexpr if all_ndarray: if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False @@ -165,6 +168,7 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra if prefilter_set: result.schunk.remove_prefilter("miniexpr") else: # couldn't do multithreading + print("multithreading failed :( ") if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s p, q = result.chunks[-2:] r = x2.chunks[-1] diff --git a/src/blosc2/utils.py b/src/blosc2/utils.py index 6a72f2ef..46fc40a1 100644 --- a/src/blosc2/utils.py +++ b/src/blosc2/utils.py @@ -19,6 +19,15 @@ import blosc2 +# Set this to False if miniexpr should not be tried out +try_miniexpr = not blosc2.IS_WASM or getattr(blosc2, "_WASM_MINIEXPR_ENABLED", False) + + +def _toggle_miniexpr(FLAG): + global try_miniexpr + try_miniexpr = FLAG + + # NumPy version and a convenient boolean flag NUMPY_GE_2_0 = np.__version__ >= "2.0" # handle different numpy versions From e2836499346fce5d4b68977d2e845f533c68b00d Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Tue, 17 Mar 2026 20:38:26 +0100 Subject: [PATCH 61/69] Add benchmark --- bench/ndarray/multithreaded_matmul_bench.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 bench/ndarray/multithreaded_matmul_bench.py diff --git a/bench/ndarray/multithreaded_matmul_bench.py b/bench/ndarray/multithreaded_matmul_bench.py new file mode 100644 index 00000000..810527c6 --- /dev/null +++ b/bench/ndarray/multithreaded_matmul_bench.py @@ -0,0 +1,48 @@ +import blosc2 +import numpy as np +import time + +N = 10000 +ndim = 2 +ashape = (N,) * ndim +bshape = ashape +dtype = np.float64 + +achunks = (1000, 1000) +bchunks = (achunks[1], achunks[0]) +ablocks = (200, 200) +bblocks = (ablocks[1], ablocks[0]) +outblocks = (ablocks[0], bblocks[1]) +outchunks = (achunks[0], bchunks[1]) +# a = blosc2.linspace(0, 1, dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +# b = blosc2.linspace(0, 1, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +a = blosc2.ones(dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +b = blosc2.full(fill_value=2, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) + +a_np = a[:] +b_np = b[:] +tic = time.time() +np_res = np.matmul(a_np, b_np) +print(f'numpy finished in {time.time()-tic} s') + +tic = time.time() +b2_res = blosc2.matmul(a, b, blocks=outblocks, chunks=outchunks) +print(f'blosc2 multithreaded finished in {time.time()-tic} s') + +tic = time.time() +b2_res = blosc2.matmul(a, b) +print(f'blosc2 normal finished in {time.time()-tic} s') + +achunks = None #(1000, 1000) +bchunks = None #(achunks[1], achunks[0]) +ablocks = None #(200, 200) +bblocks = None #(ablocks[1], ablocks[0]) +outblocks = None #(ablocks[0], bblocks[1]) +outchunks = None #(achunks[0], bchunks[1]) +# a = blosc2.linspace(0, 1, dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +# b = blosc2.linspace(0, 1, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +a = blosc2.ones(dtype=dtype, shape=ashape, chunks=achunks, blocks=ablocks) +b = blosc2.full(fill_value=2, dtype=dtype, shape=bshape, chunks=bchunks, blocks=bblocks) +tic = time.time() +b2_res = blosc2.matmul(a, b, blocks=outblocks, chunks=outchunks) +print(f'blosc2 normal with default chunks etc. finished in {time.time()-tic} s') From 0b7b0b580526a91c99e01c919f3ff33ff7cc11d5 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Thu, 19 Mar 2026 20:49:56 +0100 Subject: [PATCH 62/69] Too difficult to make general --- src/blosc2/linalg.py | 108 ++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index c050bf11..36437bfa 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -115,61 +115,73 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra # multithreaded matmul # TODO: handle a) type promotion, b) padding (explicitly), c) (improved) >2D ops = (x1, x2, result) - blocks = result.blocks all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) global try_miniexpr # Use a local copy so we don't modify the global use_miniexpr = try_miniexpr - if all_ndarray: - if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition + if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + if all_ndarray: + if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition + use_miniexpr = False + + # Just force same chunk/block shapes + same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) + same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) + same_shape = all(op.shape == result.shape for op in (x1, x2)) + + use_miniexpr &= same_blocks & same_chunks & same_shape + + # TODO: We can relax this to even just load according to result blockshape, but that's difficult. + # Two easier cases are presented below + # Case 1: Might want to restrict loading across chunk boundaries, in which case would require: + # x1.chunks[-2] % result.blocks[-2] == 0 + # x2.chunks[-1] % result.blocks[-1] == 0 + # x2.chunks[-2] % x1.blocks[-1] == 0 + # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] + # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] + + # Case 2: Slightly easier to implement this maybe + # Require that blocks are matmul compatible and broadcastable directly to result + # (M, K) x (K, N) = (M, N) + # so can load block-by-block for inputs and calculate block of output + # Also need to avoid loading across chunk boundaries + # chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + # chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + # chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + # same_blocks = x2.blocks[-2] == x1.blocks[-1] + # same_blocks &= x2.blocks[-1] == result.blocks[-1] + # same_blocks &= result.blocks[-2] == x1.blocks[-2] + # try: + # result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + # if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): + # use_miniexpr = False + # except ValueError: + # use_miniexpr = False + + use_miniexpr &= x1.dtype.kind in ("i", "f") + use_miniexpr &= x2.dtype.kind in ("i", "f") + use_miniexpr &= x1.dtype == x2.dtype + + else: use_miniexpr = False - # TODO: In fact the following can be relaxed too, just need to load across block boundaries - # Might want to restrict loading across chunk boundaries, in which case would require: - # x1.chunks[-2] % result.blocks[-2] == 0 - # x2.chunks[-1] % result.blocks[-1] == 0 - # x2.chunks[-2] % x1.blocks[-1] == 0 - # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] - # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] - - # Require that blocks are matmul compatible and broadcastable directly to result - # (M, K) x (K, N) = (M, N) - # so can load block-by-block for inputs and calculate block of output - # Also need to avoid loading across chunk boundaries - chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 - chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 - chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 - same_blocks = x2.blocks[-2] == x1.blocks[-1] - same_blocks &= x2.blocks[-1] == result.blocks[-1] - same_blocks &= result.blocks[-2] == x1.blocks[-2] - try: - result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) - except ValueError: - use_miniexpr = False - if not (same_blocks and chunks_aligned and result_blocks[:-2] == blocks[:-2]): - use_miniexpr = False - - else: - use_miniexpr = False - - if use_miniexpr: - prefilter_set = False - try: - result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) - prefilter_set = True - # Data to compress is fetched from operands, so it can be uninitialized here - data = np.empty(result.schunk.chunksize, dtype=np.uint8) - for nchunk_out in range(result.schunk.nchunks): - result.schunk.update_data(nchunk_out, data, copy=False) - except Exception as e: - raise Exception from e - finally: - if prefilter_set: - result.schunk.remove_prefilter("miniexpr") - else: # couldn't do multithreading - print("multithreading failed :( ") - if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s + if use_miniexpr: + prefilter_set = False + try: + result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) + prefilter_set = True + # Data to compress is fetched from operands, so it can be uninitialized here + data = np.empty(result.schunk.chunksize, dtype=np.uint8) + for nchunk_out in range(result.schunk.nchunks): + result.schunk.update_data(nchunk_out, data, copy=False) + except Exception as e: + raise Exception from e + finally: + if prefilter_set: + result.schunk.remove_prefilter("miniexpr") + else: # couldn't do multithreading + print("multithreading failed :( ") p, q = result.chunks[-2:] r = x2.chunks[-1] From 7e09737fdafeb7e13266195cc6843e94f82b2c49 Mon Sep 17 00:00:00 2001 From: lshaw8317 Date: Sun, 22 Mar 2026 15:22:36 +0100 Subject: [PATCH 63/69] Extended allowable cases --- src/blosc2/blosc2_ext.pyx | 47 ++++++++++++++++++++++----------------- src/blosc2/linalg.py | 35 ++++++++++++++--------------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index df9802f1..79b09d17 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2369,12 +2369,13 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int blocknitems[2] cdef int startA, startB, expected_blocknitems cdef blosc2_context* dctx - cdef int i, j, block_i, block_j, ncols, block_ncols, Bblock_ncols, Bncols + cdef int i, j, block_i, block_j, chunk_i, chunk_j, ncols, block_ncols, Bblock_ncols, Bncols, Ablock_ncols, Ancols cdef int nchunkA = 0, nchunkB = 0, nblockA = 0, nblockB = 0, offsetA = 0, offsetB = 0, offset = 0 out_arr = udata.array cdef int ndim = out_arr.ndim cdef int nchunk_ = nchunk cdef int coord, batch, batch_, batches = 1 + cdef int out_chunk_nrows, out_chunk_ncols, out_block_nrows, out_block_ncols # batches = sum(strides[i]*elcoords[i]) for i in range(ndim - 2): @@ -2388,12 +2389,10 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param nchunkB += coord * udata.chunks_strides[2][i] ncols = udata.chunks_strides[0][ndim - 2] + Ancols = udata.chunks_strides[1][ndim - 2] Bncols = udata.chunks_strides[2][ndim - 2] - - i = nchunk_ // ncols # ncols * i + j - j = nchunk_ % ncols - chunk_startA = nchunkA + i * ncols - chunk_startB = nchunkB + j + out_chunk_nrows = out_arr.chunkshape[ndim - 2] + out_chunk_ncols = out_arr.chunkshape[ndim - 1] # nblock = sum(strides[i]*blockcoords[i]) cdef int nblock_ = nblock @@ -2404,18 +2403,14 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param nblockB += coord * udata.blocks_strides[2][i] block_ncols = udata.blocks_strides[0][ndim - 2] + Ablock_ncols = udata.blocks_strides[1][ndim - 2] Bblock_ncols = udata.blocks_strides[2][ndim - 2] - - block_i = nblock_ // block_ncols - block_j = nblock_ % block_ncols - block_startA = nblockA + block_i * block_ncols - block_startB = nblockB + block_j + out_block_nrows = out_arr.blockshape[ndim - 2] + out_block_ncols = out_arr.blockshape[ndim - 1] dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True - nchunkA = chunk_startA - nchunkB = chunk_startB while True: # chunk loop for i in range(2): chunk_idx = nchunkA if i == 0 else nchunkB @@ -2431,16 +2426,28 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param if i == 0: q = ndarr.blockshape[ndim - 1] p = ndarr.blockshape[ndim - 2] + # nchunk_ = chunks_in_row * chunk_row + chunk_col + # convert from chunk_idx to element idx chunk_i (row) + chunk_i = nchunk_ // ncols * out_chunk_nrows + chunk_startA = nchunkA + chunk_i // ndarr.chunkshape[ndim - 2] * Ancols + nchunkA = chunk_startA + # nblock_ = blocks_in_chunkrow * block_row + block_col + # convert from block_idx to element idx block_i (row) + block_i = nblock_ // block_ncols * out_block_nrows + block_startA = nblockA + block_i // p * Ablock_ncols else: # i = 1 r = ndarr.blockshape[ndim - 1] + # convert from chunk_idx to element idx chunk_j (col) + chunk_j = nchunk_ % ncols * out_chunk_ncols + chunk_startB = nchunkB + chunk_j // ndarr.chunkshape[ndim - 1] + nchunkB = chunk_startB + # convert from block_idx to element idx block_j (col) + block_j = nblock_ % block_ncols * out_block_ncols + block_startB = nblockB + block_j // r input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize - if i == 0: - expected_blocknitems = blocknitems[i] - elif blocknitems[i] != expected_blocknitems: - raise ValueError("miniexpr: inconsistent block element counts across inputs") first_run = False nblockA = block_startA @@ -2484,11 +2491,11 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param batch += 1 nblockA += 1 nblockB += Bblock_ncols - if (nblockA % block_ncols == 0): + if (nblockA % Ablock_ncols == 0): break nchunkA += 1 nchunkB += Bncols - if (nchunkA % ncols == 0): + if (nchunkA % Ancols == 0): break @@ -3472,7 +3479,7 @@ cdef class NDArray: cstrides = bstrides = estrides = 1 for idx in range(2, self.array.ndim + 1): i = inp.ndim - idx - if inp.shape[i + 1] == 1 or i < 0: + if (inp.shape[i + 1] == 1 and i < inp.ndim - 3) or i < 0: udata.chunks_strides[j][i] = 0 udata.blocks_strides[j][i] = 0 udata.el_strides[j][i] = 0 diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 36437bfa..58b81333 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -125,14 +125,13 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition use_miniexpr = False + # TODO: We can relax this to even just load according to result blockshape, but that's difficult. # Just force same chunk/block shapes - same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) - same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) - same_shape = all(op.shape == result.shape for op in (x1, x2)) - - use_miniexpr &= same_blocks & same_chunks & same_shape + # same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) + # same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) + # same_shape = all(op.shape == result.shape for op in (x1, x2)) - # TODO: We can relax this to even just load according to result blockshape, but that's difficult. + # use_miniexpr &= same_blocks & same_chunks & same_shape # Two easier cases are presented below # Case 1: Might want to restrict loading across chunk boundaries, in which case would require: # x1.chunks[-2] % result.blocks[-2] == 0 @@ -146,18 +145,18 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra # (M, K) x (K, N) = (M, N) # so can load block-by-block for inputs and calculate block of output # Also need to avoid loading across chunk boundaries - # chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 - # chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 - # chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 - # same_blocks = x2.blocks[-2] == x1.blocks[-1] - # same_blocks &= x2.blocks[-1] == result.blocks[-1] - # same_blocks &= result.blocks[-2] == x1.blocks[-2] - # try: - # result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) - # if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): - # use_miniexpr = False - # except ValueError: - # use_miniexpr = False + chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + same_blocks = x2.blocks[-2] == x1.blocks[-1] + same_blocks &= x2.blocks[-1] == result.blocks[-1] + same_blocks &= result.blocks[-2] == x1.blocks[-2] + try: + result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): + use_miniexpr = False + except ValueError: + use_miniexpr = False use_miniexpr &= x1.dtype.kind in ("i", "f") use_miniexpr &= x2.dtype.kind in ("i", "f") From 2ae53873d274416b30cac1bcbec792536366d612 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 10:56:15 +0100 Subject: [PATCH 64/69] Restrict matmul fast path to supported 2D cases and fall back to chunked path --- src/blosc2/blosc2_ext.pyx | 18 +++- src/blosc2/linalg.py | 160 ++++++++++++++++++----------------- src/blosc2/utils.py | 5 ++ tests/ndarray/test_linalg.py | 86 ++++++++++++++++++- 4 files changed, 185 insertions(+), 84 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 79b09d17..8a442eb7 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2102,6 +2102,7 @@ cdef class SChunk: cpdef remove_prefilter(self, func_name, _new_ctx=True): cdef udf_udata* udf_data cdef user_filters_udata* udata + cdef mm_udata* mm_data if func_name is not None and func_name in blosc2.prefilter_funcs: del blosc2.prefilter_funcs[func_name] @@ -2123,6 +2124,13 @@ cdef class SChunk: if me_data.eval_params != NULL: free(me_data.eval_params) free(me_data) + elif self.schunk.storage.cparams.prefilter == matmul_prefilter: + if self.schunk.storage.cparams.preparams != NULL: + mm_data = self.schunk.storage.cparams.preparams.user_data + if mm_data != NULL: + if mm_data.inputs != NULL: + free(mm_data.inputs) + free(mm_data) elif self.schunk.storage.cparams.prefilter != NULL: # From Python the preparams->udata with always have the field py_func if self.schunk.storage.cparams.preparams != NULL: @@ -2408,6 +2416,8 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param out_block_nrows = out_arr.blockshape[ndim - 2] out_block_ncols = out_arr.blockshape[ndim - 1] + memset(params_output, 0, out_arr.blocknitems * typesize) + dctx = blosc2_create_dctx(BLOSC2_DPARAMS_DEFAULTS) first_run = True @@ -2464,13 +2474,13 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param if rc < 0: raise ValueError("matmul: error decompressing the B chunk") batch = 0 - offsetA = 0 - offsetB = 0 - offset = 0 while batch < batches: batch_ = batch + offsetA = 0 + offsetB = 0 + offset = 0 for i in range(ndim - 2): - coord = batch // udata.el_strides[0][i] + coord = batch_ // udata.el_strides[0][i] batch_ = batch_ % udata.el_strides[0][i] offsetA += coord * udata.el_strides[1][i] offsetB += coord * udata.el_strides[2][i] diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 58b81333..5aaeef03 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -23,7 +23,79 @@ from collections.abc import Sequence -def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: # noqa: C901 +def _matmul_chunked( + x1: blosc2.Array, x2: blosc2.NDArray, result: blosc2.NDArray, n: int, m: int, k: int +) -> None: + p, q = result.chunks[-2:] + r = x2.chunks[-1] + + intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) + for chunk in intersecting_chunks: + chunk = chunk.raw + for row in range(0, n, p): + row_end = builtins.min(row + p, n) + for col in range(0, m, q): + col_end = builtins.min(col + q, m) + for aux in range(0, k, r): + aux_end = builtins.min(aux + r, k) + bx1 = ( + x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] + if x1.ndim > 2 + else x1[row:row_end, aux:aux_end] + ) + bx2 = ( + x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] + if x2.ndim > 2 + else x2[aux:aux_end, col:col_end] + ) + result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) + + +def _matmul_can_use_fast_path( + x1: blosc2.Array, x2: blosc2.NDArray, result: blosc2.NDArray, use_miniexpr: bool +) -> bool: + if not use_miniexpr: + return False + + ops = (x1, x2, result) + all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) + if not all_ndarray: + return False + + # The current prefilter-backed implementation is only supported for 2-D layouts. + if result.ndim != 2 or x1.ndim != 2 or x2.ndim != 2: + return False + + if any(op.dtype != ops[0].dtype for op in ops): + return False + + chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 + chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 + chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 + if not chunks_aligned: + return False + + same_blocks = x2.blocks[-2] == x1.blocks[-1] + same_blocks &= x2.blocks[-1] == result.blocks[-1] + same_blocks &= result.blocks[-2] == x1.blocks[-2] + if not same_blocks: + return False + + try: + result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) + except ValueError: + return False + if result_blocks[:-2] != result.blocks[:-2]: + return False + + if x1.dtype.kind not in ("i", "f"): + return False + if x2.dtype.kind not in ("i", "f"): + return False + return x1.dtype == x2.dtype + + +def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArray: """ Computes the matrix product between two Blosc2 NDArrays. @@ -112,60 +184,10 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra kwargs["_chunksize_reduc_factor"] = 1 result = blosc2.zeros(result_shape, dtype=blosc2.result_type(x1, x2), **kwargs) - # multithreaded matmul - # TODO: handle a) type promotion, b) padding (explicitly), c) (improved) >2D - ops = (x1, x2, result) - all_ndarray = all(isinstance(value, blosc2.NDArray) and value.shape != () for value in ops) global try_miniexpr - # Use a local copy so we don't modify the global - use_miniexpr = try_miniexpr if 0 not in result.shape + x1.shape + x2.shape: # if any array is empty, return array of 0s - if all_ndarray: - if any(op.dtype != ops[0].dtype for op in ops): # TODO: Remove this condition - use_miniexpr = False - - # TODO: We can relax this to even just load according to result blockshape, but that's difficult. - # Just force same chunk/block shapes - # same_chunks = all(op.chunks == result.chunks for op in (x1, x2)) - # same_blocks = all(op.blocks == result.blocks for op in (x1, x2)) - # same_shape = all(op.shape == result.shape for op in (x1, x2)) - - # use_miniexpr &= same_blocks & same_chunks & same_shape - # Two easier cases are presented below - # Case 1: Might want to restrict loading across chunk boundaries, in which case would require: - # x1.chunks[-2] % result.blocks[-2] == 0 - # x2.chunks[-1] % result.blocks[-1] == 0 - # x2.chunks[-2] % x1.blocks[-1] == 0 - # Can then load in x1 as slices of size [result.blocks[-2], x1.blocks[-1]] - # and x2 in slices of [x1.blocks[-1], result.blocks[-1]] - - # Case 2: Slightly easier to implement this maybe - # Require that blocks are matmul compatible and broadcastable directly to result - # (M, K) x (K, N) = (M, N) - # so can load block-by-block for inputs and calculate block of output - # Also need to avoid loading across chunk boundaries - chunks_aligned = x1.chunks[-2] % x1.blocks[-2] == 0 - chunks_aligned &= x2.chunks[-1] % x2.blocks[-1] == 0 - chunks_aligned &= x2.chunks[-2] % x1.blocks[-1] == 0 - same_blocks = x2.blocks[-2] == x1.blocks[-1] - same_blocks &= x2.blocks[-1] == result.blocks[-1] - same_blocks &= result.blocks[-2] == x1.blocks[-2] - try: - result_blocks = np.broadcast_shapes(x1.blocks, x2.blocks) - if not (same_blocks and chunks_aligned and result_blocks[:-2] == result.blocks[:-2]): - use_miniexpr = False - except ValueError: - use_miniexpr = False - - use_miniexpr &= x1.dtype.kind in ("i", "f") - use_miniexpr &= x2.dtype.kind in ("i", "f") - use_miniexpr &= x1.dtype == x2.dtype - - else: - use_miniexpr = False - - if use_miniexpr: + if _matmul_can_use_fast_path(x1, x2, result, try_miniexpr): prefilter_set = False try: result._set_pref_matmul({"x1": x1, "x2": x2}, fp_accuracy=blosc2.FPAccuracy.DEFAULT) @@ -174,36 +196,16 @@ def matmul(x1: blosc2.Array, x2: blosc2.NDArray, **kwargs: Any) -> blosc2.NDArra data = np.empty(result.schunk.chunksize, dtype=np.uint8) for nchunk_out in range(result.schunk.nchunks): result.schunk.update_data(nchunk_out, data, copy=False) - except Exception as e: - raise Exception from e + except Exception as exc: + warnings.warn( + f"Fast matmul path unavailable; falling back to chunked path: {exc}", RuntimeWarning + ) + _matmul_chunked(x1, x2, result, n, m, k) finally: if prefilter_set: result.schunk.remove_prefilter("miniexpr") - else: # couldn't do multithreading - print("multithreading failed :( ") - p, q = result.chunks[-2:] - r = x2.chunks[-1] - - intersecting_chunks = get_intersecting_chunks((), result.shape[:-2], result.chunks[:-2]) - for chunk in intersecting_chunks: - chunk = chunk.raw - for row in range(0, n, p): - row_end = builtins.min(row + p, n) - for col in range(0, m, q): - col_end = builtins.min(col + q, m) - for aux in range(0, k, r): - aux_end = builtins.min(aux + r, k) - bx1 = ( - x1[chunk[-x1.ndim + 2 :] + (slice(row, row_end), slice(aux, aux_end))] - if x1.ndim > 2 - else x1[row:row_end, aux:aux_end] - ) - bx2 = ( - x2[chunk[-x2.ndim + 2 :] + (slice(aux, aux_end), slice(col, col_end))] - if x2.ndim > 2 - else x2[aux:aux_end, col:col_end] - ) - result[chunk + (slice(row, row_end), slice(col, col_end))] += np.matmul(bx1, bx2) + else: + _matmul_chunked(x1, x2, result, n, m, k) if x1_is_vector: result = result.squeeze(axis=-2) diff --git a/src/blosc2/utils.py b/src/blosc2/utils.py index 46fc40a1..67434a3a 100644 --- a/src/blosc2/utils.py +++ b/src/blosc2/utils.py @@ -9,6 +9,7 @@ import builtins import inspect import math +import sys import warnings from itertools import product @@ -26,6 +27,10 @@ def _toggle_miniexpr(FLAG): global try_miniexpr try_miniexpr = FLAG + for module_name in ("blosc2.lazyexpr", "blosc2.linalg"): + module = sys.modules.get(module_name) + if module is not None: + module.try_miniexpr = FLAG # NumPy version and a convenient boolean flag diff --git a/tests/ndarray/test_linalg.py b/tests/ndarray/test_linalg.py index aa2ddb19..b9473082 100644 --- a/tests/ndarray/test_linalg.py +++ b/tests/ndarray/test_linalg.py @@ -12,8 +12,10 @@ import pytest import blosc2 +import blosc2.linalg as blosc2_linalg +import blosc2.utils as utils_mod from blosc2.lazyexpr import linalg_funcs -from blosc2.utils import npvecdot +from blosc2.utils import _toggle_miniexpr, npvecdot # Conditionally import torch for proxy tests try: @@ -69,6 +71,88 @@ def test_matmul(ashape, achunks, ablocks, bshape, bchunks, bblocks, dtype): np.testing.assert_allclose(b2_res[()], np_res, rtol=1e-6) +def test_toggle_miniexpr_updates_linalg_runtime_flag(): + old_flag = utils_mod.try_miniexpr + try: + _toggle_miniexpr(False) + assert utils_mod.try_miniexpr is False + assert blosc2_linalg.try_miniexpr is False + + _toggle_miniexpr(True) + assert utils_mod.try_miniexpr is True + assert blosc2_linalg.try_miniexpr is True + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_uses_fast_path_for_supported_2d(monkeypatch): + old_flag = utils_mod.try_miniexpr + calls = [] + original = blosc2.NDArray._set_pref_matmul + + def wrapped_set_pref_matmul(self, inputs, fp_accuracy): + calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) + return original(self, inputs, fp_accuracy) + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(400, 400), dtype=np.int64, chunks=(200, 200), blocks=(100, 100)) + b = blosc2.full(shape=(400, 400), fill_value=2, dtype=np.int64, chunks=(200, 200), blocks=(100, 100)) + + c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(100, 100)) + + assert calls == [((400, 400), (400, 400), (400, 400))] + np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_falls_back_for_nd_inputs(monkeypatch): + old_flag = utils_mod.try_miniexpr + calls = [] + original = blosc2.NDArray._set_pref_matmul + + def wrapped_set_pref_matmul(self, inputs, fp_accuracy): + calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) + return original(self, inputs, fp_accuracy) + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(2, 40, 40), dtype=np.int64, chunks=(1, 20, 20), blocks=(1, 10, 10)) + b = blosc2.full( + shape=(2, 40, 40), fill_value=2, dtype=np.int64, chunks=(1, 20, 20), blocks=(1, 10, 10) + ) + + c = blosc2.matmul(a, b, chunks=(1, 20, 20), blocks=(1, 10, 10)) + + assert calls == [] + np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_fast_path_failure_falls_back(monkeypatch): + old_flag = utils_mod.try_miniexpr + + def failing_set_pref_matmul(self, inputs, fp_accuracy): + raise RuntimeError("boom") + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", failing_set_pref_matmul) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(200, 200), dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) + b = blosc2.full(shape=(200, 200), fill_value=2, dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) + + with pytest.warns(RuntimeWarning, match="falling back to chunked path"): + c = blosc2.matmul(a, b, chunks=(100, 100), blocks=(50, 50)) + + np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + @pytest.mark.parametrize( ("ashape", "achunks", "ablocks"), { From 46b0842fbfb3732f78638ab34b85d3b70b7fd846 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 11:09:06 +0100 Subject: [PATCH 65/69] Limit matmul fast path to floating-point 2D cases and fall back to chunked path --- src/blosc2/linalg.py | 4 +-- tests/ndarray/test_linalg.py | 59 ++++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/blosc2/linalg.py b/src/blosc2/linalg.py index 5aaeef03..894fc216 100644 --- a/src/blosc2/linalg.py +++ b/src/blosc2/linalg.py @@ -88,9 +88,9 @@ def _matmul_can_use_fast_path( if result_blocks[:-2] != result.blocks[:-2]: return False - if x1.dtype.kind not in ("i", "f"): + if x1.dtype.kind != "f": return False - if x2.dtype.kind not in ("i", "f"): + if x2.dtype.kind != "f": return False return x1.dtype == x2.dtype diff --git a/tests/ndarray/test_linalg.py b/tests/ndarray/test_linalg.py index b9473082..5f582e04 100644 --- a/tests/ndarray/test_linalg.py +++ b/tests/ndarray/test_linalg.py @@ -6,6 +6,7 @@ ####################################################################### import inspect +import warnings from itertools import permutations import numpy as np @@ -97,12 +98,40 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) try: _toggle_miniexpr(True) - a = blosc2.ones(shape=(400, 400), dtype=np.int64, chunks=(200, 200), blocks=(100, 100)) - b = blosc2.full(shape=(400, 400), fill_value=2, dtype=np.int64, chunks=(200, 200), blocks=(100, 100)) + a = blosc2.ones(shape=(400, 400), dtype=np.float64, chunks=(200, 200), blocks=(100, 100)) + b = blosc2.full( + shape=(400, 400), fill_value=2, dtype=np.float64, chunks=(200, 200), blocks=(100, 100) + ) - c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(100, 100)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(100, 100)) + expected = np.matmul(a[:], b[:]) assert calls == [((400, 400), (400, 400), (400, 400))] + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_falls_back_for_integer_inputs(monkeypatch): + old_flag = utils_mod.try_miniexpr + calls = [] + original = blosc2.NDArray._set_pref_matmul + + def wrapped_set_pref_matmul(self, inputs, fp_accuracy): + calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) + return original(self, inputs, fp_accuracy) + + monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(200, 200), dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) + b = blosc2.full(shape=(200, 200), fill_value=2, dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) + + c = blosc2.matmul(a, b, chunks=(100, 100), blocks=(50, 50)) + + assert calls == [] np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) finally: _toggle_miniexpr(old_flag) @@ -120,15 +149,18 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) try: _toggle_miniexpr(True) - a = blosc2.ones(shape=(2, 40, 40), dtype=np.int64, chunks=(1, 20, 20), blocks=(1, 10, 10)) + a = blosc2.ones(shape=(2, 40, 40), dtype=np.float64, chunks=(1, 20, 20), blocks=(1, 10, 10)) b = blosc2.full( - shape=(2, 40, 40), fill_value=2, dtype=np.int64, chunks=(1, 20, 20), blocks=(1, 10, 10) + shape=(2, 40, 40), fill_value=2, dtype=np.float64, chunks=(1, 20, 20), blocks=(1, 10, 10) ) - c = blosc2.matmul(a, b, chunks=(1, 20, 20), blocks=(1, 10, 10)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(1, 20, 20), blocks=(1, 10, 10)) + expected = np.matmul(a[:], b[:]) assert calls == [] - np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) finally: _toggle_miniexpr(old_flag) @@ -142,13 +174,16 @@ def failing_set_pref_matmul(self, inputs, fp_accuracy): monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", failing_set_pref_matmul) try: _toggle_miniexpr(True) - a = blosc2.ones(shape=(200, 200), dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) - b = blosc2.full(shape=(200, 200), fill_value=2, dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) + a = blosc2.ones(shape=(200, 200), dtype=np.float64, chunks=(100, 100), blocks=(50, 50)) + b = blosc2.full(shape=(200, 200), fill_value=2, dtype=np.float64, chunks=(100, 100), blocks=(50, 50)) - with pytest.warns(RuntimeWarning, match="falling back to chunked path"): - c = blosc2.matmul(a, b, chunks=(100, 100), blocks=(50, 50)) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=".*encountered in matmul", category=RuntimeWarning) + with pytest.warns(RuntimeWarning, match="falling back to chunked path"): + c = blosc2.matmul(a, b, chunks=(100, 100), blocks=(50, 50)) + expected = np.matmul(a[:], b[:]) - np.testing.assert_allclose(c[:], np.matmul(a[:], b[:]), rtol=1e-6, atol=1e-6) + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) finally: _toggle_miniexpr(old_flag) From d60eebf617157a098fc2750bd9b8358e15212b60 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 11:18:39 +0100 Subject: [PATCH 66/69] Expand matmul path coverage and report selected backend in benchmarks --- bench/ndarray/matmul_path_compare.py | 218 +++++++++++++++++++++++++++ tests/ndarray/test_linalg.py | 115 +++++++++++--- 2 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 bench/ndarray/matmul_path_compare.py diff --git a/bench/ndarray/matmul_path_compare.py b/bench/ndarray/matmul_path_compare.py new file mode 100644 index 00000000..cdb2d259 --- /dev/null +++ b/bench/ndarray/matmul_path_compare.py @@ -0,0 +1,218 @@ +import argparse +import json +import statistics +import time +import warnings + +import numpy as np + +import blosc2 +import blosc2.linalg as linalg + + +def parse_int_tuple(value: str) -> tuple[int, ...]: + return tuple(int(item.strip()) for item in value.split(",") if item.strip()) + + +def build_arrays( + shape_a: tuple[int, ...], + shape_b: tuple[int, ...], + dtype: np.dtype, + chunks_a: tuple[int, ...] | None, + chunks_b: tuple[int, ...] | None, + blocks_a: tuple[int, ...] | None, + blocks_b: tuple[int, ...] | None, +): + a_np = np.ones(shape_a, dtype=dtype) + b_np = np.full(shape_b, 2, dtype=dtype) + a = blosc2.asarray(a_np, chunks=chunks_a, blocks=blocks_a) + b = blosc2.asarray(b_np, chunks=chunks_b, blocks=blocks_b) + return a, b, a_np, b_np + + +def expected_gflops(shape_a: tuple[int, ...], shape_b: tuple[int, ...], elapsed: float) -> float | None: + if elapsed <= 0 or len(shape_a) < 2 or len(shape_b) < 2: + return None + m = shape_a[-2] + k = shape_a[-1] + n = shape_b[-1] + batch = int(np.prod(np.broadcast_shapes(shape_a[:-2], shape_b[:-2]))) if len(shape_a) > 2 or len(shape_b) > 2 else 1 + flops = 2 * batch * m * n * k + return flops / elapsed / 1e9 + + +def set_path_mode(mode: str) -> bool: + original = linalg.try_miniexpr + if mode == "chunked": + linalg.try_miniexpr = False + elif mode == "fast": + linalg.try_miniexpr = True + elif mode == "auto": + linalg.try_miniexpr = original + else: + raise ValueError(f"unknown mode: {mode}") + return original + + +def run_case( + mode: str, + repeats: int, + shape_a: tuple[int, ...], + shape_b: tuple[int, ...], + dtype: np.dtype, + chunks_a: tuple[int, ...] | None, + chunks_b: tuple[int, ...] | None, + blocks_a: tuple[int, ...] | None, + blocks_b: tuple[int, ...] | None, + chunks_out: tuple[int, ...] | None, + blocks_out: tuple[int, ...] | None, +): + a, b, a_np, b_np = build_arrays(shape_a, shape_b, dtype, chunks_a, chunks_b, blocks_a, blocks_b) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + expected = np.matmul(a_np, b_np) + original_flag = set_path_mode(mode) + original_set_pref_matmul = blosc2.NDArray._set_pref_matmul + selected_paths = [] + times = [] + result = None + + def wrapped_set_pref_matmul(self, inputs, fp_accuracy): + selected_paths.append("fast") + return original_set_pref_matmul(self, inputs, fp_accuracy) + + blosc2.NDArray._set_pref_matmul = wrapped_set_pref_matmul + try: + for _ in range(repeats): + before = len(selected_paths) + t0 = time.perf_counter() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + result = blosc2.matmul(a, b, chunks=chunks_out, blocks=blocks_out) + times.append(time.perf_counter() - t0) + if len(selected_paths) == before: + selected_paths.append("chunked") + finally: + blosc2.NDArray._set_pref_matmul = original_set_pref_matmul + linalg.try_miniexpr = original_flag + + if result is None: + raise RuntimeError("matmul did not produce a result") + + actual = result[:] + np.testing.assert_allclose(actual, expected, rtol=1e-6, atol=1e-6) + + best = min(times) + median = statistics.median(times) + return { + "mode": mode, + "times_s": times, + "best_s": best, + "median_s": median, + "gflops_best": expected_gflops(shape_a, shape_b, best), + "gflops_median": expected_gflops(shape_a, shape_b, median), + "correct": True, + "selected_paths": selected_paths, + "selected_path": selected_paths[0] if selected_paths and len(set(selected_paths)) == 1 else "mixed", + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Compare chunked and fast blosc2.matmul paths.") + parser.add_argument("--shape-a", default="1000,1000", help="Comma-separated shape for A.") + parser.add_argument("--shape-b", default="1000,1000", help="Comma-separated shape for B.") + parser.add_argument("--dtype", default="float64", choices=["float32", "float64", "int32", "int64"]) + parser.add_argument("--chunks-a", default="500,500", help="Comma-separated chunk shape for A.") + parser.add_argument("--chunks-b", default="500,500", help="Comma-separated chunk shape for B.") + parser.add_argument("--blocks-a", default="100,100", help="Comma-separated block shape for A.") + parser.add_argument("--blocks-b", default="100,100", help="Comma-separated block shape for B.") + parser.add_argument("--chunks-out", default="500,500", help="Comma-separated chunk shape for output.") + parser.add_argument("--blocks-out", default="100,100", help="Comma-separated block shape for output.") + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--modes", nargs="+", default=["chunked", "fast", "auto"], choices=["chunked", "fast", "auto"]) + parser.add_argument("--json", action="store_true", help="Emit full JSON instead of a compact text summary.") + args = parser.parse_args() + + shape_a = parse_int_tuple(args.shape_a) + shape_b = parse_int_tuple(args.shape_b) + chunks_a = parse_int_tuple(args.chunks_a) if args.chunks_a else None + chunks_b = parse_int_tuple(args.chunks_b) if args.chunks_b else None + blocks_a = parse_int_tuple(args.blocks_a) if args.blocks_a else None + blocks_b = parse_int_tuple(args.blocks_b) if args.blocks_b else None + chunks_out = parse_int_tuple(args.chunks_out) if args.chunks_out else None + blocks_out = parse_int_tuple(args.blocks_out) if args.blocks_out else None + dtype = np.dtype(args.dtype) + + results = [] + for mode in args.modes: + results.append( + run_case( + mode, + args.repeats, + shape_a, + shape_b, + dtype, + chunks_a, + chunks_b, + blocks_a, + blocks_b, + chunks_out, + blocks_out, + ) + ) + + summary = { + "shape_a": shape_a, + "shape_b": shape_b, + "dtype": str(dtype), + "chunks_a": chunks_a, + "chunks_b": chunks_b, + "blocks_a": blocks_a, + "blocks_b": blocks_b, + "chunks_out": chunks_out, + "blocks_out": blocks_out, + "results": results, + } + + best_by_mode = {item["mode"]: item["best_s"] for item in results} + if "chunked" in best_by_mode and "fast" in best_by_mode: + summary["speedup_fast_vs_chunked"] = best_by_mode["chunked"] / best_by_mode["fast"] + + if args.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + return + + print( + "case", + json.dumps( + { + "shape_a": shape_a, + "shape_b": shape_b, + "dtype": str(dtype), + "chunks_out": chunks_out, + "blocks_out": blocks_out, + }, + sort_keys=True, + ), + ) + for item in results: + print( + "result", + json.dumps( + { + "mode": item["mode"], + "best_s": round(item["best_s"], 6), + "median_s": round(item["median_s"], 6), + "gflops_best": None if item["gflops_best"] is None else round(item["gflops_best"], 3), + "correct": item["correct"], + "selected_path": item["selected_path"], + }, + sort_keys=True, + ), + ) + if "speedup_fast_vs_chunked" in summary: + print("speedup", json.dumps({"fast_vs_chunked": round(summary["speedup_fast_vs_chunked"], 3)}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/ndarray/test_linalg.py b/tests/ndarray/test_linalg.py index 5f582e04..31343b48 100644 --- a/tests/ndarray/test_linalg.py +++ b/tests/ndarray/test_linalg.py @@ -86,44 +86,61 @@ def test_toggle_miniexpr_updates_linalg_runtime_flag(): _toggle_miniexpr(old_flag) -def test_matmul_uses_fast_path_for_supported_2d(monkeypatch): - old_flag = utils_mod.try_miniexpr +def _set_pref_matmul_call_recorder(monkeypatch): calls = [] original = blosc2.NDArray._set_pref_matmul def wrapped_set_pref_matmul(self, inputs, fp_accuracy): - calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) + calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape, self.dtype)) return original(self, inputs, fp_accuracy) monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + return calls + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_matmul_uses_fast_path_for_supported_2d(monkeypatch, dtype): + old_flag = utils_mod.try_miniexpr + calls = _set_pref_matmul_call_recorder(monkeypatch) try: _toggle_miniexpr(True) - a = blosc2.ones(shape=(400, 400), dtype=np.float64, chunks=(200, 200), blocks=(100, 100)) - b = blosc2.full( - shape=(400, 400), fill_value=2, dtype=np.float64, chunks=(200, 200), blocks=(100, 100) - ) + a = blosc2.ones(shape=(400, 400), dtype=dtype, chunks=(200, 200), blocks=(100, 100)) + b = blosc2.full(shape=(400, 400), fill_value=2, dtype=dtype, chunks=(200, 200), blocks=(100, 100)) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(100, 100)) expected = np.matmul(a[:], b[:]) - assert calls == [((400, 400), (400, 400), (400, 400))] + assert calls == [((400, 400), (400, 400), (400, 400), np.dtype(dtype))] np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) finally: _toggle_miniexpr(old_flag) -def test_matmul_falls_back_for_integer_inputs(monkeypatch): +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_matmul_uses_fast_path_with_multiple_inner_blocks(monkeypatch, dtype): old_flag = utils_mod.try_miniexpr - calls = [] - original = blosc2.NDArray._set_pref_matmul + calls = _set_pref_matmul_call_recorder(monkeypatch) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(256, 384), dtype=dtype, chunks=(128, 192), blocks=(64, 64)) + b = blosc2.full(shape=(384, 256), fill_value=2, dtype=dtype, chunks=(192, 128), blocks=(64, 64)) - def wrapped_set_pref_matmul(self, inputs, fp_accuracy): - calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) - return original(self, inputs, fp_accuracy) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(128, 128), blocks=(64, 64)) + expected = np.matmul(a[:], b[:]) - monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + assert calls == [((256, 256), (256, 384), (384, 256), np.dtype(dtype))] + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_falls_back_for_integer_inputs(monkeypatch): + old_flag = utils_mod.try_miniexpr + calls = _set_pref_matmul_call_recorder(monkeypatch) try: _toggle_miniexpr(True) a = blosc2.ones(shape=(200, 200), dtype=np.int64, chunks=(100, 100), blocks=(50, 50)) @@ -139,14 +156,7 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): def test_matmul_falls_back_for_nd_inputs(monkeypatch): old_flag = utils_mod.try_miniexpr - calls = [] - original = blosc2.NDArray._set_pref_matmul - - def wrapped_set_pref_matmul(self, inputs, fp_accuracy): - calls.append((self.shape, inputs["x1"].shape, inputs["x2"].shape)) - return original(self, inputs, fp_accuracy) - - monkeypatch.setattr(blosc2.NDArray, "_set_pref_matmul", wrapped_set_pref_matmul) + calls = _set_pref_matmul_call_recorder(monkeypatch) try: _toggle_miniexpr(True) a = blosc2.ones(shape=(2, 40, 40), dtype=np.float64, chunks=(1, 20, 20), blocks=(1, 10, 10)) @@ -165,6 +175,65 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): _toggle_miniexpr(old_flag) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_matmul_falls_back_for_misaligned_blocks(monkeypatch, dtype): + old_flag = utils_mod.try_miniexpr + calls = _set_pref_matmul_call_recorder(monkeypatch) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(400, 400), dtype=dtype, chunks=(200, 200), blocks=(120, 100)) + b = blosc2.full(shape=(400, 400), fill_value=2, dtype=dtype, chunks=(200, 200), blocks=(100, 100)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(120, 100)) + expected = np.matmul(a[:], b[:]) + + assert calls == [] + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +def test_matmul_falls_back_for_dtype_mismatch(monkeypatch): + old_flag = utils_mod.try_miniexpr + calls = _set_pref_matmul_call_recorder(monkeypatch) + try: + _toggle_miniexpr(True) + a = blosc2.ones(shape=(200, 200), dtype=np.float32, chunks=(100, 100), blocks=(50, 50)) + b = blosc2.full(shape=(200, 200), fill_value=2, dtype=np.float64, chunks=(100, 100), blocks=(50, 50)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(100, 100), blocks=(50, 50)) + expected = np.matmul(a[:], b[:]) + + assert calls == [] + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + +@pytest.mark.parametrize("dtype", [np.complex64, np.complex128]) +def test_matmul_complex_falls_back_to_chunked(monkeypatch, dtype): + old_flag = utils_mod.try_miniexpr + calls = _set_pref_matmul_call_recorder(monkeypatch) + try: + _toggle_miniexpr(True) + a = blosc2.asarray(np.ones((100, 100), dtype=dtype)) + b = blosc2.asarray(np.full((100, 100), 2 + 0j, dtype=dtype)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + c = blosc2.matmul(a, b, chunks=(50, 50), blocks=(25, 25)) + expected = np.matmul(a[:], b[:]) + + assert calls == [] + np.testing.assert_allclose(c[:], expected, rtol=1e-6, atol=1e-6) + finally: + _toggle_miniexpr(old_flag) + + def test_matmul_fast_path_failure_falls_back(monkeypatch): old_flag = utils_mod.try_miniexpr From fe542c30c0ce68a8bb4be851dd3af534f964b780 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 11:24:34 +0100 Subject: [PATCH 67/69] New defaults for matmul benchmarks --- bench/ndarray/matmul_path_compare.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bench/ndarray/matmul_path_compare.py b/bench/ndarray/matmul_path_compare.py index cdb2d259..c2a5dd8f 100644 --- a/bench/ndarray/matmul_path_compare.py +++ b/bench/ndarray/matmul_path_compare.py @@ -119,16 +119,16 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): def main() -> None: parser = argparse.ArgumentParser(description="Compare chunked and fast blosc2.matmul paths.") - parser.add_argument("--shape-a", default="1000,1000", help="Comma-separated shape for A.") - parser.add_argument("--shape-b", default="1000,1000", help="Comma-separated shape for B.") - parser.add_argument("--dtype", default="float64", choices=["float32", "float64", "int32", "int64"]) - parser.add_argument("--chunks-a", default="500,500", help="Comma-separated chunk shape for A.") - parser.add_argument("--chunks-b", default="500,500", help="Comma-separated chunk shape for B.") + parser.add_argument("--shape-a", default="400,400", help="Comma-separated shape for A.") + parser.add_argument("--shape-b", default="400,400", help="Comma-separated shape for B.") + parser.add_argument("--dtype", default="float32", choices=["float32", "float64", "int32", "int64"]) + parser.add_argument("--chunks-a", default="200,200", help="Comma-separated chunk shape for A.") + parser.add_argument("--chunks-b", default="200,200", help="Comma-separated chunk shape for B.") parser.add_argument("--blocks-a", default="100,100", help="Comma-separated block shape for A.") parser.add_argument("--blocks-b", default="100,100", help="Comma-separated block shape for B.") - parser.add_argument("--chunks-out", default="500,500", help="Comma-separated chunk shape for output.") + parser.add_argument("--chunks-out", default="200,200", help="Comma-separated chunk shape for output.") parser.add_argument("--blocks-out", default="100,100", help="Comma-separated block shape for output.") - parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--repeats", type=int, default=250) parser.add_argument("--modes", nargs="+", default=["chunked", "fast", "auto"], choices=["chunked", "fast", "auto"]) parser.add_argument("--json", action="store_true", help="Emit full JSON instead of a compact text summary.") args = parser.parse_args() From 22ff4be88ad794fbd1b961cbe3b18c127a34817c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 11:38:25 +0100 Subject: [PATCH 68/69] Comment why we ignore some spurious matmul RuntimeWarnings on macOS arm64 --- bench/ndarray/matmul_path_compare.py | 2 ++ tests/ndarray/test_linalg.py | 1 + 2 files changed, 3 insertions(+) diff --git a/bench/ndarray/matmul_path_compare.py b/bench/ndarray/matmul_path_compare.py index c2a5dd8f..82ef7f6d 100644 --- a/bench/ndarray/matmul_path_compare.py +++ b/bench/ndarray/matmul_path_compare.py @@ -69,6 +69,7 @@ def run_case( ): a, b, a_np, b_np = build_arrays(shape_a, shape_b, dtype, chunks_a, chunks_b, blocks_a, blocks_b) with warnings.catch_warnings(): + # NumPy + Accelerate can emit spurious matmul RuntimeWarnings on macOS arm64. warnings.simplefilter("ignore", RuntimeWarning) expected = np.matmul(a_np, b_np) original_flag = set_path_mode(mode) @@ -87,6 +88,7 @@ def wrapped_set_pref_matmul(self, inputs, fp_accuracy): before = len(selected_paths) t0 = time.perf_counter() with warnings.catch_warnings(): + # NumPy + Accelerate can emit spurious matmul RuntimeWarnings on macOS arm64. warnings.simplefilter("ignore", RuntimeWarning) result = blosc2.matmul(a, b, chunks=chunks_out, blocks=blocks_out) times.append(time.perf_counter() - t0) diff --git a/tests/ndarray/test_linalg.py b/tests/ndarray/test_linalg.py index 31343b48..8967e5d2 100644 --- a/tests/ndarray/test_linalg.py +++ b/tests/ndarray/test_linalg.py @@ -108,6 +108,7 @@ def test_matmul_uses_fast_path_for_supported_2d(monkeypatch, dtype): b = blosc2.full(shape=(400, 400), fill_value=2, dtype=dtype, chunks=(200, 200), blocks=(100, 100)) with warnings.catch_warnings(): + # NumPy + Accelerate can emit spurious matmul RuntimeWarnings on macOS arm64. warnings.simplefilter("ignore", RuntimeWarning) c = blosc2.matmul(a, b, chunks=(200, 200), blocks=(100, 100)) expected = np.matmul(a[:], b[:]) From f2678e8a7d5b8132f7b25ba63d83a511a38e4a18 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 23 Mar 2026 11:44:05 +0100 Subject: [PATCH 69/69] Some notes for the forthcoming release --- RELEASE_NOTES.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 74c71229..49c74c5d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,12 +1,20 @@ # Release notes -## Changes from 4.1.0 to 4.1.1 - -XXX version-specific blurb XXX - ## Changes from 4.1.1 to 4.1.2 -- Update `c-blosc2` version +- A new fast path for src/blosc2/linalg.py that uses the matmul prefilter machinery in src/blosc2/blosc2_ext.pyx. + - The fast path is only used for supported cases: + - blosc2.NDArray inputs + - 2-D only + - floating-point only + - matching dtypes + - aligned chunk/block layouts that satisfy the current kernel assumptions + - All other valid cases fall back to the existing chunk-by-chunk implementation in src/blosc2/linalg.py. + - Some benchmarks for the supported cases show significant speedups over the chunked implementation: + - aligned 400x400 float32: about 3.7x faster over chunked + - aligned 400x400 float64: about 3.0x + - aligned 800x800 float32: about 1.5x + - misaligned case: auto correctly stays on chunked ## Changes from 4.1.0 to 4.1.1