Slicing sparse and dense matrices and allocations

Hi, I am optimizing some old non-performance critical code of mine and run into a lot of allocations when using slicing operations with sparse and dense matrices.

I distilled it to this MWE

# -----------------------------------
# Initialize everything. This comes from somewhere else in the actual code.
using SparseArrays
p = 6; n = 14; Tf = 248;Tfn = n*Tf;
AzeroAp = ones(n,n*(p+1));      # collects [A0 -A1 -A2 ... -Ap]
H_Int = zeros(Int,Tfn,Tfn)              # initializes the H matrix to get the linear indices

# fill the block-diagonals and off-diagonals with p+1 (nxn) matrices
for ij = 0:p
    for ii = 1:div(Tfn,n)-ij
        H_Int[ ij*n + (ii-1)*n + 1 : n + (ii-1)*n +  ij*n, (ii-1)*n + 1 : n + (ii-1)*n] = ones(Int,n,n)
    end
end
H_sp = sparse(H_Int)               # set everything else to 0 (density around 2%, sparsity 98%)
AzeroAp_LinInd = LinearIndices(AzeroAp) # get the linear indices of  [A0 -A1 -A2 ... -Ap]

# ----------------------------------
# THIS IS THE CRITICAL PART
@time for ij = 0:p               # loop over the block-diagonal and off diagonals
    for ii = 1:div(Tfn,n)-ij
        H_sp[ ij*n + (ii-1)*n + 1 : n + (ii-1)*n +  ij*n, (ii-1)*n + 1 : n + (ii-1)*n] = AzeroAp_LinInd[ :, 1 + (ij-0)*n : n + (ij-0)*n]
    end
end

What the code does

I am creating the the following big block-banded matrix[1]:

\begin{equation*} \mathbf{H}=\left[\begin{array}{cccccccc} \mathbf{A_0} & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & \cdots & \cdots & \cdots & \cdots & \cdots & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} \\ -\mathbf{A}_1 & \mathbf{A_0} & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & \cdots & \cdots & \cdots & \cdots & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} \\ -\mathbf{A}_2 & -\mathbf{A}_1 & \mathbf{A_0} & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & \cdots & & & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} \\ \vdots & \ddots & \ddots & \ddots & \ddots & \ddots & & \vdots \\ -\mathbf{A}_p & \cdots & & -\mathbf{A}_1 & \mathbf{A_0} & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & & \vdots \\ \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & & & & \ddots & \ddots & \ddots & \vdots \\ \vdots & & \ddots & & \ddots & \ddots & \ddots & \vdots \\ \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & \cdots & \mathbf{0}_{\boldsymbol{n} \times \boldsymbol{n}} & -\mathbf{A}_p & \cdots & -\mathbf{A}_2 & -\mathbf{A}_1 &\mathbf{A_0} \end{array}\right] \end{equation*}

It has \mathbf{A}_0 on the main block diagonal, \mathbf{A}_1 on the diagonal below that and so on until \mathbf{A}_p. p is always a small number, max is 6, n is also less than 20, while we have Tf block-rows, where Tf could be 200, so total Tf*n \times Tf*n.

In the code above, the variable AzeroAp := \left[ \mathbf{A}_0, -\mathbf{A}_1, \dots, -\mathbf{A}_p \right].

The objective of the code is to map the linear indices of AzeroAp to the corresponding linear indices of these matrices in \mathbf{H}.[2]. For a small example with p=1 and n=2

\mathbf{H_{Int}}=\begin{bmatrix} 1&5&9&13\\ 2&6&10&14\\ 3&7&11&15\\ 4&8&12&16\\ \end{bmatrix}

The indices corresponding to \mathbf{A_0} are

\begin{bmatrix} 1&5\\ 2&6\\ \end{bmatrix}, \text{and}\begin{bmatrix} 11&15\\ 12&16\\ \end{bmatrix}

