⌂ 全部教程 easyaioffer.github.io ⭐ Star

linear_sparse_attention.py

配套教程:线性 / 稀疏注意力 · EN

Linear / Sparse Attention - minimal runnable implementation

在 GitHub 查看 原始 .py python linear_sparse_attention.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""
Linear / Sparse Attention - minimal runnable implementation
===========================================================

Educational PyTorch reference for linear attention, the chunkwise-parallel
bridge, the delta rule (DeltaNet), and trainable block-sparse attention.
Standalone script: runs six sanity checks on CPU in a few seconds.

Pairs with: docs/tutorials/linear_sparse_attention_tutorial.md (concept reference).

What it demonstrates:
    linear_attn_recurrent  - token-by-token causal linear attention, matrix state S in R^{d_k x d_v}
    linear_attn_chunkwise  - intra-chunk quadratic + inter-chunk O(1) state recurrence
    delta_rule_recurrent   - DeltaNet update S <- S(I - beta k k^T) + beta v k^T (overwrite)
    block_topk_attention   - keep softmax, attend only top-k key blocks (NSA / MoBA "select" idea)
    Sanity checks:
      [a] chunkwise == recurrent (the core equivalence) for chunk sizes C in {1, 4, 7, L}
      [b] chunk-size invariance: output is identical for every C (only the compute path changes)
      [c] linear-attn state is a [d_k, d_v] matrix independent of L (constant-memory, O(1) decode)
      [d] delta rule degenerates to additive linear attention when the erase term is dropped & beta=1
      [e] delta != additive for non-orthogonal keys (overwrite term is active);
          delta == additive for orthonormal keys & beta=1 (the precise sufficient condition)
      [f] block-sparse keeps exactly top-k blocks & softmax rows sum to 1,
          for BOTH divisible (L=12) and non-divisible (L=13) lengths

Run:
    python linear_sparse_attention.py
