The training of RNNs leverages algorithms such as Real-Time Recurrent Learning (RTRL) [1], and backpropagation through time (BPTT) [2], both grounded in gradient descent. RTRL adjusts network weights in real-time with data input, facilitating dynamic adaptation, whereas BPTT unfolds the network across time to capture temporal dependencies. However, the practicality of both RTRL and BPTT diverges from biological neural networks [3]. BPTT's extensive memory requirements for storing sequences and RTRL's high computational complexity are not reflective of the more efficient and constrained nature of biological neural systems. Despite their computational capabilities, these methods underscore the existing gap between machine learning models and the operational principles of real neural networks.

1 Real-Time Recurrent Learning

Following [4], consider a discrete dynamical system1 parametrized by θ∈Θ=Rdim(θ)\theta \in \Theta = \mathbb{R}^{\text{dim}(\theta)}, let st∈St=Rdim(s)s_t \in \mathcal{S}_{t} = \mathbb{R}^{\text{dim}(s)} be the state variable, for every t≥1t \geq 1 we define the transition function of the system by:

st=Tt(st−1,θ)(1)s_t = T_{t}\left(s_{t-1}, \theta\right) \tag{1}

note the dependence in tt for the transition operator TtT_t. Given a cost function Lt ⁣:St→R\mathcal{L}_t\colon\mathcal{S}_t\to\mathbb{R}, we aim to compute the derivative dLtdθ\frac{d\mathcal{L}_t}{d\theta}. Differentiating 1 gives

dstdθ=∂Tt∂st−1dst−1dθ+∂Tt∂θ.(2)\frac{d s_t}{d\theta} = \frac{\partial T_t}{\partial s_{t-1}}\frac{d s_{t-1}}{d\theta} + \frac{\partial T_t}{\partial\theta}. \tag{2}

We can then compute ∂Lt∂st\frac{\partial\mathcal{L}_t}{\partial s_t} and apply the chain rule to obtain the loss gradient. When a new observation arrives, we do not need to repeat the calculations for the entire sequence: we keep sts_t and dstdθ\frac{d s_t}{d\theta} in memory and apply the same recurrence at the next time step. This forward propagation of derivatives is the core of RTRL.

The drawback is the size of the Jacobian dstdθ\frac{d s_t}{d\theta}, which requires O(dim⁡(s)×dim⁡(θ))\mathcal{O}(\dim(s)\times\dim(\theta)) storage. For a densely connected recurrent layer with HH hidden units, the recurrent weight matrix alone has H2H^2 parameters. Storing its state–parameter Jacobian therefore takes O(H3)\mathcal{O}(H^3) memory, which quickly becomes prohibitive compared with BPTT.2 Updating that Jacobian requires multiplying matrices of sizes H×HH\times H and H×H2H\times H^2. With standard dense matrix multiplication, this costs O(H4)\mathcal{O}(H^4) operations per time step.

These costs have motivated approximations of dstdθ\frac{d s_t}{d\theta}, including the approaches in [5], [6], [7]. Sparse RNNs can reduce the number of parameters whose influence must be tracked when only the nonzero connections are trained. Even then, memory and computation can remain bottlenecks.

In this post, we focus on the work of Menick et al. [8], who propose the Sparse n-step Approximation (SnAp-n): a biased approximation to dstdθ\frac{d s_t}{d\theta} that further reduces the memory and computational requirements of RTRL in sparse networks. For the RNNs below, we write the hidden state as hth_t and its parameter Jacobian as Jt≔dhtdθJ_t\coloneqq\frac{d h_t}{d\theta}.

As outlined in their publication, the theoretical gains of this algorithm hinge on improved software support. We aim to bridge this gap by implementing optimized kernels for computing sparse Jacobians and performing masked matrix multiplications.

Our implementation achieves a 64x speedup in wall-clock time compared to the original code provided by [8]. Furthermore, our framework is open-sourced and sufficiently flexible to facilitate the implementation of new architectures with ease.

2 Sparse N-Step Approximation

Consider the following connectivity matrix WW of a recurrent neural network and the associated computational graph for one time step:

Three hidden units connected by directed edges and self-loops, alongside their sparse connectivity matrix W.

after one step of the RNN, the parameter θ22\theta_{22} will have influenced only h2h_2. After two steps, via θ12\theta_{12}, it will begin to influence h1h_1, and after three steps, it will have affected all the hidden units of the network. Clearly, this influence after nn steps will impact ∂hn,i∂θ22\frac{\partial h_{n, i}}{\partial \theta_{22}} in a different manner for each hidden unit ii.

[8] introduced the Sparse n-step Approximation (SnAp-n) for the Jacobian of the network based on the previous observation. More formally, for all tt:

