feat: add DoubleSparse sparse storage backend#1139
Conversation
Add a hash-map backed `DoubleSparse` storage that only allocates filled cells, making histograms over very large or high-dimensional axis spaces feasible. Backed by `storage_adaptor<unordered_map<size_t, double>>`. Scoped to `double` only for now: accumulator-backed sparse storages need boostorg/histogram#421, which ships in Boost 1.92 (the repo vendors 1.90). Data access is COO-first and deliberately not a full drop-in: - `.view()` raises (no contiguous buffer to view into). - `Histogram.to_coo(flow=False)` returns the filled cells as `(per-axis index tuple, values)`, numpy-nonzero style. - The copying accessors `values()`/`variances()`/`counts()` and `np.asarray(h)` densify into a fresh array, so they keep working. - `at()` reads, slicing, projection, sum and histogram addition stay sparse. Serialization: pickle round-trips via free save/load on `double_sparse` (COO key/value arrays); UHI to_uhi/from_uhi store COO under a `"sparse"` payload plus writer_info, reconstructed via a C++ `_from_coo` bulk setter. Assisted-by: ClaudeCode:claude-opus-4.8
np.asarray(h) now raises for sparse storage like .view() does, rather than silently densifying — implicit array conversion of a potentially huge sparse histogram is a memory hazard. The explicit copying accessors (values/variances/ counts) still densify on demand. Add a "Sparse storages" section to the storage user guide, including how to build a scipy.sparse.coo_array from to_coo() output. Assisted-by: ClaudeCode:claude-opus-4.8
- Add missing indices.shape(1) vs values.shape(0) validation in histogram_from_coo to prevent out-of-bounds reads. - Hoist std::vector allocation out of the map loop in histogram_to_coo. - Add tests for setitem raising on sparse and sparse+histogram addition. Assisted-by: opencode:NRP/kimi
Regular._repr_args_ built the full edges array just to read the first and last edge; for a very large axis (e.g. the sparse-storage repr test with 1e9 bins) this allocates gigabytes, OOM-killing PyPy CI and overflowing the max array size on 32-bit Windows. Use value() for the endpoints instead, which is O(1). Also pass the coo arrays to histogram_from_coo by const reference to satisfy clang-tidy's performance-unnecessary-value-param check. Assisted-by: ClaudeCode:claude-opus-4.8
Route scalar * and / on sparse storage through the COO get/set path so they scale only the filled cells (0*x == 0 preserves sparsity) instead of densifying via the NumPy view. Scalar +/- are refused with a clear error since they would fill every cell. Document which operations stay sparse, densify, or raise, and note mixed int+slice indexing as a follow-up. Assisted-by: ClaudeCode:claude-opus-4.8
|
Review from Claude Fable 5: 🤖 AI text below 🤖 I have everything I need — here's the review of PR #1139: feat: add DoubleSparse sparse storage backend (draft, OverviewAdds a What's good
Issues1.
I'd treat this as blocking for un-drafting: the feature's whole pitch is huge axis spaces, and the default REPL display hangs on them. 2. UHI payload masquerades as 3. Concurrent fills can corrupt the map under free-threading. Minor notes
VerdictArchitecture, serialization, and test coverage are in good shape. The blocker is finding #1: hidden O(dense-size) iteration in |
The storage iterator of a map-backed histogram visits every logical cell, so bh::algorithm::sum/empty and histogram::operator== were O(dense size) -- repr (which calls sum twice) on the documented 10**15-cell example would never return. Dispatch sum, empty, and ==/!= through overloads that walk only the hash map for sparse storage, treating explicit zeros (left behind by -=) the same as absent cells. Assisted-by: ClaudeCode:claude-fable-5
|
🤖 Fable 5 text below 🤖 Done — committed as About the hang you hit: it wasn't the fix itself — What the fix does:
Verification:
|
… 32-bit Replace manual early-return loops in histogram_empty and histogram_equal with std::all_of to satisfy the readability-use-anyofallof clang-tidy rule. Skip test_huge_axis_ops_iterate_filled_cells_only on 32-bit Python: a 100000^3 histogram has ~10^15 cells, overflowing 32-bit std::size_t linear keys, so is_inner_cell misclassifies filled cells on those platforms. Assisted-by: ClaudeCode:claude-sonnet-4-6
🤖 AI text below 🤖
What
Adds a sparse
DoubleSparsestorage backend, backed byboost::histogram::storage_adaptor<std::unordered_map<std::size_t, double>>.Only filled cells are allocated, so histograms over very large or
high-dimensional axis spaces (where a dense grid would not fit in memory)
become feasible. Based on the WIP at
pfackeldey/boost-histogram@sparse_hists.Scope:
doubleonly, for nowAccumulator-backed sparse storages (
Weight,Mean, …) needboostorg/histogram#421, which
ships in Boost 1.92. This repo vendors Boost 1.90, and a
double-backedsparse storage uses a plain arithmetic value type unaffected by that bug, so it
works today. The siblings can be added after a Boost bump.
Data access — COO-first, not a full drop-in
A hash map has no contiguous buffer, so:
.view()andnp.asarray(h)raiseTypeError— there is no buffer toview into, and implicit array conversion of a potentially huge sparse
histogram would be a silent memory hazard.
Histogram.to_coo(flow=False)returns the filled cells as(indices, values), whereindicesis a tuple of per-axis index arrays(numpy-
nonzerostyle, usable directly as a fancy index).flow=Truereturnsthe with-flow grid (underflow at 0).
values()/variances()/counts()densify into a fresh array, so they keep working (densifying a huge grid is
then the caller's deliberate choice).
at()reads, slicing, projection,sum, and histogram+histogram additionstay sparse.
__setitem__and scalarh * 2raise (they need a live buffer).Reading the filled cells / SciPy
For a 2D histogram,
to_coo()maps directly onto ascipy.sparse.coo_array:Serialization
save/loadoverloads fordouble_sparse(logical size + COO key/value arrays).to_uhi/from_uhistore the filled cells as a"sparse"COOpayload plus
writer_info storage_type="DoubleSparse", reconstructed with aC++
_from_coobulk setter. The sparse encoding is boost-histogram specific.Implementation notes
storage_adaptorpublicly inheritsmap_impl : unordered_map, sostatic_cast<unordered_map&>(unsafe_access::storage(h))iterates only thefilled cells (no densification) for COO extraction and pickling.
is_sparse_storagetrait + tag-dispatchedregister_sparse_coo.Docs & tests
coo_arrayexample above.tests/test_sparse_storage.py: fill/read, stays-sparse underslice/project,
to_coowith/without flow,.view()/np.asarrayraise whilethe copying accessors densify and match a dense
Doublehistogram, repr/strwithout densifying, copy/pickle, and UHI round-trips (incl. JSON, empty,
structure-only). Full suite green;
prek(ruff/mypy/clang-format/codespell)clean.