or in vector form [1, 2, 5, 6, 11, 12, 15,16]', while the indices corresponding to \mathbf{A_1} are [3, 4, 7, 8]' in vector form. So if I call H[3,4,7,8] = AzeroAp[5, 6, 7, 8] I will update the Hmatrix. The last loop essentially gives me these linear indices for that updating.

The question

When we come to the section with the crucial part, where I map the indices, this allocates like hell. For the above example it uses 8GB of memory.

julia> @time for ij = 0:p
           for ii = 1:div(Tfn,n)-ij
               H_sp[ ij*n + (ii-1)*n + 1 : n + (ii-1)*n +  ij*n, (ii-1)*n + 1 : n + (ii-1)*n] = AzeroAp_LinInd[ :, 1 + (ij-0)*n : n + (ij-0)*n]
           end
       end
  6.088194 seconds (51.16 k allocations: 8.645 GiB, 38.78% gc time)

Before coming here, as it is normal nowadays, I asked AI and the explanation was that slicing sparse and dense matrices allocates and that I should use setindex [3]. Then, the AI suggested this β€œfix”

for ij = 0:p
    for ii = 1:div(Tfn, n) - ij
        row_start = ij * n + (ii - 1) * n + 1
        row_end = n + (ii - 1) * n + ij * n
        col_start = (ii - 1) * n + 1
        col_end = n + (ii - 1) * n

        # Directly update the sparse matrix
        for i = row_start:row_end
            for j = col_start:col_end
                H_sp[i, j] = AzeroAp_LinInd[i - row_start + 1, j - col_start + 1]
            end
        end
    end
end

It effing works and I don’t understand why. What am I doing wrong? Why is it that when it is split across rows and columns works?

julia>  0.108702 seconds (1.35 M allocations: 36.757 MiB)

TLDR: why does the last for loop in the MWE allocate a lot, I thought I was just looking into the matrices and mappign their values from the AzeroAp to H_sp


Additional info for the curious

reveal

While this is unncessary for the question at hand you might be wondering what the hell am I doing.
The main problem is that in every iteration (say from 20000), I get new \mathbf{A} matrices, so I need to update the \mathbf{H} matrix with the new [\mathbf{A_0} \dots \mathbf{A_p}]. So I thought its easiest to save the linear indices, so that I can do

H_sp[Lind] = AzeroAp[Rind]

So before the main loop I get the linear indices vectors, Lind and Rind, respectively, once with the above code. In that sense it is not performance critical, because it is only ran once but still I thought I am doing something wrong with that many allocations. Other than that the code and the updating work very well.

Maybe there is a much easier way generate Lindand Rind but I didn’t know how and that is how I came up with the above solution. I also tried all the block-banded packages, as well as the .nzval property of sparse matrices but none worked in the way I wanted them to.


  1. Yes, I am aware and did all try the BlockBanded and sparse BlockBanded packages in the Julia ecosystem β†©οΈŽ

  2. I will discuss this design choice later as to not make the exposition too large, but there are definitely other ways to achieve the same result. β†©οΈŽ

  3. honestly this is what I thought I was doing β†©οΈŽ

EDIT: misdiagonosed the issue because this has to do with SparseArrays. See below comments. That said, I would still make the recommendation of finding/making a custom array type based on dense blocks in a Toeplitz structure.

The issue is that slicing in Julia (except as the target of an assignment) allocates a copy of the region. This can be avoided by using the view function (instead of getindex, which is used by the A[i, j] syntax) or using the @view or @views macro to make this substitution for you.

Replacing

