GPT-2 から Kimi K3 まで、AI モデルの進化を解説
本文の状態
日本語全文を表示中
詳細モードで約16分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
Baseten Engineering
Baseten Engineering は GPT-2 のデコーダー型アーキテクチャとコード実装を解説し、Kimi K3 などへの技術的進化の基礎となる構造的特徴を提示する。
AI深層分析を開く2026年8月4日 10:44
AI深層分析
キーポイント
GPT-2 のアーキテクチャ定義
記事は GPT-2 がデコーダー型アーキテクチャであることを明確に定義し、その基本的な構造的特徴を解説する。
コードによる実装の明示
トークン埋め込みと位置埋め込みの入力処理からロジス値出力までの Python コードスニペットを提示し、内部動作を可視化する。
技術進化の文脈化
タイトルにおいて GPT-2 から Kimi K3 への比較を示唆し、技術的進歩の歴史的文脈を提供する。
入力の埋め込み処理
入力データはトークン埋め込みと位置埋め込みを受け取ることで処理を開始する。
トランスフォーマーブロックの構成
各ブロックはレイヤー正規化、因果自己注意機構、MLPから構成され、残差結合を用いて情報を伝達する。
重要な引用
GPT-2 is a decoder-only architecture:
tok_emb = self.transformer.wte(idx) # token embeddings of shape (b, t, n_embd)
pos_emb = self.transformer.wpe(pos) # position embeddings of shape (t, n_embd)
The input receives token and positional embeddings:
編集コメントを表示
編集コメント
この記事は GPT-2 の技術的基盤をコードと共に解説しており、現代の LLM がどのように発展してきたかの基礎知識として有用である。ただし、Kimi K3 などの最新モデルの詳細な比較や性能評価については本稿では言及されていない点に注意が必要だ。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。

GPT-2 のアーキテクチャ
GPT-2 はデコーダー専用(decoder-only)のアーキテクチャを採用しています。
tok_emb = self.transformer.wte(idx) # token embeddings of shape (b, t, n_embd)
pos_emb = self.transformer.wpe(pos) # position embeddings of shape (t, n_embd)
x = self.transformer.drop(tok_emb + pos_emb)
for block in self.transformer.h:
x = block(x)
x = self.transformer.ln_f(x)
logits = self.lm_head(x)
return logits入力データは、トークン埋め込みと位置埋め込みを受け取ります。
✕

各トランスフォーマーブロックを拡大して見ると、以下のようになります。
1class Block(nn.Module):
2 def __init__(self, config):
3 super().__init__()
4 self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
5 self.attn = CausalSelfAttention(config)
6 self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
7 self.mlp = MLP(config)
8
9 def forward(self, x):
10 x = x + self.attn(self.ln_1(x))
11 x = x + self.mlp(self.ln_2(x))
12 return x✕

アテンション(注意機構)のプロセスは以下の通りです。
1class CausalSelfAttention(nn.Module):
2
3 def __init__(self, config):
4 super().__init__()
5 assert config.n_embd % config.n_head == 0
6 # key, query, value projections for all heads, but in a batch
7 self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
8 # output projection
9 self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
10 # regularization
11 self.attn_dropout = nn.Dropout(config.dropout)
12 self.resid_dropout = nn.Dropout(config.dropout)
13 self.n_head = config.n_head
14 self.n_embd = config.n_embd
15 self.dropout = config.dropout
16
17 def forward(self, x):
18 B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)
19
20 # calculate query, key, values for all heads in batch and move head forward to be the batch dim
21 q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
22 k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
23 q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
24 v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
25
26 # manual implementation of attention
27 att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
28 att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
29 att = F.softmax(att, dim=-1)
30 att = self.attn_dropout(att)
31 y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
32 y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side
33
34 # output projection
35 y = self.resid_dropout(self.c_proj(y))
36 return y最終的な隠れ状態行列が生成されると、言語モデルのヘッドがこれを語彙のロジット値に変換します。自己回帰デコーディングにおいては、次のトークンを選択するために必要な情報は、最終位置におけるロジットだけです。
これはデコーダー専用生成における非効率性の一つです。モデルは入力位置すべてに対して表現を計算しますが、各デコードステップで消費されるのは最終位置のロジットのみです。キャッシュ(記憶領域)を使わない場合、次のトークンを生成する際にこれらの計算が繰り返されてしまいます。
✕