"""
import torch
import torch.nn.functional as F

torch.manual_seed(0)


def linear_attn_recurrent(Q, K, V):
    """Token-by-token causal linear attention. Q,K,V: [L, d] -> O: [L, d].

    State S: [d_k, d_v] matrix (here d_k = d_v = d), accumulated outer products
    sum_{j<=t} k_j v_j^T. Self-inclusive: S is updated before reading o_t.
    """
    L, d = Q.shape
    S = torch.zeros(d, d, dtype=Q.dtype)              # [d_k, d_v] matrix state
    O = torch.zeros(L, d, dtype=Q.dtype)
    for t in range(L):
        S = S + torch.outer(K[t], V[t])               # S += k_t v_t^T
        O[t] = Q[t] @ S                               # o_t = q_t^T S
    return O


def linear_attn_chunkwise(Q, K, V, C=4):
    """Chunkwise parallel form. Mathematically identical to the recurrent form.

    Per chunk: inter = Q_c @ S (history state, tokens strictly before this chunk),
    intra = ((Q_c K_c^T) * tril) @ V_c (in-chunk causal, diagonal inclusive),
    then update S += K_c^T V_c. Handles ragged last chunk (L not divisible by C).
    """
    L, d = Q.shape
    S = torch.zeros(d, d, dtype=Q.dtype)              # cross-chunk state [d_k, d_v]
    O = torch.zeros(L, d, dtype=Q.dtype)
    tri = torch.tril(torch.ones(C, C, dtype=Q.dtype))  # in-chunk causal mask, diagonal incl.
    for s in range(0, L, C):
        e = min(s + C, L)
        Qc, Kc, Vc = Q[s:e], K[s:e], V[s:e]           # [c, d]
        c = e - s
        m = tri[:c, :c]                               # ragged-safe slice
        inter = Qc @ S                                # history-state contribution [c, d]
        intra = ((Qc @ Kc.t()) * m) @ Vc             # in-chunk causal attention [c, d]
        O[s:e] = inter + intra
        S = S + Kc.t() @ Vc                           # update state [d_k, d_v]
    return O


def delta_rule_recurrent(K, V, beta, use_delta=True):
    """DeltaNet recurrence. K,V: [L, d] (k assumed unit-norm), beta: [L] -> states [L, d, d].

    S_t = S_{t-1}(I - beta_t k_t k_t^T) + beta_t v_t k_t^T : the (I - beta k k^T) term
    first ERASES the old association in the k direction, then writes the new v k^T.
    use_delta=False with beta=1 falls back to additive linear attention S += v k^T.
    """
    L, d = K.shape
    S = torch.zeros(d, d, dtype=K.dtype)              # [d_k, d_v] matrix state
    I = torch.eye(d, dtype=K.dtype)
    states = []
    for t in range(L):
        k = K[t]                                      # [d]
        if use_delta:
            S = S @ (I - beta[t] * torch.outer(k, k))  # erase old association in k direction
        S = S + beta[t] * torch.outer(V[t], k)        # write new v k^T  (order: v k^T)
        states.append(S.clone())
    return torch.stack(states)                        # [L, d, d]


def block_topk_attention(Q, K, V, block=4, topk=2):
    """Top-k block-sparse attention: each query attends only its top-k key blocks
    (in-block softmax). Q,K,V: [L, d] -> out: [L, d], keep_blk: [L, nblk] bool.

    Block representative score = MEAN over the block's REAL key columns only (padding
    ignored), so L not divisible by `block` is handled correctly. The final attention
    still uses the original dense scores, restricted to the selected blocks.
    """
    L, d = Q.shape
    nblk = (L + block - 1) // block
    scores = (Q @ K.t()) / (d ** 0.5)                 # [Lq, Lk] full scores (select + final attn)
    pad = nblk * block - L
    # masked block mean: pad scores with 0, count real columns, divide by the count
    sc  = F.pad(scores, (0, pad), value=0.0).view(L, nblk, block)
    cnt = F.pad(torch.ones_like(scores), (0, pad)).view(L, nblk, block)
    blk_score = sc.sum(-1) / cnt.sum(-1).clamp(min=1)  # [Lq, nblk] mean over real cols only
    topk = min(topk, nblk)
    sel = blk_score.topk(topk, dim=-1).indices         # [Lq, topk]
    keep_blk = torch.zeros(L, nblk, dtype=torch.bool)
    keep_blk.scatter_(1, sel, True)                    # [Lq, nblk] True = keep this block
    keep = keep_blk.repeat_interleave(block, dim=1)[:, :L]   # token-level mask [Lq, Lk]
    masked = scores.masked_fill(~keep, float("-inf"))
    w = F.softmax(masked, dim=-1)                      # in-selected-block normalized, rows sum to 1
    return w @ V, keep_blk


def orthonormal_rows(n, d):
    """Return n orthonormal row vectors in R^d (n <= d), via QR of a random matrix."""
    q, _ = torch.linalg.qr(torch.randn(d, d))          # q: [d, d] orthonormal columns
    return q.t()[:n]                                   # [n, d] orthonormal rows


def main():
    L, d = 13, 6                                       # ragged: L not divisible by C below
    Q, K, V = torch.randn(L, d), torch.randn(L, d), torch.randn(L, d)
    ref = linear_attn_recurrent(Q, K, V)

    # [a] chunkwise == recurrent (core equivalence) across chunk sizes
    sizes = [1, 4, 7, L]
    max_diff = max((linear_attn_chunkwise(Q, K, V, C=c) - ref).abs().max().item() for c in sizes)
    a_ok = max_diff < 1e-5
    print(f"[a] chunkwise == recurrent, C in {sizes}: max |Δ| = {max_diff:.2e}  "
          f"{'OK' if a_ok else 'FAIL'}")
    assert a_ok

    # [b] chunk-size invariance: every C gives the SAME output (path changes, result doesn't)
    outs = [linear_attn_chunkwise(Q, K, V, C=c) for c in sizes]
    b_ok = all(torch.allclose(o, outs[0], atol=1e-5) for o in outs)
    print(f"[b] chunk-size invariance over C in {sizes}: all equal = {b_ok}  "
          f"{'OK' if b_ok else 'FAIL'}")
    assert b_ok

    # [c] linear-attn state is [d_k, d_v], independent of L (constant memory, O(1) decode)
    shapes = set()
    S = torch.zeros(d, d)
    for t in range(L):
        S = S + torch.outer(K[t], V[t])
        shapes.add(tuple(S.shape))
    c_ok = shapes == {(d, d)}
    print(f"[c] state shape over all {L} steps = {shapes} (expect {{({d}, {d})}}), L-independent  "
          f"{'OK' if c_ok else 'FAIL'}")
    assert c_ok

    # [d] delta rule -> additive linear attention when erase term dropped & beta=1
    Kn = F.normalize(torch.randn(L, d), dim=-1)
    beta1 = torch.ones(L)
    S_no_delta = delta_rule_recurrent(Kn, V, beta1, use_delta=False)[-1]   # additive
    S_additive = torch.zeros(d, d)
    for t in range(L):
        S_additive = S_additive + torch.outer(V[t], Kn[t])                 # plain S += v k^T
    d_ok = torch.allclose(S_no_delta, S_additive, atol=1e-6)
    print(f"[d] delta(use_delta=False, beta=1) == additive S+=vk^T: |Δ| = "
          f"{(S_no_delta - S_additive).abs().max():.2e}  {'OK' if d_ok else 'FAIL'}")
    assert d_ok

    # [e] overwrite term is real: delta != additive for non-orthogonal keys,
    #     but delta == additive for orthonormal keys & beta=1 (the precise condition)
    S_delta_rnd = delta_rule_recurrent(Kn, V, beta1, use_delta=True)[-1]
    diff_rnd = (S_delta_rnd - S_no_delta).norm().item()                    # expect > 0
    Ko = orthonormal_rows(d, d)                                            # L=d orthonormal keys
    Vo = torch.randn(d, d)
    be = torch.ones(d)
    S_delta_o = delta_rule_recurrent(Ko, Vo, be, use_delta=True)[-1]
    S_add_o = delta_rule_recurrent(Ko, Vo, be, use_delta=False)[-1]
    diff_orth = (S_delta_o - S_add_o).abs().max().item()                  # expect ~0
    e_ok = diff_rnd > 1e-3 and diff_orth < 1e-5
    print(f"[e] overwrite term: non-orthogonal ||Δ|| = {diff_rnd:.3e} (>0), "
          f"orthonormal |Δ| = {diff_orth:.2e} (~0)  {'OK' if e_ok else 'FAIL'}")
    assert e_ok

    # [f] block-sparse keeps exactly top-k blocks & softmax rows sum to 1,
    #     for both divisible (L=12) and non-divisible (L=13) lengths
    f_ok = True
    for Lf in (12, 13):
        Qf, Kf, Vf = torch.randn(Lf, d), torch.randn(Lf, d), torch.randn(Lf, d)
        out, keep_blk = block_topk_attention(Qf, Kf, Vf, block=4, topk=2)
        nblk = (Lf + 3) // 4
        kept = keep_blk.sum(dim=-1)                                        # blocks kept per query
        exact = int((kept == min(2, nblk)).all())
        rows_sum1 = torch.allclose((F.softmax(
            (Qf @ Kf.t() / d ** 0.5).masked_fill(
                ~keep_blk.repeat_interleave(4, 1)[:, :Lf], float("-inf")), dim=-1)).sum(-1),
            torch.ones(Lf), atol=1e-5)
        ok = bool(exact) and rows_sum1 and out.shape == (Lf, d)
        f_ok = f_ok and ok
        print(f"[f] L={Lf}: blocks kept/query = {kept.unique().tolist()} (expect [2]), "
              f"rows sum to 1 = {rows_sum1}  {'OK' if ok else 'FAIL'}")
    assert f_ok

    print("\nall linear / sparse attention sanity checks passed ✓")


if __name__ == "__main__":
    main()