H_sp[#= ... =#] = AzeroAp_LinInd[ :, 1 + (ij-0)*n : n + (ij-0)*n]
# with
H_sp[#= ... =#] = @view AzeroAp_LinInd[ :, 1 + (ij-0)*n : n + (ij-0)*n]

should remove those allocations.


Now a couple of thoughts:

This β€œblock-banded” matrix is also sometimes called a block Toeplitz matrix. Unfortunuately, it looks like ToeplitzMatrices.jl only handles non-block Toeplitz matrices. If you make the individual blocks using StaticArrays.jl it might work but I’m guessing not.

Regardless, you might benefit from implementing a type for this structure yourself (maybe BlockArrays.jl can help a little? maybe not). A Toeplitz matrix can be efficiently multiplied (or inverted, among a few other operations) by embedding it in a circulant matrix and using the fast Fourier transform, but that’s a touch more complicated in the block case unless the blocks are also Toeplitz.

No, that’s not the culprit here. Using @view, putting everything in a function, and using @btime still yields 8GB of allocations.

The basic problem is that setindex! for sparse matrices is very hard to implement efficiently, especially if there is a possibility that you may be inserting new nonzero entries. If you want to mutate existing nonzero values in a sparse matrix it’s vastly more efficient to do so on the column-sorted nonzeros(matrix) array instead if you can, although this requires more care with indexing.

In particular, the sparse-matrix setindex! with a vector of indices allocates because it assumes you might be inserting new non nonzeros: SparseArrays.jl/src/sparsematrix.jl at 7abbefaedb456d0b24a94873c01a345780fe07f1 Β· JuliaSparse/SparseArrays.jl Β· GitHub

It could be made much more efficient if setindex! knew that the indices are sorted and no new nonzeros would be inserted. (Not sure what the API would look like?)

Thanks to you both.

I also thought that viewing would help and tried that, but it didn’t help and I didn’t include it in my post.

I did try block arrays, and the block banded suites and ended up on this solution as the best.

I should be honest and say that my linear algebra is not up to par, I should finally read about toeplitz structures. Few years ago I was coding this in Matlab and asked in MatlabCentral what is the best way to do this and a person gave me an amazing almost oneliner based on a toeplitz structure that I use to this day.

Matlab code

Link to the discussion on Matlab central

function [L,R]=makeIndices(n,p,T)
  
Icell=mat2cell(reshape(1:n^2*p,n,[]), n,n*ones(1,p) );
Icell=[zeros(n), Icell, zeros(n)];
idx=toeplitz(min(p+2,1:T), [1,repelem(p+2,T-1)]  );
 Q = cell2mat(Icell(idx));
 L=logical(Q);
 R=nonzeros(Q);
end

I didn’t manage to translate this to Julia so I wrote the above implementation.

What I am invested in the above is, why does the AI code solve the problem? How does it fix the issue with the setindex? Is it to do that it goes columnwise? Also, I have to say that I am not touching any non-zeros.

Thanks to you both.

I also thought that viewing would help and tried that, but it didn’t help and I didn’t include it in my post.

I did try block arrays, and the block banded suites and ended up on this solution as the best.

I should be honest and say that my linear algebra is not up to par, I should finally read about toeplitz structures. Few years ago I was coding this in Matlab and asked in MatlabCentral what is the best way to do this and a person gave me an amazing almost oneliner based on a toeplitz structure that I use to this day.

Matlab code

Link to the discussion on Matlab central

function [L,R]=makeIndices(n,p,T)
  
Icell=mat2cell(reshape(1:n^2*p,n,[]), n,n*ones(1,p) );
Icell=[zeros(n), Icell, zeros(n)];
idx=toeplitz(min(p+2,1:T), [1,repelem(p+2,T-1)]  );
 Q = cell2mat(Icell(idx));
 L=logical(Q);
 R=nonzeros(Q);
end

I didn’t manage to translate this to Julia so I wrote the above implementation.

What I am interested in the above is, why does the AI code solve the problem? How does it fix the issue with the setindex? Is it to do that it goes columnwise? Also, I have to say that I am not touching any non-zeros.

Edit:

It could be made much more efficient if setindex! knew that the indices are sorted and no new nonzeros would be inserted. (Not sure what the API would look like?)

I found it extremely fast to access the indices with the .nzval property, but couldn’t figure out how to map that to my much much smaller matrix and the fact that the more I get near the bottom row, the more I lose blocks from my AzeroAp matrix. In the last block-row I only have \mathbf{A_0} and not the whole column-block AzeroAp'. Maybe if it were possible for the sparse matrix to give me the indices of the values behind nzval by dropping the zeros? I don’t know if this makes sense or if I am conveying my idea properly.

I think the allocation difference between your code the AI code is that the latter doesn’t actually use slicing. Instead of H_sp[ ij*n + (ii-1)*n + 1 : n + (ii-1)*n + ij*n, (ii-1)*n + 1 : n + (ii-1)*n] which creates a copy of this subset, the AI updates the matrix one element at a time with H_sp[i, j] which does not create a new copy.

Hm, somehow I didn’t get that, I thought it was a slice but you are correct.

Depending on what your doing with them, creating you own datatype may be very helpful. I threw this together with the help of an LLM:

At the very least it makes updating the blocks a lot easier.

Thanks, l will try this!

I am using it to multiply it with some matrices and then to take a cholesky decomposition of this product. In the end the H matrix becomes a variance covariance matrix of a normal distirbuiton from which I sample.

When using sparse matrices with CSC format, it is very useful to pay attention to the internals to achieve maximum efficiency.

If I understand correctly, the H matrix (the blocky one) is comprised from the matrices which are in Ap.

Looking at the columns of H, they are currently populated by values which span subranges of rows of Ap - in column oriented Julia, it’s better to use column-first operations, so I would keep and update the transpose of Ap i.e. a column of the A_i matrices (the A_i needn’t be transposed).

With this change, the updating of H can be done with minimal allocations as the following code suggests:

using SparseArrays

# smaller version to make correctness clearer
const p = 3; const n = 2; const Tf = 6; const Tfn = Tf * n

# the initialization as suggested in the OP
AzeroAp = ones(n,n*(p+1));      # collects [A0 -A1 -A2 ... -Ap]
H_Int = zeros(Int,Tfn,Tfn)              # initializes the H matrix to get the linear indices
# fill the block-diagonals and off-diagonals with p+1 (nxn) matrices
for ij = 0:p
    for ii = 1:div(Tfn,n)-ij
        H_Int[ ij*n + (ii-1)*n + 1 : n + (ii-1)*n +  ij*n, (ii-1)*n + 1 : n + (ii-1)*n] = ones(Int,n,n)
    end
end
H_sp = sparse(H_Int)
AzeroAp_LinInd = LinearIndices(AzeroAp) # get the linear indices of  [A0 -A1 -A2 ... -Ap]

# now fix things as suggested in this post:
ApT = collect(1.0*transpose(AzeroAp_LinInd))  # transposed ApT matrix
HH = H_sp * 1.0     # sparse H matrix of values (no indices)

# define CSC savvy update function
function updateHwithAp(H, Ap)
    cc = 0
    for bc in 1:Tf
        for c in 1:n
            cc += 1
            nonzeros(H)[nzrange(H,cc)] .= @view Ap[1:min(p+1,Tf-bc+1)*n,c]
        end
    end
end

and now for a test:

julia> updateHwithAp(HH, ApT)

julia> HH
12Γ—12 SparseMatrixCSC{Float64, Int64} with 72 stored entries:
  1.0   2.0    β‹…     β‹…     β‹…     β‹…     β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
  3.0   4.0    β‹…     β‹…     β‹…     β‹…     β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
  5.0   6.0   1.0   2.0    β‹…     β‹…     β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
  7.0   8.0   3.0   4.0    β‹…     β‹…     β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
  9.0  10.0   5.0   6.0   1.0   2.0    β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
 11.0  12.0   7.0   8.0   3.0   4.0    β‹…     β‹…    β‹…    β‹…    β‹…    β‹… 
 13.0  14.0   9.0  10.0   5.0   6.0   1.0   2.0   β‹…    β‹…    β‹…    β‹… 
 15.0  16.0  11.0  12.0   7.0   8.0   3.0   4.0   β‹…    β‹…    β‹…    β‹… 
   β‹…     β‹…   13.0  14.0   9.0  10.0   5.0   6.0  1.0  2.0   β‹…    β‹… 
   β‹…     β‹…   15.0  16.0  11.0  12.0   7.0   8.0  3.0  4.0   β‹…    β‹… 
   β‹…     β‹…     β‹…     β‹…   13.0  14.0   9.0  10.0  5.0  6.0  1.0  2.0
   β‹…     β‹…     β‹…     β‹…   15.0  16.0  11.0  12.0  7.0  8.0  3.0  4.0

The above function doesn’t allocate at all.

Hope this variation provides useful insight.

Does this do what you need?

using BlockArrays

H = BlockMatrix{Int}(undef, fill(2, 5), fill(2, 5));
A0 = fill(1, 2, 2); minusA1 = fill(-2, size(A0)); minusA2 = fill(-3, size(A0)); ZERO = zero(A0);

for i in blockaxes(H, 1), j in blockaxes(H, 2) # initialize all blocks to zero
    H[i,j] = ZERO
end

for (d, M) in zip(0:2, [A0,minusA1,minusA2])
    for j in 1:blocksize(H,2)-d
        H[Block(j+d), Block(j)] = M
    end
end
H # has desired structure

A0[1,2] = 5; minusA1[2,2] = 6 # modify some blocks
H # changes reflected in H
Matrix(H) # if you need this assembled into a normal Matrix type (somewhat expensive)
julia> H # just showing the final result
5Γ—5-blocked 10Γ—10 BlockMatrix{Int64}:
  1   5  β”‚   0   0  β”‚   0   0  β”‚   0   0  β”‚  0  0
  1   1  β”‚   0   0  β”‚   0   0  β”‚   0   0  β”‚  0  0
 ────────┼──────────┼──────────┼──────────┼──────
 -2  -2  β”‚   1   5  β”‚   0   0  β”‚   0   0  β”‚  0  0
 -2   6  β”‚   1   1  β”‚   0   0  β”‚   0   0  β”‚  0  0
 ────────┼──────────┼──────────┼──────────┼──────
 -3  -3  β”‚  -2  -2  β”‚   1   5  β”‚   0   0  β”‚  0  0
 -3  -3  β”‚  -2   6  β”‚   1   1  β”‚   0   0  β”‚  0  0
 ────────┼──────────┼──────────┼──────────┼──────
  0   0  β”‚  -3  -3  β”‚  -2  -2  β”‚   1   5  β”‚  0  0
  0   0  β”‚  -3  -3  β”‚  -2   6  β”‚   1   1  β”‚  0  0
 ────────┼──────────┼──────────┼──────────┼──────
  0   0  β”‚   0   0  β”‚  -3  -3  β”‚  -2  -2  β”‚  1  5
  0   0  β”‚   0   0  β”‚  -3  -3  β”‚  -2   6  β”‚  1  1

This creates a BlockMatrix (a matrix of matrices) using only a few small arrays for the blocks. By modifying the original arrays that were placed into blocks, the entire matrix is modified (due to aliasing). Each block in H requires only a single matrix (which is actually shared with other entries), so basically is 8 bytes (much less than a SparseMatrixCSC would have used).

One could still do more by exploiting the sparse block-diagonal structure, but that might not be necessary for you.

Thank you both! @mikmoore @Dan

I will have a look in the coming days, currently have to work on another project.

Your suggestions, if I understand correctly, are about updating the \mathbf{H} matrix, whereas my question was mostly about its creation, which in my implementation takes 8GB. After that the update should also be allocation free but it has been a while since I tested that. Therefore I will actually look at your implementations of the updating step and see if they are better.

@mikmoore I did use BlockArrays at some point for that problem but had some other issue down the line, I will revisit it.

Do note that although 8GB were allocated total, it was deallocated just as quickly. The peak usage was still quite small.


One thing I love about Julia is how easy it is to build a new AbstractArray for whatever you need. So if BlockArrays has some issue you don’t like, you can roll your own that’s even more specialized.

For example, in ~25 lines I was able to make a matrix type that matches your structure, uses minimal memory, and should work with any matrix operation you want (although providing custom methods could be more efficient):

struct BorisMatrix{T, M<:AbstractVecOrMat{T}} <: AbstractMatrix{T}
    AzeroAp::M
    Tf::Int
end
getTf(X::BorisMatrix) = X.Tf
getn(X::BorisMatrix) = size(X.AzeroAp, 2)
getp(X::BorisMatrix) = cld(size(X.AzeroAp, 1), getn(X)) - 1
Base.parent(X::BorisMatrix) = X.AzeroAp
Base.size(X::BorisMatrix) = (getn(X)*getTf(X), getn(X)*getTf(X))
function Base.getindex(X::BorisMatrix, i::Int, j::Int)
    @boundscheck checkbounds(X, i, j)
    n = getn(X)
    blk, jA = fldmod1(j, n)
    iA = i - (blk-1)*n
    return get(parent(X), CartesianIndex(iA, jA), zero(eltype(X)))
end
function Base.setindex!(X::BorisMatrix, v, i::Int, j::Int) # optional: allow modification of the underlying AzeroAp through X
    # updating one entry will update it in every block
    n = getn(X)
    blk, jA = fldmod1(j, n)
    iA = i - (blk-1)*n
    return setindex!(parent(X), v, iA, jA) # will error if (i,j) is not in the AzeroAp part of X
end
# can go on to provide more efficient methods for multiplication, etc.
# if not, it will use the default fallbacks
julia> As = [1 2; 3 4; 5 6; 7 8; 9 10; 11 12];

julia> H = BorisMatrix(As, 5)
10Γ—10 BorisMatrix{Int64, Matrix{Int64}}:
  1   2   0   0   0   0  0  0  0  0
  3   4   0   0   0   0  0  0  0  0
  5   6   1   2   0   0  0  0  0  0
  7   8   3   4   0   0  0  0  0  0
  9  10   5   6   1   2  0  0  0  0
 11  12   7   8   3   4  0  0  0  0
  0   0   9  10   5   6  1  2  0  0
  0   0  11  12   7   8  3  4  0  0
  0   0   0   0   9  10  5  6  1  2
  0   0   0   0  11  12  7  8  3  4

julia> using SparseArrays; sH = sparse(H) # can convert to SparseMatrixCSC if that's more efficient for ensuing math
10Γ—10 SparseMatrixCSC{Int64, Int64} with 48 stored entries:
  1   2   β‹…   β‹…   β‹…   β‹…  β‹…  β‹…  β‹…  β‹…
  3   4   β‹…   β‹…   β‹…   β‹…  β‹…  β‹…  β‹…  β‹…
  5   6   1   2   β‹…   β‹…  β‹…  β‹…  β‹…  β‹…
  7   8   3   4   β‹…   β‹…  β‹…  β‹…  β‹…  β‹…
  9  10   5   6   1   2  β‹…  β‹…  β‹…  β‹…
 11  12   7   8   3   4  β‹…  β‹…  β‹…  β‹…
  β‹…   β‹…   9  10   5   6  1  2  β‹…  β‹…
  β‹…   β‹…  11  12   7   8  3  4  β‹…  β‹…
  β‹…   β‹…   β‹…   β‹…   9  10  5  6  1  2
  β‹…   β‹…   β‹…   β‹…  11  12  7  8  3  4

julia> @time sH .= 2 .* H; # if the sparse target has the correct structure, can overwrite values without allocations
  0.000023 seconds (1 allocation: 32 bytes)

Note: I actually don’t recommend including the setindex! method. Because of the aliased entries, it’s rather easy to make mistakes while using it (e.g., if you increment two entries that alias, you will actually increment them both twice). Instead, I would modify the wrapped As or produce a new As to wrap in a new BorisMatrix.

I really dig having a Boris Matrix :smiley:

Thank you for your time. I really need to get more into this, it is essentially the main strength of Julia. I will try this out as well.

I have started going through the proposed solutions.

  • Starting with @mikmoore
    I couldn’t even wrap my head around the code until I finally realised that it does not create the big matrix H but actually uses clever tricks to look into the small matrix. This got me thinking how does it work later on in my code. I am doing essentially linear algebra operations and saw that the multiplication is slower for a standard matrix*vector multiplication, than if it were a Matrix. However, also converting it to matrix kind of loses the purpose, because that is expensive in itself.
julia> # Letting it fall back
       @btime begin
           AzeroAp[:,:] = rand(n, n*(p+1));  # Fill AzeroAp with random values for demonstration
           Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
           Hout = Hnew*randvec;
       end;
  50.158 ms (160 allocations: 64.36 KiB)

julia> # if BorisMatrix were treated as a matrix by Julia
       Hnew_mat = Matrix(Hnew);
       @btime begin
           AzeroAp[:,:] = rand(n, n*(p+1));  # update AzeroAp
           Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
           #simulate if Hnew were a matrix, e.g. we are not updating it correctly here
           Hout = Hnew_mat*randvec;
       end;
  3.051 ms (160 allocations: 64.35 KiB)

julia> # Account that we need to convert
       @btime begin
           AzeroAp[:,:] = rand(n, n*(p+1));  # update AzeroAp
           Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
           Hnew_mat = Matrix(Hnew);
           Hout = Hnew_mat*randvec;
       end;
  61.833 ms (163 allocations: 92.03 MiB)

julia> 
full code
using SparseArrays
using BenchmarkTools

p = 6; n = 14; Tf = 248;Tfn = n*Tf;
AzeroAp = rand(n, n*(p+1))  # Fill AzeroAp with random values for demonstration


function reorderAzeroAp(AzeroAp)
    # I need the horizontal blocks of AzeroAp to be stacked vertically in H, not the transpose of AzeroAp
AzAptr=similar(AzeroAp');
for ii = 1:p+1
    AzAptr[1+n*(ii-1):n+n*(ii-1),:] = AzeroAp[:,1+n*(ii-1):n+n*(ii-1)]
end
return AzAptr
end

struct BorisMatrix{T, M<:AbstractVecOrMat{T}} <: AbstractMatrix{T}
    AzeroAp::M
    Tf::Int
end

getTf(X::BorisMatrix) = X.Tf
getn(X::BorisMatrix) = size(X.AzeroAp, 2)
getp(X::BorisMatrix) = cld(size(X.AzeroAp, 1), getn(X)) - 1
Base.parent(X::BorisMatrix) = X.AzeroAp
Base.size(X::BorisMatrix) = (getn(X)*getTf(X), getn(X)*getTf(X))
function Base.getindex(X::BorisMatrix, i::Int, j::Int)
    @boundscheck checkbounds(X, i, j)
    n = getn(X)
    blk, jA = fldmod1(j, n)
    iA = i - (blk-1)*n
    return get(parent(X), CartesianIndex(iA, jA), zero(eltype(X)))
end

AzAptr = reorderAzeroAp(AzeroAp);
Hnew = BorisMatrix(AzAptr, Tf)

# HBM = sparse(Hnew);

randvec = rand(Tfn,); Hout=similar(randvec);

# Letting it fall back
@btime begin 
    AzeroAp[:,:] = rand(n, n*(p+1));  # Fill AzeroAp with random values for demonstration
    Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
    Hout = Hnew*randvec;
end;

# if BorisMatrix were treated as a matrix by Julia
Hnew_mat = Matrix(Hnew);
@btime begin 
    AzeroAp[:,:] = rand(n, n*(p+1));  # update AzeroAp
    Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
    #simulate if Hnew were a matrix, e.g. we are not updating it correctly here
    Hout = Hnew_mat*randvec;
end;

# Account that we need to convert
@btime begin 
    AzeroAp[:,:] = rand(n, n*(p+1));  # update AzeroAp
    Hnew = BorisMatrix(reorderAzeroAp(AzeroAp), Tf);
    Hnew_mat = Matrix(Hnew);
    Hout = Hnew_mat*randvec;
end;

You mentioned that I should probably write my own operations for the type and I think that this is exactly the example of that, am I correct? What would be the way to approach the problem, adjust the * operator to use it as a matrix? I have never extended a base function for my own types, so this is essentially my starting option. @RobertGregg also extends mul!, so I will have a look into that next (how it is implemented)

I also revisited the BlockArrays and the reason I didn’t do it was again that it was slow afterwards. Now, after reading again their readme, I fond that maybe I should be implementing it with BlockedArraysand not BlockArrays. As far as I understand the readme, if you are using a solver, i.e. if you are doing multiplications that need the full matrix (which is what I do), then the second one is better at the expense of viewing the individual blocks (which I do not care about, mine are really small).

  • @Dan I really like this idea, I will implement it in the sparse case (I also generate a similar matrix as sparse and non-sparse for different cases). First, I have to adjust it a bit, though. My structure is not the transpose of Ap per se. I have AzeroAp =[\mathbf{A_0}, -\mathbf{A_1}... ], which is 14x98 and in \mathbf{H} you put the blocks on top of eachother. E.g. in the first 1:n columns of \mathbf{H} I have [\mathbf{A_0}; -\mathbf{A_1};- \mathbf{A_2}.. ]. Note the semicolon here. A brute force repordering to get β€œtranspose” of the Apmatrix that I need to use with your suggestion could look like this:
function reorderAzeroAp(AzeroAp)
    # I need the horizontal blocks of AzeroAp to be stacked vertically in H, not the transpose of AzeroAp
AzAptr=similar(AzeroAp');
for ii = 1:p+1
    AzAptr[1+n*(ii-1):n+n*(ii-1),:] = AzeroAp[:,1+n*(ii-1):n+n*(ii-1)]
end
return AzAptr
end

ofc one can do views here and so on, this is just an example of how it should look.

  • @RobertGregg I haven’t gotten around to your suggestion yet, I will have a look, thanks again.

Yes, multiplication will be slow (by default) because it will use the generic multiplication fallback.

Calling sparse to build a sparse matrix from it is one option to give you faster multiplication. If you keep the same structure and keep writing the values into that same sparse matrix, this should require minimal/no further allocations on subsequent uses.

As you noted, the other option is to specialize your own version. Here’s something I wrote for that:

# optional: specialize multiplication
## only covers cases like B*x (and maybe x'*B'?)
## need to write other methods for other forms
##  without this, it will use a fallback
using LinearAlgebra
function LinearAlgebra.mul!(z::AbstractVecOrMat, X::BorisMatrix, y::AbstractVecOrMat)
    AzeroAp = parent(X)
    n = getn(X)
    sz1 = size(X, 1)
    axX1 = axes(X, 1)
    axA1, axA2 = axes(AzeroAp)
    fill!(z, zero(eltype(z))) # set z to zero
    for b in 1:getTf(X)
        Arows = intersect(axA1, axX1[begin:min(end, begin+sz1-(b-1)*n-1)])
        zrows = intersect((b-1)*n .+ axA1, axX1)
        yrows = (b-1)*n .+ axA2
        @views mul!(z[zrows, :], AzeroAp[Arows, :], y[yrows, :], true, true) # this line appears to allocate
    end
    return z
end

## TODO: specialize B'*x
# function LinearAlgebra.mul!(z::AbstractVecOrMat, X::Adjoint{<:Any, <:BorisMatrix}, y::AbstractVecOrMat)
# end
julia> x = randn(size(H,2));

julia> using BenchmarkTools

julia> @btime *($H, $x); # fallback, before defining specialization
  527.311 ns (2 allocations: 144 bytes)

julia> @btime *($H, $x); # after defining specialization
  157.415 ns (12 allocations: 624 bytes)

julia> H*one(H) == H # verify H*I == H
true

Unfortunately, my version appears to allocate once per loop (once per Tf). It’s not obvious to me why and I don’t have more time to keep trying to figure it out.

You don’t need to, you have already given me enough of your time and showed me how to improve a lot, the rest is up to me. Thanks!