キャッシュがない場合、トークン n+1 に対して同じプロセスが繰り返されます。
KV Cache の発明
KV キャッシュは、生成されたトークンを入力に追加した後にモデルが過去のすべてのトークンに対する投影を再計算してしまうという、極めて単純な観察から生まれました。そのキーベクトルとバリューベクトルを保存しておくことで、重複する計算を回避できます。
これが KV キャッシュです。過去 N-1 トークンのベクトルを保持するため、容量が膨大になり、メモリ帯域幅のボトルネックとなる可能性があります。

サイズと規模
全体として、約 5 万トクンの語彙数、12 ブロック、12 ヘッド、埋め込み次元が 768 の場合、ベースラインモデルのパラメータ数は約 1.24 億です。
vocab_size: int = 50304 # GPT-2 vocab_size of 50257, padded up to nearest multiple of 64 for efficiency
n_layer: int = 12
n_head: int = 12
n_embd: int = 7682.8 兆パラメータを持つ Kimi K3 モデル 1 つは、GPT-2 モデルを約 22,580 個分と同等のパラメータ数を含んでいることになります。
リニアアテンション
ソフトマックス・アテンションでは、q と k の積の後に非線形性を適用し、すべてのクエリがすべてのキーに結合されます。一方、リニア・アテンションは ELU+1 などの特徴写像を q と k に個別に適用します。これにより積の再結合が可能になり、増加する K と V のベクトル群を固定された D×D の状態に圧縮できます。
この論文が「O(N²)」と記述している部分は、2020 年当時の文脈を知らないと誤解されやすいものです。当時は、N×N のアテンション行列を明示的に計算することが一般的で、FlashAttention も存在せず、リファレンスとなる自己回帰モデルの実装では KV キャッシュなしにトークン履歴を再計算するケースが多かったのです。