J^t(i,j)={Jt(i,j)if θj influences hidden unit hn,i0otherwise(3)\hat{J}_t(i, j) = \begin{cases} J_t(i, j) & \text{if } \theta_j \text{ influences hidden unit } h_{n,i} \\ 0 & \text{otherwise} \end{cases} \tag{3}

this approximation essentially defines a mask matrix MnM_n (Figure 1), that will induce sparsity in the Jacobian, based on the sparsity of the network.

The same three-node network over one, two, and three steps, with orange, blue, and magenta paths highlighting how theta 22 progressively affects the entries in the SnAp masks.

Figure 1: Masks induced by Sparse N-Step Approximation algorithm in W=[θ11θ12θ130θ220θ310θ33]W = \left[\begin{smallmatrix}\theta_{11} & \theta_{12} & \theta_{13} \\ 0 & \theta_{22} & 0 \\ \theta_{31} & 0 & \theta_{33}\end{smallmatrix}\right]

This nn-step approximation can be integrated within the algorithmic framework introduced by [5] for algorithms that approximate the JtJ_t in RTRL (Algorithm 1). There they define a function Ap⊛( ⋅ )\text{Ap}_{\circledast}\left(\,\cdot\,\right) as a function that computes either a biased or unbiased approximation of its input and stores it in the prescribed format ⊛\circledast. We choose to work within this framework since it provides a clear description of the terms that need to be computed.

Algorithm 1: One step of approximating RTRL

Input: input xtx_t, hidden state ht−1h_{t-1}, parameters θ\theta, approximation J^t−1\hat{J}_{t-1} of Jt−1J_{t-1} stored in prescribed format ⊛\circledast.

Output: hidden state hth_t, approximation J^t\hat{J}_t of JtJ_t in format ⊛\circledast.

Preliminary calculations

  1. ht←h_t \gets new hidden state, based on ht−1h_{t-1}, θ\theta, xtx_t
  2. Dt←∂ht∂ht−1D_t \gets \frac{\partial h_t}{\partial h_{t-1}}, It←∂ht∂θI_t \gets \frac{\partial h_t}{\partial \theta}

Bring addends DtJ^t−1D_t\hat{J}_{t-1}, ItI_t in desired format ⊛\circledast

  1. A1←Ap⊛(DtJ^t−1)A_1 \gets \text{Ap}_{\circledast}(D_t\hat{J}_{t-1})
  2. A2←Ap⊛(It)A_2 \gets \text{Ap}_{\circledast}(I_t)

Mix two addends A1A_1, A2A_2

  1. J^t←Mix(A1,A2)\hat{J}_t \gets \text{Mix}(A_1, A_2), where Mix(x,y)\text{Mix}(x, y) is an approximator of x+yx + y in format ⊛\circledast

In the case of SnAp-n:

Ap⊛,n( X )=Mn⊙X\text{Ap}_{\circledast, n}\left(\,X\,\right) = M_n \odot X

therefore the RTRL equations can now be written as :

J^0=0J^t=Ap⊛,n(DtJ^t−1)+Ap⊛,n(It)=Ap⊛,n(DtJ^t−1)+It(4)\begin{aligned} &\hat{J}_0 = \mathbf{0} \\ &\hat{J}_t = \text{Ap}_{\circledast,n}\left(D_t\hat{J}_{t-1}\right) + \text{Ap}_{\circledast,n}(I_t) = \text{Ap}_{\circledast,n}\left(D_t\hat{J}_{t-1}\right) + I_t \end{aligned} \tag{4}

note that Ap⊛,n(It)=It\text{Ap}_{\circledast,n}\left(I_t\right) = I_t since for any n≥1n\geq1, MnM_n its not performing any effective masking3 on ItI_t.

Since the same mask is applied at each time step, we can exploit this uniformity to optimize computational efficiency. Specifically, as highlighted by the authors of SnAp-n, it is possible to transition from the naive matrix multiplication algorithm, which operates in O(mpk)\mathcal{O}(mpk) time (for matrices A∈Rm×k,B∈Rk×pA \in \mathbb{R}^{m\times k}, B \in \mathbb{R}^{k\times p}), to an algorithm with a complexity of O(nnz(M)k)\mathcal{O}(\text{nnz}(M)k), where nnz\text{nnz} denotes the number of non-zero elements in the mask matrix MM. Furthermore, if the storage format ⊛\circledast is designed to leverage the sparsity of the elements involved in the computation, we can achieve significant memory savings proportional to the sparsity of the mask. In this post, we will present various algorithms designed to achieve these computational improvements. More specifically we will show how to compute Ap⊛,n(DtJ^t−1)\text{Ap}_{\circledast,n}(D_t\hat{J}_{t-1}), Ap⊛,n(It)\text{Ap}_{\circledast,n}(I_t), and Mix(A1,A2)\text{Mix}(A_1,A_2), with memory cost bounded by O(nnz(Mn))\mathcal{O}(nnz(M_n)).

3 Sparse Matrices

In order to implement Algorithm 1, we have to decide on the prescribed format ⊛\circledast for storing the Jacobians. Given that we are inducing sparsity through SnAp-n, we can utilize one of the various formats developed over the past decades to represent sparse matrices. Among these, we opt to work with the Coordinate List (COO) format and the Compressed Sparse Row (CSR) format.

COO stores an array of non-zero elements and their corresponding row and column indices. In contrast, the CSR (Figure 2) format enhances memory efficiency and computational performance by storing the non-zero elements in a single array (data), along with two auxiliary arrays indicating the start of each row (indptr) and the column indices (indices) of the elements, this structure allows for faster row access operations when compared to COO.

For the purposes of implementing SnAp-n we will choose COO as the prescribed format ⊛\circledast. This choice is motivated by the following considerations.

The first one being that efficient algorithms exist to convert it to other sparse sparse formats, which might be more suitable for performing linear algebra operations. Converting from COO to CSR, when the indices are sorted lexicographically can be done by computing a prefix sum, which in turn can be performed efficiently using a parallel associative scan ([9]). This becomes important when in runtime, at every step of RTRL we have to switch to CSR for faster row access operations.

The second one is more SnAp-n specific. Suppose we can compute A1←Ap⊛,n(DtJ^t−1)A_1\gets \text{Ap}_{\circledast,n}(D_t\hat{J}_{t-1}) and A2←Ap⊛,n(It)A_2\gets \text{Ap}_{\circledast,n}(I_t) (See 4, 6) in any prescribed sparse format ⊛\circledast. For n>1n>1 the non-zero entries induced by SnAp-n in A1A_1 and A2A_2 will differ and consequently also their associated data structures, as A2A_2 is independent of nn (Recall 4). Therefore we need to devise an efficient method to add both sparse terms. When using the COO format, we have that A2.indices⊆A1.indicesA_2.\textit{indices}\subseteq A_1.\textit{indices} and implementing Mix(A1,A2)\text{Mix}(A_1,A_2) becomes trivial. This is achieved by maintaining an array that maps each index in A2.indicesA_2.\textit{indices} to its corresponding position in A1.indicesA_1.\textit{indices}, so that we have:

A1.indices[A2.pos[k]]=A2.indices[k]A_1.\textit{indices}[A_2.\textit{pos}[k]] = A_2.\textit{indices}[k]

addition of both sparse terms can now be implemented as:

(A1,A2)↦A1.data.at[A2.pos].set(A1.data[A2.pos]+A2.data)(5)(A_1,A_2) \mapsto A_1.\textit{data}.\text{at}[A_2.\textit{pos}].\text{set} (A_1.\textit{data}[A_2.\textit{pos}] + A_2.\textit{data}) \tag{5}

This approach however requires storing an extra array, A2.posA_2.\textit{pos}, increasing the cost for storing A2A_2, but note that in general nnz(A1)≫nnz(A2)\text{nnz}(A_1)\gg\text{nnz}(A_2) for n>1n>1, so its the data structures from A1A_1 that will dominate the memory. Also 5 is a pure function, where in addition all the array shapes involved are static and known at compile time, which will come in handy at later sections. The mixing operation can also be computed avoiding any extra storage at the expense of Ω(nnz(Mn))\Omega(nnz(M_n)) more operations per step, reducing significantly the time performance.

Having motivated our choice for ⊛\circledast in the following section we will present an algorithm to compute Ap⊛(It)\text{Ap}_{\circledast}\left(I_t\right) without explicitly computing the dense ItI_t. To this end, we will employ a matrix coloring technique that will substantially reduce the space required for storage, as well as the computational effort needed.

A four-by-five sparse matrix with blue row indices and magenta column indices, followed by its COO data and coordinate pairs and its CSR data, column indices, and row pointers.

Figure 2: Matrix AA and its sparse representations in COO and CSR format.

4 Sparse Jacobian Computation

Let f ⁣:R4↦R4f\colon\mathbb{R}^{4}\mapsto\mathbb{R}^{4}, and assume that for regardless of the input to the function, the Jacobian always keeps the following sparse structure :

J(x)=[∂f1∂x1∂f1∂x20000∂f2∂x3∂f2∂x4000∂f3∂x4∂f4∂x1000](6)\mathbf{J}\left(\mathbf{x}\right) = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \frac{\partial f_1}{\partial x_2} & 0 & 0 \\[1ex] 0 & 0 & \frac{\partial f_2}{\partial x_3} & \frac{\partial f_2}{\partial x_4} \\[1ex] 0 & 0 & 0 & \frac{\partial f_3}{\partial x_4} \\ \frac{\partial f_4}{\partial x_1} & 0 & 0 & 0 \end{bmatrix} \tag{6}

Ideally we would like to take advantage of the sparsity in the Jacobian to minimize the number of evaluations required for its determination. [10] observed that a group of rows could be determined through a single evaluation, provided no two rows in the group contain a nonzero element in the same column position. Such a group of rows is defined as structurally orthogonal to each other, which differs from the common notion of orthogonality in vector spaces. A set of structurally orthogonal groups that contains all the rows of the matrix is termed a consistent partition of the matrix. A matrix clearly can be trivially partitioned into a set of structurally orthogonal groups, one group for each row, the objective therefore its to find a consistent partition with the minimum number of groups.

Define now G(J)G(\mathbf{J}) to be a graph with vertices V={r1,r2,r3,r4}V=\{r_1,r_2,r_3, r_4\} and edges (ri,rj)(r_i,r_j) if and only if i≠ji\neq j and rows rir_i and rjr_j in 6 have a nonzero in the same column position (See figure 3). If we color the graph so that no two adjacent vertices have the same color, we can obtain a consistent partition of the matrix where the groups are defined by the color of each vertex. This connection between finding consistent partition of the matrix and coloring the associated graph, was first introduced by [11].

The row-conflict graph has edges from r1 to r4 and from r2 to r3. Its colored version groups r1 and r2 in cyan and r3 and r4 in magenta. The crossing edges do not meet at a vertex.

Figure 3: Induced graph G(J)G(\mathbf{J}) and its coloring.

The resulting matrix coloring of J\mathbf{J} will look like this:

J(x)=[∂f1∂x1∂f1∂x20000∂f2∂x3∂f2∂x4000∂f3∂x4∂f4∂x1000]\mathbf{J}\left(\mathbf{x}\right) = \begin{bmatrix} \textcolor{cyan}{\frac{\partial f_1}{\partial x_1}} & \textcolor{cyan}{\frac{\partial f_1}{\partial x_2}} & 0 & 0 \\[1ex] 0 & 0 & \textcolor{cyan}{\frac{\partial f_2}{\partial x_3}} & \textcolor{cyan}{\frac{\partial f_2}{\partial x_4}} \\[1ex] 0 & 0 & 0 & \textcolor{magenta}{\frac{\partial f_3}{\partial x_4}} \\ \textcolor{magenta}{\frac{\partial f_4}{\partial x_1}} & 0 & 0 & 0 \end{bmatrix}

Clearly we can define now v1=[1100]\mathbf{v}_1=\left[\begin{smallmatrix}1&1&0&0\end{smallmatrix}\right] and v2=[0011]\mathbf{v}_2=\left[\begin{smallmatrix}0&0&1&1\end{smallmatrix}\right], and perform a blocked vector-Jacobian product to obtain the compressed Jacobian, from which the non zero elements of J\mathbf{J} can easily be accessed and stored in ⊛\circledast:

[v1v2]J(x)=[∂f1∂x1∂f1∂x2∂f2∂x3∂f2∂x4∂f4∂x100∂f3∂x4](7)\begin{bmatrix}\mathbf{v}_1\\\mathbf{v}_2\end{bmatrix}\mathbf{J}\left(\mathbf{x}\right) = \begin{bmatrix} \frac{\partial f_1}{\partial x_1} & \frac{\partial f_1}{\partial x_2} & \frac{\partial f_2}{\partial x_3} & \frac{\partial f_2}{\partial x_4} \\[1ex] \frac{\partial f_4}{\partial x_1} & 0 & 0 & \frac{\partial f_3}{\partial x_4} \end{bmatrix} \tag{7}

Throughout this post we will refer to the matrix used to left multiply the Jacobian and retrieve the non-zero elements as the Jacobian projection matrix V\mathbf{V}. For a detailed description of the algorithm, please refer to Algorithm 2.

Algorithm 2: Non-Zero Entries of Sparse Jacobian Using Graph Coloring.

Input: Jacobian J\mathbf{J}, Sparsity Pattern M\mathbf{M}.

Output: Compressed Jacobian S\mathbf{S}.

  1. Compute the induced graph G(M)G(\mathbf{M}).
  2. Color G(M)G(\mathbf{M}) and save the groups {E1 , … }\{E_1\,,\,\dots\}.
  3. c←Number of colors used to color G(M)c\gets\text{Number of colors used to color }G(\mathbf{M}).
  4. n←Number of rows in Mn\gets\text{Number of rows in }\mathbf{M}.
  5. For i←1i\gets1 to cc do
    1. vi←0∈Rn\mathbf{v}_i\gets\mathbf{0}\in\mathbb{R}^{n}
    2. For k←1k\gets1 to nn do
      1. vi[k]←1\mathbf{v}_i[k]\gets1 if and only if row k∈Eik\in E_i
    3. End for
  6. End for
  7. V←[v1⊺…vc⊺]⊺\mathbf{V}\gets\left[\begin{smallmatrix}\mathbf{v}_1^\intercal&\dots&\mathbf{v}_c^\intercal\end{smallmatrix}\right]^\intercal
  8. S←VJ\mathbf{S}\gets\mathbf{V}\mathbf{J}

In general for a function f ⁣:Rn↦Rmf\colon\mathbb{R}^{n}\mapsto\mathbb{R}^{m}, the number of entries required to store the Jacobian is reduced by a fraction of cm\frac{c}{m}, where cc is the number of colors used in the algorithm. For a standard RNN with a hidden size of hh, storing the dense ∂ht∂θ\frac{\partial h_t}{\partial\theta} requires O(h3)\mathcal{O}(h^3) memory. In contrast, for the compressed version, the memory requirement is reduced to O(h2)\mathcal{O}(h^2).

While the matrix coloring algorithm enables us to uniquely determine the non-zero entries in a sparse Jacobian, a significant challenge remains: the explicit Jacobian matrix is often unavailable and computing it directly is infeasible due to its large memory requirements. To address this, we can leverage automatic differentiation tools, particularly those that implement the vector-Jacobian product (VJP) operation. These tools allow us to compute the necessary derivatives implicitly, without forming the entire Jacobian matrix.

A prominent example of such a tool is JAX [12], a high-performance library designed for numerical computing and machine learning research. JAX provides robust automatic differentiation capabilities, allowing us to compute the necessary derivatives implicitly, without forming the entire Jacobian matrix. By integrating matrix coloring with JAX's VJP functionality, we can efficiently handle sparse Jacobians, thus circumventing the substantial storage and computational costs associated with the explicit Jacobian computation.

Implementing Algorithm 2 would be straightforward if we restricted ourselves to functions accepting a single rank-1 array, since JAX through its jax.vjp interface combined with jax.vmap, facilitates the computation of S←VJ\mathbf{S}\gets\mathbf{V}\mathbf{J} in a one-liner. However, to provide a clean interface and generalize our implementation to accommodate any RNN architecture, we must support the computation of sparse Jacobians for functions that accept arbitrary PyTrees4. For the remainder of this post we will refer to an arbitrary PyTree as θ\theta, and to its leaves as θi\theta_i.

5 Sparse JACREV

Assume for simplicity that f:Θ×Rn→Rmf:\Theta\times\mathbb{R}^n\rightarrow\mathbb{R}^m. JAX of course allows us to compute the VJP for functions accepting an arbitrary PyTree (Figure 4), however there is one caveat. Once the VJP of ff is obtained, the cotangent vector will be applied for every leaf in the PyTree. Since not every leaf will produce the same sparsity pattern in the Jacobian, and therefore not the same Jacobian projection matrix ViV_i, what we want is to compute Vi dθifV_i\,\mathrm{d}_{\theta_i}f using jax.vjp for every leaf in θ\theta without computing Vi dθkfV_i\,\mathrm{d}_{\theta_k}f for k≠ik\neq i, so that we don't waste space or perform unnecesary computations (Figure 5).

To the best of our knowledge there is no such functionality avalaible, therefore we dig into the internals of JAX to allow such computations. Our implementation supports dense leaves and sparse leaves represented in the COO format, under the same function. Furthermore, in computing ∂f∂θi\frac{\partial f}{\partial\theta_i} for a sparse θi\theta_i the dimensions of the Jacobian will reflect the sparsity of the leaf, obtaining a Jacobian of dimensions Rm×nnz(θi)\mathbb{R}^{m\times\text{nnz}(\theta_i)}. This allows us to obtain the memory gains in sparse recurrent neural networks , outlined by [8], even when there is no approximation made to the Jacobian when using the RTRL algorithm.

With the previous functionality in hand, we propose the following interface for implementing Algorithm 2:

  • def make_jacobian_projection(
        jacobian_mask: PyTree
    ) -> PyTree:
    

    Must recieve a PyTree with the same structure as θ\theta, where at each leaf must contain the sparsity pattern of its corresponding Jacobian. It will then compute the coloring of the induced graph for each leaf using networkx ([13]) as described in section 4, and then return the Jacobian projection matrices in a PyTree with the same structure as θ\theta.

  • def sp_jacrev(
        f: Callable,
        V: PyTree,
        argnums: int | sequence[int] = 0
    ) -> Callable:
    

    Mimics almost one-to-one the current jax.jacrev that allows the user to compute the dense jacobians, with the difference that ours allows the user to pass a PyTree with the Jacobian projection matrices to obtain the sparse Jacobians. Our implementation is fully compatible with all the JAX high level operations (jit, grad, vmap). The returned callable will compute the sparse jacobians evaluated at the primals, and return them stored in COO.

JAX applies the same cotangent Vi to every leaf of a PyTree, yielding Vi times the derivative of f with respect to each of theta 0 through theta 3.

Figure 4: JAX VJP for a PyTree. Note that the contangent vector ViV_i is applied for every leaf, independently if we wanted to apply it just for one leaf in specific.

The custom VJP accepts a primal PyTree and a matching PyTree of cotangents V0 through V3, applying each cotangent only to the corresponding parameter leaf.

Figure 5: Custom vjp function where we accept the primal PyTree and another PyTree that contains at each leaf a differrent contangent vector that will be applied to the matching leaf in the primal PyTree.

We conduct benchmarks of our implementation to measure the predicted memory savings when computing It≔∂ht∂θI_t\coloneqq\frac{\partial h_t}{\partial\theta}, for both a tanhRNN and standard GRU. As illustrated in Figure 6 and Figure 7, our approach demonstrates both memory and computation cost advantages when using the matrix coloring technique in comparison to the explicit computation of the Jacobian.

For profiling the memory we used pprof as recommended by the JAX developers. To ensure the correctness of our benchmarks, we provide the snapshots of the memory sate for each method where we must see a linear tree in which the root must indicate the function being used (Figure 8).

Log-scale grouped bars compare GPU memory for explicit and matrix-coloring Jacobians in tanhRNN and GRU networks as hidden size increases. Missing blue bars mark unavailable dense benchmarks.

Figure 6: Memory allocated in the GPU, log scale, to compute It≔∂ht∂θI_t\coloneqq\frac{\partial h_t}{\partial\theta} for different architectures in a NVIDIA RTX 3090. Bars not showed indicate that we could allocate the dense Jacobians in memory. Note that it requires storage O(H3)\mathcal{O}\left(H^3\right) where HH is the numer of hidden units.

Log-scale grouped bars compare the time to compute explicit and matrix-coloring Jacobians for tanhRNN and GRU networks, with hidden sizes from 128 to 4096.

Figure 7: Time to compute It≔∂ht∂θI_t\coloneqq\frac{\partial h_t}{\partial\theta} for different architectures in a NVIDIA RTX 3090. Bars not showed indicate that we could allocate the dense Jacobians in memory. Note that it requires storage O(H3)\mathcal{O}\left(H^3\right) where HH is the numer of hidden units.

Two pprof call chains compare explicit jacrev allocations near 1024 MB with sparse jacrev allocations near 6 MB for a tanhRNN with 512 hidden units.

Figure 8: Flamegraph obtained by pprof of jax.jacrev and sp_jacrev, for a tanhRNN with a hidden size of 512 units. We omit the rest of the leafs below the last node for better visualization purposes.

Given that we can now compute ItI_t for any arbitrary recurrent neural network, f(θ,ht−1,xt)f(\theta,h_{t-1},x_t), and store it in the prescribed format as defined by ⊛\circledast, in the next section we will implement a kernel to compute Ap⊛,n(DtJ^t−1)\text{Ap}_{\circledast,n}(D_t\hat{J}_{t-1}) exploiting the sparsity of the mask MnM_n involved. This will substantially reduce the computational cost, when the zero elements of the mask dominate the non-zero elements.

6 Masked Sparse Matrix Multiplication

Sparse matrix computations are crucial in scientific and engineering applications, enabling efficient handling of large-scale data. Algorithms for sparse matrix operations, such as sparse matrix-sparse matrix multiplication (SpMM) and dense-sparse matrix multiplication (DSMM) reduce memory usage and computational complexity by exploiting the sparsity of the operands.

In recent years, JAX has made significant progress enhancing its support for sparsity on GPUs by integrating with cuSPARSE, NVIDIA's library for sparse matrix operations. Despite these advancements numerous operations provided by cuSPARSE are not yet fully supported within JAX.

In this section, we introduce an algorithm to address a specific operation:

R=M⊙DX(8)R = M \odot D X \tag{8}

where D∈Rm×kD\in\mathbb{R}^{m\times k} is dense, X∈Rk×pX\in\mathbb{R}^{k\times p} is a sparse matrix stored in ⊛\circledast5, and MM is a masking matrix also in ⊛\circledast. Since the mask matrix induces sparsity in DXDX, we aim for the resulting RR to be in ⊛\circledast as well. This operation is particularly relevant to us since recall that Ap⊛,n(DtJ^t−1)=Mn⊙(DtJ^t−1)\text{Ap}_{\circledast,n}(D_t\hat{J}_{t-1})=M_n\odot(D_t\hat{J}_{t-1}). Therefore implementing 8 efficiently will allows us to finalize the implementation of Algorithm 1 under the SnAp-n approximation.

Implementing 8 can be done in O(nnz(M)k)\mathcal{O}(\text{nnz}(M)k) using the most naive algorithm, where for each Mi,j≠0M_{i,j}\neq0 we perform the dot product Di:X:jD_{i:}X_{:j}. This algorithm is also embarrassingly parallelizable, excluding any parallelism within the dot product itself. To exploit this parallelism we could use jax.vmap in combination with JAX's support of the COO6 format. However as we found, this approach is highly inefficient. The inefficiency arises from accessing a column in the COO format, which is inherently slow. One possible solution is to convert XX from the COO format to a CSR format, and compute instead M⊺⊙(X⊺D⊺)M^\intercal\odot(X^\intercal D^\intercal), as CSR allows for efficient row access. Unfortunately, the JAX library does not support row slicing in CSR format. We believe the reason for this limitation is that accessing a row in CSR format does not satisfy JAX's static shape constraints. Specifically, in CSR format, a row is accessed using X.data[indptr[i]:indptr[i+1]], and this form of dynamic slicing is not permitted within just-in-time compiled (JIT) functions, which is one of the main reasons to use JAX.

Considering these constraints, we opted to implement the Masked Dense Sparse Matrix Multiplication (Masked DSMM) as a primitive operation in JAX. This implementation ensures compatibility with JIT compiled functions. The primitive operates on CSR sparse matrices, and the algorithm is detailed in Algorithm 3.

Algorithm 3: Masked Sparse-Matrix Dense-Matrix Multiplication

Input: Mask MM, ACSRA_{\text{CSR}}, BB.

Output: C⊛C_{\circledast}.

  1. Procedure Masked-SpMM(MM, ACSRA_{\text{CSR}}, BB)
    1. N←∣M∣N\gets|M|
    2. For all n←1n\gets1 to NN do in parallel
      1. i,j←M[n]i,j\gets M[n]
      2. rs←A.indptr[i]r_s\gets A.\text{indptr}[i]
      3. re←A.indptr[i+1]r_e\gets A.\text{indptr}[i+1]
      4. If rs−re=0r_s-r_e=0 then
        1. C.data[n]←0C.\text{data}[n]\gets0
      5. Else
        1. v←A.data[rs:re]\mathbf{v}\gets A.\text{data}[r_s:r_e]
        2. nnz_cols←A.cols[rs:re]\text{nnz\_cols}\gets A.\text{cols}[r_s:r_e]
        3. C.data[n]←v⋅B[ : ,nnz_cols]C.\text{data}[n]\gets\mathbf{v}\cdot B[\,:\,,\text{nnz\_cols}]
      6. End if
    3. End for
    4. C.indptr←MC.\text{indptr}\gets M
  2. End procedure

For the CPU support we implemented the primitive in NUMBA ([14]) and take advantage of their cfunc decorator that creates a compiled function callable from foreign C code. We then take the address of this JITed function and register it in the XLA compiler through their custom call API. For the GPU support the process becomes more intricate and requires knowledge of Python and C programming. The steps required to add a custom GPU primitive in JAX can be seen in Figure 9.

Two columns list the Python and C steps needed for a custom JAX CUDA primitive: define the primitive and graph builders, abstract evaluation and MLIR lowering; supply CUDA kernels, a C dispatcher, a descriptor, and Python bindings.

Figure 9: Steps required to add a new CUDA primitive to JAX.

Before delving into CUDA programming, it is prudent to check whether existing libraries already implement the desired kernel. To the best of our knowledge, the primitive described in equation 8 is absent in current GPU Sparse General Matrix Multiplication (SpGEMM) libraries. We identified only two relevant libraries: GraphBlast ([15]) and cuSPARSE.

GraphBlast implements the GraphBlas API specification ([16]), which stipulates that a compliant library should include the primitive in 8. However, an examination of GraphBlast's source code reveals that it does not support masking when one of the operands is dense, as required for our application. Although we could convert our first operand DD to a sparse format to use their implementation, this approach is infeasible. For our use case DD represents ∂ht∂ht−1\frac{\partial h_t}{\partial h_{t-1}}, which can indeed be sparse and constant. However, outside the regime of a Linear RNN, the sparsity of the dynamics Jacobian varies depending on the input. This variability violates JAX's static shape constraints when constructing a COO or CSR matrix, as the number of non-zero entries must be static and known at compile time.

On the other hand, cuSPARSE offers the function cusparseSDDMM for masking the output, which only works with dense inputs to produce a sparse output. For RNNs trained with Real-Time Recurrent Learning (RTRL), reverting to a dense representation of the Jacobians is prohibitive, as discussed in the previous section. This limitation in existing libraries motivated us to create our own custom kernels, specifically tailored to address the requirements of our Masked Dense Sparse Matrix Multiplication (Masked DSMM) operations.

To invoke a CUDA kernel, we must specify the grid size and block dimensions. Since Algorithm 3 parallelizes over the non-zero elements of the mask we opted for a one-dimensional grid where the grid size is determined by ⌈nnz(M)/B⌉\left\lceil\text{nnz}(M)/B\right\rceil. Here BB indicates the number of threads per block, which we set to be 1024.

With the Masked DSMM primitive established, we can now fully implement Algorithm 1. Figure 10 showcases benchmarks of our implementation, contrasting it with the original code provided by [8] for SnAp-1. Our implementation demonstrates an order of magnitude increase in speed compared to the original code, thereby validating the optimizations developed throughout this post. Specifically, our implementation aligns more closely with the theoretical improvements outlined in their publication, particularly when comparing SnAp-1 with Backpropagation Through Time (BPTT) for hidden sizes less than 512. Figure 11 reports the time required to compute the gradients for a full sequence of input-output pairs, rather than just a single step.

Log-scale bars compare dense and sparse implementations of one SnAp-1 step for tanhRNN and GRU networks with 128, 256, 512, and 1024 hidden units.

Figure 10: Time to compute one step of SnAp-1 for different architectures in a NVIDIA RTX 3090.

Log-scale bars compare full-sequence gradient computation time using BPTT and SnAp-1 for tanhRNN and GRU networks across hidden sizes.

Figure 11: Time to compute ∇L\nabla L for BPTT and SnAp-1 in a NVIDIA RTX 3090.

For highly sparse networks where SnAp-2 and SnAp-3 are applicable, we are unable to provide benchmarks as the corresponding code was not released. However during the development of the library we observed that the performance of our Masked DSMM diminishes as the density7 of the Jacobian falls below 0.01. Dense Matrix Multiplications are highly optimized operations that exploit memory coalescence, with cache timing being a critical factor at this level of parallelism. Our implementation achieves good memory coalescence when accessing rows of the sparse matrix. However, accessing columns results in numerous cache misses due to non-contiguous memory access patterns. This is a big limitation of our kernel, but we believe there is a lot of improvement can be made, since we just implemented the most naive algorithm we could think of. There is an existing line of research that attempts to improve the memory coalescence when using sparse operands, we refer the interested reader to [17], [18], [19], [20] among others.

We conclude this post by noting that the source code for all discussed algorithms is available as an open-source package in snapjax. In addition to the SnAp-N approximations we implement the exact RTRL algorithm for any class of single-layer RNNs. For general multilayer RNNs we implement the approximation rule introduced in [21], and if we restrict to layers following:

ht1=f1(ht−11,xt)htl=fl(ht−1l,htl−1)yg=g(htL)\begin{aligned} h_t^1 &= f_1\left(h_{t-1}^1,x_t\right)\\ h_t^l &= f_l\left(h_{t-1}^l,h_t^{l-1}\right)\\ y_g &= g\left(h_t^L\right) \end{aligned}

we implement the exact RTRL gradient computations.

References

[1]
R. J. Williams and D. Zipser, “A Learning Algorithm for Continually Running Fully Recurrent Neural Networks,” Neural Computation, vol. 1, no. 2, pp. 270–280, 1989, doi: 10.1162/neco.1989.1.2.270.
[2]
P. J. Werbos, “Backpropagation through time: what it does and how to do it,” Proceedings of the IEEE, vol. 78, no. 10, pp. 1550–1560, 1990, doi: 10.1109/5.58337.
[3]
F. Crick, “The recent excitement about neural networks,” Nature, vol. 337, no. 6203, pp. 129–132, 1989, doi: 10.1038/337129a0.
[4]
P.-Y. Massé and Y. Ollivier, “Convergence of online adaptive and recurrent optimization algorithms,” arXiv preprint arXiv:2005.05645, 2020.
[5]
F. Benzing, M. M. Gauy, A. Mujika, A. Martinsson, and A. Steger, “Optimal Kronecker-Sum Approximation of Real Time Recurrent Learning,” in Proceedings of the 36th International Conference on Machine Learning, K. Chaudhuri and R. Salakhutdinov, Eds., in Proceedings of Machine Learning Research, vol. 97. PMLR, Jun. 2019, pp. 604–613. [Online]. Available: https://proceedings.mlr.press/v97/benzing19a.html
[6]
A. Mujika, F. Meier, and A. Steger, “Approximating real-time recurrent learning with random kronecker factors,” Advances in neural information processing systems, vol. 31, 2018.
[7]
C. Tallec and Y. Ollivier, “Unbiased Online Recurrent Optimization,” in International Conference on Learning Representations, 2018. [Online]. Available: https://openreview.net/forum?id=rJQDjk-0b
[8]
J. Menick, E. Elsen, U. Evci, S. Osindero, K. Simonyan, and A. Graves, “Practical Real Time Recurrent Learning with a Sparse Approximation,” in International Conference on Learning Representations, 2021. [Online]. Available: https://openreview.net/forum?id=q3KSThy2GwB
[9]
G. E. Blelloch, “Prefix Sums and Their Applications,” School of Computer Science, Carnegie Mellon University, techreport CMU-CS-90-190, Nov. 1990.
[10]
A. R. CURTIS, M. J. D. POWELL, and J. K. REID, “On the Estimation of Sparse Jacobian Matrices,” IMA Journal of Applied Mathematics, vol. 13, no. 1, pp. 117–119, Feb. 1974, doi: 10.1093/imamat/13.1.117.
[11]
T. F. Coleman and J. J. Moré, “Estimation of Sparse Jacobian Matrices and Graph Coloring Blems,” SIAM Journal on Numerical Analysis, vol. 20, no. 1, pp. 187–209, 1983, doi: 10.1137/0720013.
[12]
J. Bradbury et al., “JAX: composable transformations of Python+NumPy programs.” 2018. [Online]. Available: http://github.com/google/jax
[13]
A. A. Hagberg, D. A. Schult, and P. J. Swart, “Exploring Network Structure, Dynamics, and Function using NetworkX,” in Proceedings of the 7th Python in Science Conference, G. Varoquaux, T. Vaught, and J. Millman, Eds., Pasadena, CA USA, 2008, pp. 11–15.
[14]
S. K. Lam, A. Pitrou, and S. Seibert, “Numba: a LLVM-based Python JIT compiler,” in Proceedings of the Second Workshop on the LLVM Compiler Infrastructure in HPC, in LLVM ’15. Austin, Texas: Association for Computing Machinery, 2015. doi: 10.1145/2833157.2833162.
[15]
C. Yang, A. Buluç, and J. D. Owens, “GraphBLAST: A High-Performance Linear Algebra-based Graph Framework on the GPU,” ACM Trans. Math. Softw., vol. 48, no. 1, Feb. 2022, doi: 10.1145/3466795.
[16]
B. Brock, A. Buluç, T. Mattson, S. McMillan, and J. Moreira, “The graphblas c api specification,” GraphBLAS. org, Tech. Rep, 2021.
[17]
S. Milaković, O. Selvitopi, I. Nisa, Z. Budimlić, and A. Buluç, “Parallel algorithms for masked sparse matrix-matrix products,” in Proceedings of the 27th ACM SIGPLAN Symposium on Principles and Practice of Parallel Programming, in PPoPP ’22. Seoul, Republic of Korea: Association for Computing Machinery, 2022, pp. 453–454. doi: 10.1145/3503221.3508430.
[18]
G. Huang, G. Dai, Y. Wang, and H. Yang, “GE-SpMM: General-Purpose Sparse Matrix-Matrix Multiplication on GPUs for Graph Neural Networks,” in SC20: International Conference for High Performance Computing, Networking, Storage and Analysis, 2020, pp. 1–12. doi: 10.1109/SC41405.2020.00076.
[19]
W. Liu and B. Vinter, “An Efficient GPU General Sparse Matrix-Matrix Multiplication for Irregular Data,” in 2014 IEEE 28th International Parallel and Distributed Processing Symposium, 2014, pp. 370–381. doi: 10.1109/IPDPS.2014.47.
[20]
A. Buluç and J. R. Gilbert, “Parallel Sparse Matrix-Matrix Multiplication and Indexing: Implementation and Experiments,” SIAM Journal on Scientific Computing, vol. 34, no. 4, pp. C170–C191, 2012, doi: 10.1137/110848244.
[21]
N. Zucchet, R. Meier, S. Schug, A. Mujika, and J. Sacramento, “Online learning of long-range dependencies,” in Thirty-seventh Conference on Neural Information Processing Systems, 2023. [Online]. Available: https://openreview.net/forum?id=Wa1GGPqjUn
[22]
P. Kidger and C. Garcia, “Equinox: neural networks in JAX via callable PyTrees and filtered transformations,” Differentiable Programming workshop at Neural Information Processing Systems 2021, 2021.

Notes

  1. A derivation for RNNs and a discussion of online parameter updates appear in Real-Time Recurrent Learning: Training Dynamics. ↩

  2. BPTT uses vector–Jacobian products and reverse-mode differentiation instead of storing the full state–parameter Jacobian. Its parameter-gradient storage is O(dim⁡(θ))\mathcal{O}(\dim(\theta)), but conventional full-sequence BPTT also stores forward activations, so its total memory use grows with sequence length. ↩

  3. This only holds for RNNs in which a parameter θi\theta_i only affects one hidden unit in one time step as noted by the authors. ↩

  4. A PyTree is (Verbatim from [22]) any arbitrarily nested composition of ‘node’ types (dictionaries, lists, and tuples) containing ‘leaf’ types (all other Python types as well as Rank-N Arrays). ↩

  5. ⊛≔COO\circledast\coloneqq\text{COO} ↩

  6. JAX actually prioritizes support for BCOO (Batch COO) and BCSR (Batch CSR). We work with these formats but in the text we just name then COO and CSR respectively. ↩

  7. Density is defined as d≔1−sd\coloneqq1-s, where ss is the level of sparsity. ↩