Linear attention is becoming the dominant attention mechanism in LLMs. It primarily serves to decouple KV-cache growth from context size (a comparison of softmax attention vs linear attention is provided below). Linear attention belongs in the family of recurrent neural networks and was brought to the forefront by the work Transformers are RNNs in 2020. Its lineage is however deeper, going back to the work on learned associations by Widrow and Hoff, fast weights by Jürgen Schmidhuber and LSTM and RNN work from the late 20th century. Today, we'll look closely at a variant of linear attention, Gated DeltaNet (GDN), the basis of the linear attention layers in the Nenya family of models.
As opposed to softmax attention, where we matmul
Linear attention revolves around one core entity, the
To add mathematical rigour, the following is the update rule for GDN, given the
As you can see from the update rules above, we need
Eq. 1 decays the existing state
Note that the terms read, forget and write here are just matrix multiplications and additions. These are the primitives a model has to read and write from a tensor.
Below is a naive PyTorch implementation (very slow, but helps with understanding) of GDN. The key update rule takes place between lines 126 and 148. However, there are a few important details to understand first:
log_alpha in the code), and before applying it to the model, we exponentiate it, which keeps d_out in the code) of d_out the 'channel' dim so depthwise conv treats them with separate kernels. For example, the conv would take the first feature dim of F.normalize in the code), so that
class GatedMultiHeadDeltaAttention(nn.Module):
def __init__(self, d_model, d_out, context_length, dropout, num_heads, qkv_bias=False):
super().__init__()
self.d_out = d_out
self.num_heads = num_heads
self.head_dim = d_out // num_heads
# Q, K, V projections
# [b, t, d_model] @ [d_model, d_out] -> [b, t, d_out]
self.W_query = nn.Linear(d_model, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_model, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_model, d_out, bias=qkv_bias)
# Output projection to mix information between heads
# [b, t, d_out] @ [d_out, d_out] -> [b, t, d_out]
self.out_proj = nn.Linear(d_out, d_out)
# Update gate, one scalar per token per head
# [b, t, d_model] @ [d_model, nh] -> [b, t, nh]
self.beta = nn.Linear(d_model, num_heads)
# Input-dependent forget gate, one scalar per token per head
# [b, t, d_model] @ [d_model, nh] -> [b, t, nh]
self.alpha = nn.Linear(d_model, num_heads)
# Input-dependent output gate, applied after combining heads
# [b, t, d_model] @ [d_model, d_out] -> [b, t, d_out]
self.gate = nn.Linear(d_model, d_out)
# Input-independent forget gate, one parameter per head
# [nh]
self.log_alpha = nn.Parameter(
torch.log(torch.empty(num_heads).uniform_(0, 16))
)
# Depthwise causal convolutions for local token mixing
# Input: [b, d_out, t + 3] -> Output: [b, d_out, t]
self.conv_k = nn.Conv1d(
d_out, d_out, kernel_size=4, stride=1, groups=d_out
)
self.conv_v = nn.Conv1d(
d_out, d_out, kernel_size=4, stride=1, groups=d_out
)
self.conv_q = nn.Conv1d(
d_out, d_out, kernel_size=4, stride=1, groups=d_out
)
# RMS normalization over head dimension
self.norm = nn.RMSNorm(self.head_dim, eps=1e-6)
def forward(self, x):
b, num_tokens, d_model = x.shape # [b, t, d_model]
# Project input into Q, K, V
# [b, t, d_model] @ [d_model, d_out] -> [b, t, d_out]
keys = self.W_key(x)
queries = self.W_query(x)
values = self.W_value(x)
# Left-pad by 3 tokens for causal convolution
# [b, t, d_out] -> [b, t + 3, d_out]
keys = F.pad(keys, (0, 0, 3, 0, 0, 0))
queries = F.pad(queries, (0, 0, 3, 0, 0, 0))
values = F.pad(values, (0, 0, 3, 0, 0, 0))
# Apply depthwise convolutions followed by SiLU
# [b, t + 3, d_out] -> [b, d_out, t + 3] (transpose)
# [b, d_out, t + 3] -> [b, d_out, t] (conv, then SiLU)
# [b, d_out, t] -> [b, t, d_out] (transpose back)
keys = F.silu(self.conv_k(keys.transpose(1, 2))).transpose(1, 2)
queries = F.silu(self.conv_q(queries.transpose(1, 2))).transpose(1, 2)
values = F.silu(self.conv_v(values.transpose(1, 2))).transpose(1, 2)
# Split embedding dimension into heads
# [b, t, d_out] -> [b, t, nh, hd]
keys = keys.view(b, num_tokens, self.num_heads, self.head_dim)
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim)
values = values.view(b, num_tokens, self.num_heads, self.head_dim)
# Move head dimension before sequence dimension
# [b, t, nh, hd] -> [b, nh, t, hd]
keys = keys.transpose(1, 2)
queries = queries.transpose(1, 2)
values = values.transpose(1, 2)
# Unit-length Q and K (so k^T k = 1); RMSNorm on V
# [b, nh, t, hd] -> [b, nh, t, hd]
keys = F.normalize(keys, dim=-1)
queries = F.normalize(queries, dim=-1)
values = self.norm(values)
# Initialize recurrent state matrix S_0
# [b, nh, hd, hd]
S = x.new_zeros(b, self.num_heads, self.head_dim, self.head_dim)
# Compute input-dependent component of forget gate
# [b, t, d_model] @ [d_model, nh] -> [b, t, nh]
input_dependent = F.softplus(self.alpha(x))
# Compute input-independent component of forget gate, A = exp(log_alpha)
# [nh] -> [1, 1, nh]
A = self.log_alpha.exp().unsqueeze(0).unsqueeze(0)
# Combine forget gate components
# [b, t, nh] * [1, 1, nh] -> [b, t, nh]
log_combined = -input_dependent * A
# Exponentiate to obtain alpha in (0, 1]
# [b, t, nh] -> [b, t, nh]
alpha_final = log_combined.exp()
# Compute update gate beta
# [b, t, d_model] @ [d_model, nh] -> [b, t, nh]
beta = torch.sigmoid(self.beta(x))
# Compute output gate
# [b, t, d_model] @ [d_model, d_out] -> [b, t, d_out]
gate = self.gate(x)
# Collect token outputs from recurrent state updates
outputs = []
# Process tokens sequentially: each step below is one of Eqs. 1-4
for i in range(num_tokens):
# Current token's vectors and gates
# [b, nh, t, hd] -> [b, nh, hd]; [b, t, nh] -> [b, nh, 1, 1]
q, k, v = queries[:, :, i], keys[:, :, i], values[:, :, i]
alpha_i = alpha_final[:, i, :, None, None]
beta_i = beta[:, i, :, None, None]
# Eq. 1, decay: S' = alpha * S
# [b, nh, 1, 1] * [b, nh, hd, hd] -> [b, nh, hd, hd]
S = alpha_i * S
# Eq. 2, read what S' stores under k: v_hat = S'^T k (computed as the row k^T S')
# [b, nh, 1, hd] @ [b, nh, hd, hd] -> [b, nh, 1, hd] -> [b, nh, hd]
v_hat = (k.unsqueeze(-2) @ S).squeeze(-2)
# Eq. 3, write the correction under k: S = S' + beta * k (v - v_hat)^T
# [b, nh, hd, 1] @ [b, nh, 1, hd] -> [b, nh, hd, hd]
S = S + beta_i * (k.unsqueeze(-1) @ (v - v_hat).unsqueeze(-2))
# Eq. 4, read the updated memory with the query: o = S^T q (as the row q^T S)
# [b, nh, 1, hd] @ [b, nh, hd, hd] -> [b, nh, 1, hd]
outputs.append(q.unsqueeze(-2) @ S)
# Concatenate outputs along sequence dimension
# t x [b, nh, 1, hd] -> [b, nh, t, hd]
# [b, nh, t, hd] -> [b, t, nh, hd]
context_vec = torch.cat(outputs, dim=2).transpose(1, 2)
# Normalize output over head dimension
# [b, t, nh, hd] -> [b, t, nh, hd]
context_vec = self.norm(context_vec)
# Combine heads into embedding dimension
# [b, t, nh, hd] -> [b, t, d_out]
context_vec = context_vec.reshape(b, num_tokens, self.d_out)
# Apply input-dependent output gate
# [b, t, d_out] * [b, t, d_out] -> [b, t, d_out]
context_vec = context_vec * F.silu(gate)
# Final projection to mix information between heads
# [b, t, d_out] @ [d_out, d_out] -> [b, t, d_out]
context_vec = self.out_proj(context_vec)
return context_vec
In a serious training run, it's recommended to use the optimised version in the fla library which has an extraordinary amount of work poured into it, along with a few tricks to optimise the token-wise read–write loop seen in the code above.
Below is a comparison of validation loss (held-out cross-entropy) between a softmax transformer and GDN (with parameter parity) trained for 2.5B tokens on FineWeb-Edu and evaluated on a held-out subset of it. As you can see, there is a ~5% improvement (which is quite large w.r.t. other architecture improvements). However, we have to be careful, as this is a 124M model, with 2.5B tokens and gaps can close with more training.

Figure 1: Held-out validation loss comparing softmax and GDN attention mechanisms.
Assume we have
In contrast, for GDN, the cache scales with
The FLOPs required for the attention operation also scale favourably for linear attention. For softmax, the FLOPs for attention scale quadratically with the context size,
| Method | Memory scaling | Attention FLOPs | Loss delta |
|---|---|---|---|
| Softmax | 0.0% | ||
| GDN | −5% |
These amazing features of linear attention aren't free. Since we don't keep exact