KV キャッシュは再計算を回避しますが、デコードステップごとにすべての過去のキーと値を読み込む必要があります。その結果、1 ステップあたりのコストは O(ND) となり、キャッシュのサイズも O(ND) で増加します。N トークンを生成する総コストは O(N²D) です。実際には、HBM からこのキャッシュを繰り返しストリーミングすることがボトルネックとなります。
線形アテンションでは、増大するキャッシュの代わりに固定された D×D の状態を使用します。N トークン全体での計算量は O(ND²) になります。デコードは依然として逐次処理ですが、N が D より十分大きい場合、固定された状態によりメモリアクセスを削減し、大幅な高速化を実現できます。1000 倍の改善という主張は、通常は古いキャッシュレスベースラインとの比較に基づいています。現代の KV キャッシュ実装と比較した場合、その効果はより控えめなものになります。
実装の違いを理解するには、従来の KV キャッシュから始めるのが最もわかりやすいでしょう:
1def forward(self, x, mask=None, past_kv=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 # at prefill, q,k,v have shapes b,h,t,d
13 # at decode, shape is b, h, 1, d
14 # so i cat at the t dimension, dim(2)
15
16 if past_kv is not None:
17 k_past=past_kv[0]
18 v_past=past_kv[1]
19 k=torch.cat((k_past, k), dim=2)
20 v=torch.cat((v_past, v), dim=2)
21
22 scores=(q@k.transpose(-1,-2))/math.sqrt(d_head)
23 if past_kv is None: #we're in prefill and need to mask
24 causal_mask=torch.ones(t,t,dtype=bool, device=q.device)
25 causal_mask=torch.triu(causal_mask, diagonal=1)
26 scores=scores.masked_fill(causal_mask, float('-inf'))
27
28 if mask is not None:
29 scores=scores.masked_fill(~mask, float('-inf'))
30
31 #get attn (bhtt x bhtd)
32 attn=scores.softmax(-1)#bhtt
33 o=attn@v #bhtd
34 o=o.transpose(1,2).contiguous().view(b,t,d) #b,t,d
35
36 # use x to get qkv
37 o_proj=self.o_proj(o)
38 past_kv=(k, v)
39 return o_proj, past_kv同じプロセスを視覚的に捉えることも容易です。各デコードステップでは HBM に対して 2 回の ND 読み込みと 2 回の 1D 書き込みが行われ、KV キャッシュはシーケンス長に応じて O(N) で線形に成長します。
✕

この論文では、過剰な読み書きを置き換える手法として以下のものを提案しています。
1def forward(self, x, mask=None, cache=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 k=F.elu(k)+1
13 k=k.transpose(-1,-2)
14 q=F.elu(q)+1
15
16 S,z=cache if cache is not None else (0.0, 0.0)
17 S=S+k@v
18 z=z+k
19
20 o=q@S #bhtd
21 denom=q@z
22 o_scaled=o/denom
23 o_scaled=o_scaled.transpose(1,2).contiguous().view(b,t,d)
24 o_proj=self.o_proj(o_scaled)
25 cache=(S,z)
26
27 return o_proj, cacheここにはトレードオフが存在します。ここでは、softmax が使用する指数関数を置き換え、クエリ(q)とキー(k)が相互作用する前にそれぞれに ELU+1 を適用しています。両方のアプローチは結果のスコアを正規化しますが、線形アテンションで使用される特徴マップは、ソフトマックスカーネルの表現力の低い近似です。この近似は忠実度を低下させる可能性がありますが、実際の精度の低下はアーキテクチャやワークロードに依存します。

図では簡略化のため省略されていますが、qk の合計値で割る処理は依然として行われています。高レベルで見ると、アテンションには 3 つのステップがあります。
- qk スコアを非負(ゼロ以上)にする。線形アテンションでは ELU+1 を使用し、ソフトマックスでは指数関数を使用します。
- 合計値で割る。
- バリューの加重平均を計算する。
これは基本的なアテンションの契約を保ちつつ、qk スコアを非負にするために表現力の低い特徴マップを利用しています。
DeltaNet (高速重みプログラマー)
有限サイズのキャッシュでは、既存の情報に上書きするか統合する必要があります。トークン i-1 の状態は独自のスロットを受け取らず、同じ D 行列に追加されます。そのため、新しいクエリはもはや各以前のトークンの完全に孤立した表現を retrieval できなくなります。
この追加計算こそが、効率化の源泉です。キャッシュを結合するのではなく加算的に更新することで、O(N) の成長を防ぐことができますが、同じ操作によって情報の干渉が生じます。DeltaNet はこの回復性の喪失に対処します。

Schlag らの論文『Fast Weight Programmers』は、この問題を巧みに指摘しています。「シーケンス長が記憶容量を超えると、モデルは過負荷状態に陥る可能性があります。このような状況下で適切に動作させるには、モデルは記憶内容と動的に対話し、どのキー・バリュー連合を保持し、どれを削除するかを選択的に決定できるよう学習する必要があります。純粋に加算的な指示はこの目的には不適切です……有限サイズの記憶に新しい連合を無限に加え続けることは(式 17 のように)、必ず限界に達します」。
N が D よりもはるかに大きいという線形アテンションが有利に働く状況こそ、その最大の弱点を露呈させるものです。状態が実効的な容量を超えると、更新が加算的でありキャッシュから何かが削除されないため、連合同士が干渉し始めます。
1def forward(self, x, mask=None, cache=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 q = F.normalize(F.silu(q), dim=-1)
13 k = F.normalize(F.silu(k), dim=-1)
14 beta = torch.sigmoid(self.w_beta(x)).view(b, 1, t, 1)
15 # new: per-token write strength
16
17 S = cache if cache is not None else 0.0
18
19 v_old = k @ S # read the board at this key
20 u = beta * (v - v_old) # the delta: only what's actually new
21 S = S + k.transpose(-1, -2) @ u # same outer-product write as before
22
23 o = q @ S # read, no denominator
24 o = o.transpose(1, 2).contiguous().view(b, t, d)
25 return self.o_proj(o), S視覚的な例を示せば、この仕組みはより理解しやすくなります。
✕

S = k.T @ v という単一の連想を考えると、同じキーで読み出すと k @ (k.T @ v) となり、これは (k @ k.T) v、つまりキーの二乗ノルム倍された v が得られます。つまり、読み出し結果はキーの二乗ノルムによってスケーリングされます。k を単位長に正規化するか、あるいは結果をそのノルムで割れば、v が正確に戻ってきます。これがコード内で F.normalize が使われている理由です。これにより読み出しが正確になり、後述する消去項も実際の射影として機能します。
Q もまた学習されたポインタです。Wq と Wk は同じ残差ストリームを読み込みます。ある事実に対するクエリは、その事実が書き込まれたキーの方向を指し示します。更新処理ではまず、現在のキーがキャッシュからどのような情報を取得するかを確認します。そして、既存の情報を格納したい値から差し引き、その差分にキーを乗じて結果を加算します。これにより古い情報が取り除かれ、新しい情報がその場所に書き込まれます。
DeltaNet (デルタ則を用いた線形トランスフォーマーの並列化)
この記事で最も難解なセクションです。私自身、実装を理解するのに約 7 時間かかりました。そのため、ここでは実装から解説を構築していきます。要するに、DeltaNet は一般化されたハウスホルダー遷移行列を用いた 1 次線形再帰を実装しており、ハードウェア効率の高い線形時間のトレーニングのために、チャンクごとの並列前向きパスを可能にします。
この手法は入出力をサイズ C の複数のチャンクに分割し、各チャンクの出力を「前のチャンクの最終状態」と「現在のチャンクのクエリ・キー・バリューブロック」に基づいて計算します。
実務的な課題はプリフィル(初期入力処理)にあります。T トークンのシーケンス全体に対してデルタ則を直接実装すると、以下のようになります。
1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t):
4 k_i = k[:, :, i:i+1]
5 v_i = v[:, :, i:i+1]
6 b_i = beta[:, :, i:i+1]
7 v_old = k_i @ S
8 u_i = b_i * (v_i - v_old)
9 S = S + k_i.transpose(-1, -2) @ u_i # write
10 outs.append(q[:, :, i:i+1] @ S)
11o = torch.cat(outs, dim=2)標準アテンションとは異なり、この定式化では各キーベクトルごとに補正が必要となるため、並列行列乗算への道筋は直ちに明らかではありません。デルタ則を使わない場合でも、線形アテンションの直接プリフィルは依然として逐次的です。
1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t):
4 q = q[:, :, i:i+1]
5 k = k[:, :, i:i+1]
6 v = v[:, :, i:i+1]
7
8 S=S_old+k@v
9 o=q@S #bhtd
10 o=self.norm(o)
11 o=o.transpose(1, 2).contiguous().view(b, t, d)
12
13 out=self.o_proj(o)
14 cache=S
15 outs.append(out)
16
17o = torch.cat(outs, dim=2)チャンク化された定式化の方がより効率的なアプローチとなります。その仕組みは具体例を通じて理解しやすくなります。
✕

C=N と設定すると標準的な O(N²) アテンションが復元され、C=1 にすると通常の線形アテンションになります。中間値は、チャーク内の追加計算とハードウェア利用率の向上をトレードオフするものです。実際には、テンソルコア命令がこの粒度で効率的に動作するため、C は 64 または 128 であることが多く、UMMA がその一例です。
中間タイルは状態更新の一部として S に折りたたまれます:
✕

1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t//C):
4 q_c = q[:, :, i*C:(i+1)*C]
5 k_c = k[:, :, i*C:(i+1)*C]
6 v_c = v[:, :, i*C:(i+1)*C]
7
8 o_prev=q_c@S #this is everything up to this block
9
10 attn=(q_c@k_c.transpose(-1,-2)).tril() #masked attention
11 o_curr=attn@v_c
12
13 o=o_prev+o_curr
14
15 S_new=k_c.transpose(-1,-2)@v_c #recurrent attention
16 S=S+S_new
17 outs.append(o)
18
19o = torch.cat(outs, dim=2)ブロック内では q(kᵀv) を計算します。これはスコアを先に計算する、マスク付きの通常の注意順序です。一方、ブロック間では (kᵀv)q の順で進めるため、再帰的な順序、つまり状態を先に処理することになります。
アテンションは O(N²) で成長しますが、この手法はそうではありません。ブロック内では実際のアテンション(マスク付きの QKᵀ と V の積)を実行し、ブロック間ではすべての情報を状態に折りたたんで、1 つの行列乗算で読み出します。つまりコストは 2 つに分かれます。固定された部分として 2Ld² があり、これは状態処理に関わるもので C に依存しません。また、成長する部分として 2LCd があり、これは対角線上に配置されるスコア行列です。完全なアテンションとは、C が L と等しい場合で、このとき第 2 の項は 2L²d となり二次関数的になります。したがって、C を小さくするほど FLOPs(浮動小数点演算数)を減らすことができます。
純粋な FLOP 数の観点では C=1 が最も安価ですが、必ずしも実行時間(壁時計時間)が最短とは限りません。GPU は、作業が行列乗算ハードウェアに効率的に割り当てられる場合に、より高速に演算を完了できます。
次のステップは、このアプローチを DeltaNet へ拡張することです。

数式の細部は複雑ですが、本質的な問題は単純です。純粋な加算アテンション向けに設計されたチャンキング手法を、デルタ更新(delta updates)にそのまま適用することはできません。
v_old = k_i @ S
u_i = b_i * (v_i - v_old)つまり、差し引くべき情報を計算するには、すべての状態値が必要になります。何らかの数式的な再パラメータ化を行わない限り、同じ方法で並列処理を行うことはできないのです。
そのため、著者たちはデルタ更新の式を以下のように書き換えました。
u=v_new-v_old
S_t= S_(t-1)+K.T@u
o=q@S_Tここでは、逐次的なループが各イテレーションごとに 1 つのデルタ値を計算します。これを再パラメータ化した形は以下の通りです。
<c
原文を表示
✕

GPT-2
GPT-2 is a decoder-only architecture:
tok_emb = self.transformer.wte(idx) # token embeddings of shape (b, t, n_embd)
pos_emb = self.transformer.wpe(pos) # position embeddings of shape (t, n_embd)
x = self.transformer.drop(tok_emb + pos_emb)
for block in self.transformer.h:
x = block(x)
x = self.transformer.ln_f(x)
logits = self.lm_head(x)
return logitsThe input receives token and positional embeddings:
✕

Each transformer block, zoomed in, looks like this:
1class Block(nn.Module):
2 def __init__(self, config):
3 super().__init__()
4 self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
5 self.attn = CausalSelfAttention(config)
6 self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
7 self.mlp = MLP(config)
8
9 def forward(self, x):
10 x = x + self.attn(self.ln_1(x))
11 x = x + self.mlp(self.ln_2(x))
12 return x✕

The attention process looks like this:
1class CausalSelfAttention(nn.Module):
2
3 def __init__(self, config):
4 super().__init__()
5 assert config.n_embd % config.n_head == 0
6 # key, query, value projections for all heads, but in a batch
7 self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
8 # output projection
9 self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
10 # regularization
11 self.attn_dropout = nn.Dropout(config.dropout)
12 self.resid_dropout = nn.Dropout(config.dropout)
13 self.n_head = config.n_head
14 self.n_embd = config.n_embd
15 self.dropout = config.dropout
16
17 def forward(self, x):
18 B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)
19
20 # calculate query, key, values for all heads in batch and move head forward to be the batch dim
21 q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
22 k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
23 q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
24 v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
25
26 # manual implementation of attention
27 att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
28 att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
29 att = F.softmax(att, dim=-1)
30 att = self.attn_dropout(att)
31 y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
32 y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side
33
34 # output projection
35 y = self.resid_dropout(self.c_proj(y))
36 return yOnce the final hidden-state matrix is produced, the language-model head maps it into vocabulary logits. During autoregressive decoding, only the logits at the final position are needed to select the next token.
This is an inefficiency of decoder-only generation: the model computes representations for every input position, but each decode step consumes only the final position’s logits. Without caching, much of that work would be repeated for the next token.
✕

