Skip to content

Fix/dtype narrowing - #29

Merged
aarmey merged 6 commits into
mainfrom
fix/dtype-narrowing
Sep 4, 2026
Merged

Fix/dtype narrowing#29
aarmey merged 6 commits into
mainfrom
fix/dtype-narrowing

Conversation

@fishidaho

Copy link
Copy Markdown
Contributor

Size index arrays by the axis they address, not the array beside them

What's wrong

indices is the only nnz-sized array in the VCS layout, and nothing ever picked its dtype. Instead, every path just inherited whatever the input carried:

  • _construct.compress (src/vsparse/_construct.py) copied minor_indices.dtype straight into its output buffer.
  • write_ivcs_elem (src/vsparse/_io.py) recorded np.dtype(v.indices.dtype).name, so a wide in-memory array stayed wide on every future read.

scipy hands out int64 indices for any array with enough nonzeros, so a 33k-gene minor axis was routinely stored at 8 bytes per nonzero instead of 4. This was found to be the case in memory, on disk, and in every kernel that walks it.
_rapid_load._filter_and_compact had the same conflation in a sharper form. One idx_dtype, keyed off nnz, was applied to two arrays with completely different bounds:

idx_dtype=np.int64ifindices.shape[0] >np.iinfo(np.int32).maxelsenp.int32new_indptr=np.zeros(..., dtype=idx_dtype) # indexed by nnz: needs int64 at scaleout_indices=np.empty(nnz_filtered, dtype=idx_dtype) # gene indices: never does

What this changes

Adds _indexutils.smallest_index_dtype(n) as the single place the rule lives and applies it at each point an index array gets sized:

SiteBound it's keyed off
_construct.compress (new n_minor arg)minor-axis length
_VCSBase.__init__minor-axis length
write_ivcs_elemminor-axis length
_filter_and_compactnew_indptrtotal nnz
_filter_and_compactout_indiceskept-gene count
transpose_major(already did this inline; now uses the helper)

Verification

tests/test_index_dtypes.py (new, 58 cases across the shared shape fixtures):

  • the boundary rule itself, exactly at INT32_MAX and one past it;
  • from_scipy on an int64-indexed input produces int32 indices, and indices.nbytes == 4 * nnz;
  • a minor axis genuinely larger than INT32_MAX still gets int64 (tiny nnz against a huge shape, so the test stays cheap);
  • out-of-bounds indices still raise instead of wrapping;
  • vcsc/ivcsc write→read round trips preserve values and shape while coming back narrow;
  • the packed writer re-derives the dtype for an array that was handed to it holding int64;
  • _filter_and_compact picks int32 for both when both fit, and keeps gene indices at int32 when the nnz-keyed half is forced to int64 — the regression case, since allocating a >INT32_MAX-nonzero matrix isn't testable directly.

Full suite: 819 passed, 37 skipped. ruff check src tests clean; ty check
introduces no new diagnostics (the one pre-existing no-matching-overload
on the read path is untouched).

Before/after on the headline path:

OLD from_scipy indices dtype: int64 -> 8 bytes/nonzero
NEW from_scipy indices dtype: int32 -> 4 bytes/nonzero

fishidahoand others added 3 commits September 2, 2026 16:48
`indices` is the only nnz-sized array in the VCS layout, and nothing ever
chose its dtype: `_construct.compress` copied whatever dtype the input
scipy array carried, and `write_ivcs_elem` recorded the same. scipy hands
out int64 indices for any array with enough nonzeros, so a 33k-gene minor
axis was routinely stored -- in memory, on disk, and in every kernel that
walks it -- at 8 bytes per nonzero instead of 4.
`_rapid_load._filter_and_compact` had the same conflation in a sharper
form: one `idx_dtype`, keyed off nnz, applied to both `new_indptr` (indexed
by nonzero count, genuinely needs int64 at scale) and `out_indices` (gene
indices, bounded by the gene count). Crossing INT32_MAX nonzeros silently
doubled the largest allocation in the function for no reason.
Adds `_indexutils.smallest_index_dtype(n)` as the single place that rule
lives, and applies it at each point an index array is sized:
- `_construct.compress` takes `n_minor` and narrows up front, so the wide
buffer is never allocated rather than allocated and then shrunk.
- `_VCSBase.__init__` narrows as the construction choke point -- after the
bounds check, so an out-of-range index is still rejected rather than
truncated, and a no-op (no copy) when the dtype is already right.
- `write_ivcs_elem` re-derives the dtype a reader will rebuild `indices`
as, instead of trusting the array it was handed.
- `_filter_and_compact` keys its pointer and column-index dtypes off nnz
and the kept-gene count separately.
- `transpose_major` now uses the shared helper for the rule it already
applied inline.
Narrowing never widens: indices already stored in something smaller than
int32 are left alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#22/#23 added astype, native minor-axis selection, and elementwise
add/sub/multiply (which rebuild through scipy). Every one of them
constructs its result with `type(self)(...)`, so all of them inherit the
narrowing in `_VCSBase.__init__` for free -- no per-method change was
needed, which is the payoff for putting the rule at the construction choke
point rather than in `from_scipy`.
Verified rather than assumed: an int64-indexed input stays int32 through
astype, `v[:, cols]`, both-axes selection, scalar mul/div/neg, add/sub
against another VCS array, copy, log1p, `_transpose_major` and `T`. The
scipy round-trip in the elementwise path is the one that could plausibly
have handed back int64, so it gets its own case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fishidaho
fishidaho marked this pull request as draft September 3, 2026 21:14
Comments now state what the code does rather than the reasoning behind it.
Index-dtype tests drop the cases the code trivially guarantees, keeping the
boundary, the truncation risks, the roundtrips, and the paths that build a
new array.
@fishidaho
fishidaho marked this pull request as ready for review September 4, 2026 00:23
@fishidahofishidaho self-assigned this Sep 4, 2026
@fishidaho

Copy link
Copy Markdown
ContributorAuthor

All of the active PRs right now are essentially load-bearing for all of the issues I have open right now. I think merging these in prior to an initial v0.1.0 PyPi release would be ideal.

None of them actually change the existing API; however, the future issues will likely touch the API or introduce new API function.

@aarmey
aarmey merged commit c939797 into mainSep 4, 2026
5 checks passed
@aarmey
aarmey deleted the fix/dtype-narrowing branch September 4, 2026 01:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@fishidaho@aarmey