Repository navigation
RFC: In-memory sparse array interchange #840
Description
Motivation
The sparse (also called PyData/Sparse) development team have been working on integration efforts with the ecosystem, most notably with SciPy, scikit-learn and others, with CuPy, PyTorch, JAX and TensorFlow also on the radar. One of the challenges we were facing was the lack of (possibly zero-copy) interchange between the different sparse array implementations. We believe this may be a pain point for many sparse array implementations moving forward.
This mirrors an issue seen for dense arrays previously, where the DLPack protocol was the one of the first things to be standardised. We're hoping to achieve community consensus for a similar problem.
Luckily, all sparse array formats (with the possible exception of DOK) are usually collections of dense arrays underneath. In addition, this problem has been solved for on-disk arrays before by the binsparse specification. @willow-ahrens is a co-author of that spec, and is also a collaborator for the sparse work.
Proposal
We propose introducing two new methods to the array-API compliant sparse array objects (such as those in sparse), which are described below.
__binsparse_descriptor__
The returned item is a dict equivalent to a parsed JSON binsparse descriptor of an array.
__binsparse__
The second item is a dict[str, Array] of __dlpack__ compatible arrays, which are the constituent arrays of the sparse array. The key represents the equivalent key in the descriptor.
Introduction of from_binsparse function.
If a library supports sparse arrays, its from_binsparse method should support accepting (when possible, zero-copy) versions of objects that follow this __binsparse__ protocol, and have an equivalent sparse format within the library.
Psuedocode implementation
Here's a psuedocode example using two libraries, xp1 and xp2, both supporting sparse arrays:
# In library code:
xp2_sparray = xp2.from_binsparse(xp1_sparray, ...)
# This psuedocode impl is common between `xp1` and `xp2`
def from_binsparse(x: object, /, *, device: device | None = None, copy: bool | None = None) -> array:
binsparse_descr = getattr(x, "__binsparse_descriptor__", None)
binsparse_impl = getattr(x, "__binsparse__", None)
if binsparse_impl is None or binsparse_descr is None:
raise TypeError(...)
binsparse_descriptor = binsparse_descr()
# Will raise an error if the format/descriptor is unsupported.
sparse_type = _type_from_binsparse_descriptor(binsparse_descriptor)
constituent_arrays = binsparse_impl()
my_constituent_arrays = {
k: from_dlpack(arr, device=device, copy=copy) for k, arr in constituent_arrays.items()
}
return sparse_type.from_strided_arrays(my_constituent_arrays, shape=...)Parallel implementation in sparse: pydata/sparse#764
Parallel implementation in SciPy: scipy/scipy#22553
Alternative solutions
There are formats for on-disk sparse-array interchange [1] [2]; but none for in-memory interchange. binsparse is the one that comes closest to offering in-memory interchange.
Pinging possibly interested parties:
- @mtsokol @ivirshup (for
scipy.sparse) - @willow-ahrens @mtsokol (from
binsparseandfinch-tensor/sparse) - @leofang (for
cupyx.sparse) - @pearu (for
torch.sparse) - @jakevdp (for JAX/TensorFlow)
Updated on 2024.10.09 as agreed in #840 (comment).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsStage 1
Activity
willow-ahrens commentedon Sep 16, 2024
I'd like to additionally CC the binsparse contributors so they're aware of this effort.
@BenBrock
@eriknw
@ivirshup
@jim22k
@DrTimothyAldenDavis
kgryte commentedon Sep 16, 2024
@hameerabbasi Are there any other alternative sparse interchange protocols that we should be considering, or is
binsparsethe only game in town? Given that this RFC proposes standardizing a particular approach, I'd be interested in at least covering our bases and having an understanding of other prior art.hameerabbasi commentedon Sep 16, 2024
There are other on-disk/text formats: 1 2
Both of these are not designed for in-memory interchange of data -- and have no support for multiple sparse formats. I'll add these to the issue.
willow-ahrens commentedon Sep 18, 2024
There's also the FROSTT format, which stores tensors in a minimal text encoding and has the same limitations of the above.
http://frostt.io/
rgommers commentedon Sep 19, 2024
The relevant issue on the
binsparserepo for an in-memory representation is GraphBLAS/binsparse-specification#4. That also mentions that Arrow has support for 2-D CSR/CSC/CSF/COO (no n-D, no chunking).There's of course a bit of a chicken-and-egg problem here: it's hard to standardize anything that doesn't exist yet, but library implementors want to move ahead and implement something that is likely to be standardized later to avoid fragmentation or bc-breaking changes.
So here is what I think what should happen:
__binsparse__sounds about right; whether more is needed a la__dlpack_device__is TBD I think).binsparsein-memory spec should be worked out on GraphBLAS/binsparse-specification#4.binsparsecould decide to support both though, in case there's a real-world need for Arrow support.pearu commentedon Sep 19, 2024
PyTorch has sparse formats that binsparse specifications does not specify as pre-defined formats. For instance, there are BSR/BSC (blocked CSR/CSC) formats, hybrid sparse formats (COO/CSR/CSC/BSR/BSC with values being strided tensors), sparse formats with batch dimensions (CSR/CSC/BSR/BSC with indices being multidimensional), and batch/hybrid sparse formats (combinations of hybrid and batched sparse formats).
So, for PyTorch, the usefulness of the in-memory sparse array interchange format based on binsparse specification will depend on how easy is to define these blocked/hybrid/batch sparse formats using binsparse custom format support, or better yet, on adding blocked/hybrid/batch sparse formats as pre-defined to binsparse specification.
pearu commentedon Sep 19, 2024
CC: @amjames (for torch.sparse)
hameerabbasi commentedon Sep 19, 2024
Since
__binsparse__is meant to be a mainly cross-library format, the usefulness is limited also by other implementations that support blocked/hybrid/batch formats. If PyTorch is the only implementation of these formats, then adding it to binsparse makes little sense, as there won't be another library to do the exchange with.Incidentally, I do agree with the notion of blocked formats being supported. They are supported in the MLIR
sparse_tensordialect, and in SciPy as well. The cleanest way to support these seems to be an index map: A mapping from the 4-D virtual indices to 2-D indices using the operators+,-,*,//,%only, although they aren't supported yet. This also leads to supporting the diagonal format. I'll raise an issue on thebinsparserepo and link it here.xref Binsparse/binsparse-specification#56
amjames commentedon Sep 23, 2024
I want to say that I don't think there is a huge amount of value in specifically naming and tabulating different pre-defined formats like this. It is vastly more important that the standard be able to support generic layout definitions which have these features: non-scalar values (hybrid), non-scalar index lookup(blocked), and various dense/sparse levels.
Looking at the
binsparsespec it seems pretty close with the ability to definecustom, and I have some comments to add on discussion there.I in general support the idea of a sparse interchange protocol based on some kind of standard like
binsparsehowever I do not think it is generic enough in its current form, that is a separate issue from the proposal here though.hameerabbasi commentedon Dec 3, 2024
I have two things to add here:
__binsparse__and__binsparse_format__should be separate methods; the reasoning behind this is exactly the same as__dlpack__and__dlpack_device__:a. One might need to query the format before getting the constituent arrays out, as getting the arrays out could be an expensive operation for some cases.
b. There are many parallels in design for needing to query the format (device) independent of the actual arrays in
__dlpack__, such as erroring out early and requesting a specific format.Please let me know what folks think of 2, especially @pearu who proposed the original move to a 1-method design.
DrTimothyAldenDavis commentedon Dec 3, 2024
This is an extremely important issue:
"One might need to query the format before getting the constituent arrays out, as getting the arrays out could be an expensive operation for some cases."
pearu commentedon Dec 3, 2024
Recall,
__dlpack__method returns a capsule that holds a structure that contains all the information about the array: shape, dtype, strides, pointer to storage, etc. This makes previous array interface protocols (__array_interface__,__cuda_array_interface__) that expose the array info explicitly, unnecessary.I'd consider
__binsparse_format__a similar protocol to__array_interface__that exposes various information about the sparse format: shape information (sparse and dense dimensions), dtype, format name, and format specific data that supports dlpack protocol.Assuming that
__binsparse__protocol implementation will be similar to__dlpack__protocol implementations, that is,__binsparse__method returns a capsule that holds a structure that contains information about the sparse array. Hence,__binsparse_format__is unnecessary: the query about the sparse array format can be implemented as a query to the binsparse capsule object, similar to how https://github.com/pearu/pydlpack/ implements a query to dlpack capsule, without the need to access the storage of data.hameerabbasi commentedon Dec 3, 2024
Right, this assumes an O(1) conversion to a binsparse capsule (or equivalent) is always possible. However, this may not be the case: Libraries may support more formats than are supported by the binsparse protocol, and may need to perform an additional conversion.
pearu commentedon Dec 3, 2024
I am not sure how
__binsparse_format__will be helpful here. If a library implements a sparse format that binsparse protocol does not support, then the binsparse protocol will be useless anyway: binsparse protocol cannot implement conversion about a format that it does not support, any necessary conversion should be done by the provider library.If you'd disagree, please provide more details about the
__binsparse_format__specification as I may miss some points here.hameerabbasi commentedon Dec 3, 2024
I think you've hit the nail on the head with this part -- a conversion might be necessary, which may make the cost of constructing a capsule O(n), and therefore we're left with two options if we want to guarantee an O(1) conversion (both require a
.asformatwhich can take binsparse descriptors):.asformaton error.__binsparse_format__and then raise or call.asformatas necessary.pearu commentedon Dec 3, 2024
An exception with a helpful exception message is better than a silent expensive conversion, imho.
rgommers commentedon Dec 4, 2024
The two-method format seems clearly preferred, indeed for the same reason as
__dlpack_device__: allowing to introspect metadata for compatibility and "hand-shaking" (it allows the consumer to change its request when it supports multiple devices/formats/etc.).@pearu the analogy with
__array_interface__is not a good one. That method is numpy-specific, and the actual data is hence always already in memory and always in a single format (strided array on CPU). A__binsparse__call, just like a__dlpack__call, requires the data to be in memory in a supported format, so for (for example) lazily evaluated formats or custom formats not part of the protocol, a call to__binsparse__may indeed require an expensive operation (and no, always raising an exception instead is definitely not the correct answer, there are always needs to do the expensive thing if no cheap thing is possible).If anything, we've so far found a few corner cases where it would be helpful for more parts of the DLPack protocol to be introspectable separately from the actual "give me a capsule with a pointer to data in memory".
pearu commentedon Dec 4, 2024
Sparse tensors of any format can be modeled as a pair
(this is how we think about sparse tensors in PyTorch, for instance). Notice that
__binsparse__structure does not (need to) hold raw pointers to memory which indeed is the case of the__dlpack__structure. Hence,__binsparse__structure would be lazy by definition when using this mental model.Lazy access to sparse tensors is about accessing
shape-dtype-format specswithout accessing the data of indices and values. Notice that the__dlpack__capsule that holds pointers to raw memory is constructed when the__dlpack__method is called (this is the point where data needs to be materialized in memory, not earlier). So, lazy access to sparse tensors shape-dtype-format data is possible if one will not trigger the creation of__dlpack__capsules (read: materialize data in memory) earlier than necessary.In any case, the laziness property should be provided at the DLpack protocol level, not in binsparse protocol that just uses the dlpack protocol for accessing sparse tensor data.
rgommers commentedon Dec 4, 2024
This is the entire point of why two separate methods have to exist. Your answer agrees that there should be this separation, you're just moving the separation between accessing metadata and data to a place it doesn't exist (
__dlpack__). Coupling the introduction of a binsparse protocol to not-yet-proposed and probably infeasible changes to__dlpack__isn't a good idea. Separation of concerns is nice:)Please either go with the 2-method approach for binsparse itself instead, or propose a way of what a 1-method
__binsparse__should return to keep things lazy without any changes to DLPack.hameerabbasi commentedon Feb 19, 2025
I've modified the issue description to use the two-method version.
hameerabbasi commentedon Mar 6, 2025
Looping in @BenBrock.
Based on scipy/scipy#22553 (comment) We discussed in the bi-weekly Binsparse meeting yesterday where the spec should live -- It seems the most appropriate thing to do is (similar to DLPack) let the binsparse repo take care of the descriptor and the naming conventions of the arrays, and the array-api repo should specify how that descriptor is used in Python-land.
For context, all the binsparse spec requires from back-ends is:
dict[str, SupportsDLPack]in our case).So this seems to be a good separation of concerns.
rgommers commentedon Mar 8, 2025
That seems reasonable. I think a draft PR on this repo for review of the new methods in more detail would be useful. Based on feedback and working PRs to 2-3 array libraries that are all in sync, things can be tested and iterated on until both library and spec maintainers are happy, before merging anything.
hameerabbasi commentedon Mar 11, 2025
PR is up at #912.
hameerabbasi commentedon Apr 4, 2025
@rgommers Following the discussion of yesterday's meeting, one valid use-case for implementing
__binsparse__on dense arrays might be for an array library like PyTorch orfinch-tensor, which supports both dense and sparse arrays. In that case, it isn't clear thatasarrayshould densify: Indeed, it should keep the format as-is and attempt to use__dlpack__to consume the data. However, in this case,arr.__binsparse__(descriptor=some_format)(or, to a user,xp2.from_binsparse(xp1_arr, descriptor=some_format)) might be suitable to convert to a given sparse array format, or even to a dense one.Alternatively we can add
array.asformat(some_descriptor)on both sparse and dense arrays ifhasattr(xp, 'sparse')holds.I'm open to both options.
rgommers commentedon Apr 4, 2025
That rationale isn't logically consistent.
asarray(x_dense)will always use__dlpack__and it's irrelevant whether__binsparse__is present.Can you please start simple, with only binsparse on sparse arrays, and then have a very concrete code example of where/how things can go wrong?
As a reminder: we're only working on
__binsparse__andfrom_binsparseto work out any potential kinks and guiding libraries with sparse arrays on how to implement those two things in the same way. Once that is stable in multiple libraries and proven useful, we can add it to the standard. It's quite premature at this point to discuss a larger API surface.hasattr(xp, 'sparse')is also a misconception. We needhasattr(xp, 'from_binsparse')orhasattr(an_array, '__binsparse__').