This issue follows up from #987 (and complements #9586), where the question was raised about what it means to be an AbstractArray. My understanding of where this issue left off was that AbstractArray{T,N} has the following interface:
size is defined and returns a tuple of length N,getindex is defined and returns data of type T "cheaply", where cheaply means "in O(1) time preferably but we will allow some small growth bounded very loosely above by O(length(n)) to accommodate sparse matrices".This interface is incompletely defined because Julia arrays support nontrivial indexing semantics and not all of which have the same computational complexity.
This issue is about spelling out the methods supported by AbstractArrays, and in particular:
AbstractArrays must support is, andJulia arrays support several indexing semantics:
:, UnitRange or Range (slice indexing)For arrays of rank N>1 we have to further distinguish between indexing with a
single entity (linear indexing) vs indexing with N entities (Cartesian
indexing). So this gives us a matrix of 4 x 2 = 8 different indexing behaviors.
@andreasnoack @jakebolewski and I sat down this afternoon to work out the complexity of indexing semantics of various kinds of arrays. The goal of these calculations is to help clarify the importance of complexity bounds on the interface of AbstractArrays.
We considered the following types of arrays:
StridedArray, which is the union of SubArray andDenseArray (which in turn includes ordinary Array).M distinct chunks of contiguous memory.M>1. For M=1 chunk per dimension,Vector{T}, Vector{Vector{T}}, Vector{Vector{Vector{T}}},... Also, DArrays withAbstractArrays.SparseMatrixCSC.Where necessary the analysis was simplified by assuming a uniform prior distribution of matrix elements in each column and each row.
Let
N be the rank of an array (i.e., the number of dimensions, or the length of the index tuple associated with a matrix element),M be the number of chunks in a single ragged array dimension,k be the length of a Range, Colon or iterable (= k1 * k2 * ... * kN inN of these entities),ncol be the number of columns,NNZ be the number of stored elements.Considering only linear indexing and Cartesian indexing using the same type of
entity in each dimension, we can write the computational complexity of each
indexing behavior as
| | linear | Cartesian |
| --- | --- | --- |
| ordinary | strided O(N) | strided O(N) |
| | ragged sorted O(N log M) | ragged sorted O(N log M) |
| | ragged unsorted O(N M) | ragged unsorted O(N M) |
| | CSC ~O(log(NNZ/ncol)) | CSC ~O(log (NNZ/ncol)) |
| --------- | ------------------------ | ------------------------- |
| slicing | strided O(N) | strided O(N) |
| (view) | ragged sorted O(min(M, k) N log M) | ragged sorted O(min(M, k) N log M) |
| | ragged unsorted ~O(k M^(N-1)) | ragged unsorted ~O(k M^(N-1)) |
| | CSC ~O(k1 log(NNZ/ncol)) | CSC ~O(k1 log(NNZ/ncol)) |
| --------- | ------------------------------ | ------------------------- |
| slicing | strided O(k) | strided O(k) |
| (copy) | ragged sorted ~O(M^N) | ragged sorted ~O(M^N) |
| | ragged unsorted ~O(k M^(N-1)) | ragged unsorted ~O(k M^(N-1)) |
| | CSC ~O(k ncol/NNZ log(NNZ/ncol)) | CSC ~O(k ncol/NNZ log(NNZ/ncol)) |
| --------- | ------------------------------ | ------------------------- |
| iterable | strided O(k N) | strided O(k N) |
| | ragged sorted O(k N log M) | ragged sorted O(k N log M) |
| | ragged unsorted O(k N M) | ragged unsorted O(N M) |
| | CSC ~O(k log(NNZ/ncol)) | CSC ~O(k log (NNZ/ncol)) |
| --------- | ------------------------------ | ------------------------- |
| logical | strided O(NNZ) | strided O(NNZ) |
| | ragged sorted O(NNZ N) | ragged sorted O(NNZ N) |
| | ragged unsorted O(NNZ N M) | ragged unsorted O(NNZ N M) |
| | CSC O(NNZ log(NNZ/ncol)) | CSC O(NNZ log(NNZ/ncol)) |
In no case did I find that the cost of the Cartesian to linear offset computation dominates the complexity.
Note that we have different results depending on whether slicing returns
Strided array
Ragged sorted array
Ragged unsorted array (indexes for each chunk are not sorted)
CSC
Cartesian indexing behaviors mixing different indexing types in each dimension
can get more complicated. CSC is an interesting one to look at:
A[:,5] returning a view costs O(1) since the answer is given immediately byA[:,5] returning a copy costs ~O(NNZ/ncol), the expected number of entries inA[5,:] returning a view costs O(ncol log(NNZ/ncol)), since you have to do aA[5,:] returning a copy costs O(NNZ log(NNZ/ncol)), since you have to computeNNZ/ncol of them.Ordinary indexing means looking up the column pointer of the current and next rows
Ordinary indexing means looking up the column pointer of the current and next rows columns
Bless you for opening this, Jiahao.
By "rank" do you mean dimensionality? "rank" has a different meaning in linear algebra, of course, and that meaning of rank does not influence the cost of indexing.
Updated with clarifications and corrections.
@timholy it is quite unfortunate that "rank of an array" means something very different from "rank of a matrix", and that both meanings are quite widely used. It is rather unpretty. One might even say it is a rank situation.
Yes, thank you for posting that link!
Here's a very quick summary of the current state of the API proposal in the gist:
indices(A) - Returns an iterator that generates objects representing the canonical indices of A.order(A) - Gives a total order (isless) predicate on index objects, describing the order in which indices(A) generates them.axes(A) - Returns a container that maps dimension labels to objects describing possible values of components of indices. (note ndims(A) == length(axes(A))). The same dimension labels are accepted by functions like reducedims.bounds(A) which returns basically map(extrema, axes(A)), but possibly using some kind of interval type.A[I] should be implemented, where I is a single index object.ArrayType(IndexSet, Data) should exist.Arrays can have the ability to return multiple index iterators. For example SparseMatrixCSC should be able to return an iterator over the indices of its nonzeros. Or you might want to return an iterator that provides faster access.
It's possible that dimensions need to be ordered, such that 1:N are always valid dimension labels. We might also want to mandate that "index objects" be tuples, providing compatibility that allows code like A[indices(B)] = 0.
It would be great with some thoughts on distributed arrays.
Here's one dirt-simple thought, that's possible to implement now: the local chunks should have indices that correspond to their "piece" of the whole array. That seems like it might make a number of things easier (fewer index gymastics to worry about).
That does sound compelling. I also have a half-baked idea that the outer container managing the distribution could somehow be an array mapping bounding boxes to chunks with the data, e.g. for easily identifying which chunks are involved in a computation.
This is exactly the idea promoted in ZPL ("regions") and Chapel ("distributions" (index set mapping global index view to individual chunks), and "layouts" (local index view of underlying buffer, aka Jeff's proposal). Lots of previous work to draw on here.
PETSc also has the notions of local to global mapping index sets, views of local data, and fast access to local data (not quite iteration, but pretty close considering it is written in C), and it has worked out really well for them in terms of usability.
There are also Fortran co-arrays. Aside, those use two sets of parenthesis for local and global indexing. Maybe something to consider for {}.
Not sure what's actually the action item here, but @mbauman, I'll leave that up to you.
This issue is a bit vague for a 1.0 milestone issue at this point. Let's discuss on the call next week.
Too vague to be actionable for 1.0; everything actionable is traits stuff which can be done in 1.x.
Is this still an open issue? My understanding is that the AbstractArray interface is pretty well-defined now, even if not in the manner proposed here (traits).