Without a cache, we then repeat the process for token n+1.
KV Cache Invention
The KV cache comes from a straightforward observation: after appending the generated token to the input, the model would otherwise recompute projections for all previous tokens. Storing their key and value vectors avoids that redundant work.
That storage is the KV cache. It retains vectors for the previous N-1 tokens and can become large enough to create a memory-bandwidth bottleneck.
✕

Size and scale
Overall, with about 50k possible tokens, 12 blocks, 12 heads, and an embedding dimension of 768, our baseline model is about 124M parameters.
vocab_size: int = 50304 # GPT-2 vocab_size of 50257, padded up to nearest multiple of 64 for efficiency
n_layer: int = 12
n_head: int = 12
n_embd: int = 768At 2.8 trillion parameters, one Kimi K3 model contains roughly as many parameters as 22,580 GPT-2 models.
Linear attention
Softmax attention applies its nonlinearity after the q·k product, coupling every query to every key. Linear attention instead applies a feature map, such as ELU+1, to q and k separately. This makes the product reassociable, so the growing set of K and V vectors can be folded into a fixed D×D state.
The paper’s O(N²) framing is easy to misread without its 2020 context. At the time, training commonly materialized the full N×N attention matrix, FlashAttention did not exist, and reference autoregressive implementations often recomputed the token history without a KV cache.
✕

A KV cache avoids recomputation, but each decode step still reads all previous keys and values. The per-step cost grows as O(ND), the cache grows as O(ND), and generating N tokens costs O(N²D) in total. In practice, repeatedly streaming that cache from HBM is the bottleneck.
Linear attention replaces the growing cache with a fixed D×D state. Across N tokens, the work becomes O(ND²). Decode remains sequential, but when N is much larger than D, the fixed state can reduce memory traffic and deliver substantial speedups. Claims of thousand-fold improvements generally compare against the older cacheless baseline; gains over a modern KV-cached implementation are more modest.
The implementation difference is easiest to see by starting with the conventional KV cache:
1def forward(self, x, mask=None, past_kv=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 # at prefill, q,k,v have shapes b,h,t,d
13 # at decode, shape is b, h, 1, d
14 # so i cat at the t dimension, dim(2)
15
16 if past_kv is not None:
17 k_past=past_kv[0]
18 v_past=past_kv[1]
19 k=torch.cat((k_past, k), dim=2)
20 v=torch.cat((v_past, v), dim=2)
21
22 scores=(q@k.transpose(-1,-2))/math.sqrt(d_head)
23 if past_kv is None: #we're in prefill and need to mask
24 causal_mask=torch.ones(t,t,dtype=bool, device=q.device)
25 causal_mask=torch.triu(causal_mask, diagonal=1)
26 scores=scores.masked_fill(causal_mask, float('-inf'))
27
28 if mask is not None:
29 scores=scores.masked_fill(~mask, float('-inf'))
30
31 #get attn (bhtt x bhtd)
32 attn=scores.softmax(-1)#bhtt
33 o=attn@v #bhtd
34 o=o.transpose(1,2).contiguous().view(b,t,d) #b,t,d
35
36 # use x to get qkv
37 o_proj=self.o_proj(o)
38 past_kv=(k, v)
39 return o_proj, past_kvThe same process is easier to see visually. Each decode step performs two ND reads and two 1D writes to HBM, while the KV cache grows linearly, in O(N), with the sequence length.
✕

Notice the excessive reads and writes, which this paper replaces with:
1def forward(self, x, mask=None, cache=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 k=F.elu(k)+1
13 k=k.transpose(-1,-2)
14 q=F.elu(q)+1
15
16 S,z=cache if cache is not None else (0.0, 0.0)
17 S=S+k@v
18 z=z+k
19
20 o=q@S #bhtd
21 denom=q@z
22 o_scaled=o/denom
23 o_scaled=o_scaled.transpose(1,2).contiguous().view(b,t,d)
24 o_proj=self.o_proj(o_scaled)
25 cache=(S,z)
26
27 return o_proj, cacheThere is a trade-off. Here, we replace the exponential used by softmax with ELU+1 applied separately to q and k before they interact. Both approaches normalize the resulting scores, but the feature map used by linear attention is a less expressive approximation of the softmax kernel. That approximation can reduce fidelity, although the practical accuracy loss depends on the architecture and workload.
✕

Notice that we still divide by the sum of qk, which is omitted from the diagram for simplicity. At a high level, attention consists of three steps:
- Make the qk scores non-negative. Linear attention uses ELU+1, while softmax uses exponentiation.
- Divide by the sum.
- Compute the weighted average of the values.
This preserves the basic attention contract, but uses a less expressive feature map to make the QK scores non-negative.
DeltaNet (Fast weight programmers)
A finite cache must overwrite or combine with information already stored. The state from token i-1 does not receive its own slot; it is added to the same D by D matrix. New queries can therefore no longer retrieve a perfectly isolated representation of each earlier token.
That addition is also the source of the efficiency gain. Updating the cache additively rather than by concatenation prevents it from growing in O(N), but the same operation causes information to interfere. DeltaNet addresses this loss of recoverability.
✕

Eloquently put by Schlag’s paper (Fast Weight Programmers): “when the sequence length exceeds storage capacity, the model may end up in an overcapacity regime. To properly operate under such a regime, the model should learn to dynamically interact with the memory contents and selectively decide which key-value associations to keep and which ones to delete. The purely additive instruction may be inappropriate for this purpose…. endlessly adding new associations to a memory of finite size, as in Eq. 17, inevitably will reach a limit.“
The regime that makes linear attention attractive, where N is much larger than D, also exposes its main limitation. Once the state exceeds its effective capacity, associations begin to interfere because the update is additive and nothing leaves the cache.
1def forward(self, x, mask=None, cache=None):
2 # x is b,t,d
3 b,t,d=x.shape
4 d_head=d//self.num_heads
5 h=self.num_heads
6 qkv=self.qkv_proj(x)
7
8 q=qkv[:, :, :d].view(b,t,h,d_head).transpose(1,2)
9 k=qkv[:, :, d:2*d].view(b,t,h,d_head).transpose(1,2)
10 v=qkv[:, :, 2*d:].view(b,t,h,d_head).transpose(1,2)
11
12 q = F.normalize(F.silu(q), dim=-1)
13 k = F.normalize(F.silu(k), dim=-1)
14 beta = torch.sigmoid(self.w_beta(x)).view(b, 1, t, 1)
15 # new: per-token write strength
16
17 S = cache if cache is not None else 0.0
18
19 v_old = k @ S # read the board at this key
20 u = beta * (v - v_old) # the delta: only what's actually new
21 S = S + k.transpose(-1, -2) @ u # same outer-product write as before
22
23 o = q @ S # read, no denominator
24 o = o.transpose(1, 2).contiguous().view(b, t, d)
25 return self.o_proj(o), SA visual example makes this easier to follow.
✕

Take a single association written as S = k.T @ v. Read it back with the same key and you get k @ (k.T @ v), which is (k @ k.T) v, which is the squared norm of k times v. So the read comes back scaled by the key's squared norm, and if you normalize k to unit length, or just divide the result by that norm, you get v back exactly. This is why F.normalize sits in the code: it's what makes the read exact and what makes the erase term below an actual projection. Q is also a learned pointer. Wq and Wk read the same residual stream, and the query for a fact points at the key direction that fact was written into. The update first asks what information the current key retrieves from the cache. It subtracts that existing information from the value we want to store, multiplies the key by the difference, and adds the result back. Old information is removed and new information is written in its place.
DeltaNet (Parallelizing linear transformers with delta rule)
This is the most difficult section of the post. It took me about seven hours to develop a working understanding of it, so I will build the explanation from the implementation. In short, DeltaNet implements a first-order linear recurrence with generalized Householder transition matrices, enabling chunk-wise parallel forward passes for hardware-efficient linear-time training. It splits the inputs and outputs into several chunks of size C, and computes outputs for each chunk based on the final state of the previous chunk and the query key value blocks of the current chunk.
The practical problem is prefill. A direct implementation of the Delta rule over a sequence of T tokens would look like this:
1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t):
4 k_i = k[:, :, i:i+1]
5 v_i = v[:, :, i:i+1]
6 b_i = beta[:, :, i:i+1]
7 v_old = k_i @ S
8 u_i = b_i * (v_i - v_old)
9 S = S + k_i.transpose(-1, -2) @ u_i # write
10 outs.append(q[:, :, i:i+1] @ S)
11o = torch.cat(outs, dim=2)Unlike standard attention, this formulation requires a correction at every key vector, so the path to a parallel matrix multiplication is not immediately obvious. Even without the Delta rule, a direct linear-attention prefill remains sequential:
1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t):
4 q = q[:, :, i:i+1]
5 k = k[:, :, i:i+1]
6 v = v[:, :, i:i+1]
7
8 S=S_old+k@v
9 o=q@S #bhtd
10 o=self.norm(o)
11 o=o.transpose(1, 2).contiguous().view(b, t, d)
12
13 out=self.o_proj(o)
14 cache=S
15 outs.append(out)
16
17o = torch.cat(outs, dim=2)A chunked formulation provides a more efficient approach. The mechanics are easier to understand through an example:
✕

Setting C=N recovers standard O(N^2) attention, while C=1 gives regular linear attention. Intermediate values trade additional within-chunk work for better hardware utilization. In practice, C is often 64 or 128 because tensor-core instructions operate efficiently at that granularity; UMMA is one example.
The intermediate tiles are folded into S as part of the state update:
✕

1S = torch.zeros(b, h, dh, dh) if cache is None else cache
2outs = []
3for i in range(t//C):
4 q_c = q[:, :, i*C:(i+1)*C]
5 k_c = k[:, :, i*C:(i+1)*C]
6 v_c = v[:, :, i*C:(i+1)*C]
7
8 o_prev=q_c@S #this is everything up to this block
9
10 attn=(q_c@k_c.transpose(-1,-2)).tril() #masked attention
11 o_curr=attn@v_c
12
13 o=o_prev+o_curr
14
15 S_new=k_c.transpose(-1,-2)@v_c #recurrent attention
16 S=S+S_new
17 outs.append(o)
18
19o = torch.cat(outs, dim=2)Within a block, we do q(kᵀv). This is score first, the normal attention order with masking. Across blocks, we follow (kᵀv)q, so we’re doing recurrent order, state first.
Attention grows in O(N²) and this does not. Inside a block I do real attention (the masked QKᵀ times V), and across blocks I fold everything into the state and read it back with one matmul. So the cost splits in two. There's a fixed piece, 2Ld², which is the state work and doesn't care about C at all. And there's a growing piece, 2LCd, which is the score matrices sitting on the diagonal. Full attention is just the case where C equals L, and then that second term becomes 2L²d, quadratic. So the smaller I make C, the fewer FLOPs I do.
C=1 is the cheapest option in pure FLOP terms, but not necessarily in wall-clock time. A GPU can complete more arithmetic faster when the work maps efficiently onto its matrix-multiply hardware.
The next step is to extend the same approach to DeltaNet.
✕

The mathematical detail is substantial, but the underlying issue is simple: the chunking method used for purely additive attention does not directly apply to the delta updates:
v_old = k_i @ S
u_i = b_i * (v_i - v_old)This means we need every single state in order to compute the information that needs to be subtracted out. We can't parallelize it the same way without some mathematical reparameterization.
The authors therefore rewrite the delta updates from:
u=v_new-v_old
S_t= S_(t-1)+K.T@u
o=q@S_THere, a sequential loop computes one delta per iteration. The reparameterized form is:
News to Guide
ニュースの次に確認する
発表内容を、現在の料金や仕様と照らし合わせられる関連ガイドです。
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み