File size: 12,634 Bytes
6f75f79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57a73e4
 
 
 
 
 
 
 
 
 
 
 
 
6f75f79
 
 
 
 
 
 
 
 
57a73e4
6f75f79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b9a4ce
6f75f79
 
 
 
 
 
 
 
 
2b9a4ce
 
6f75f79
 
 
 
 
 
2b9a4ce
 
 
 
 
 
 
 
 
 
 
6f75f79
 
 
 
 
 
 
 
2b9a4ce
 
6f75f79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57a73e4
 
 
 
6f75f79
 
 
 
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""CounterMemoryFFN -- the architecture lever (verification-plan M1).

Replace a dense FFN (two d x 4d GEMMs, every weight touched by every token) with a *retrieval*
memory: a token forms a query, retrieves its top-k cells out of E via a product-key index, and
reads out a weighted sum of those k value rows. The value table is counter-state (0.75 byte/weight),
so the table can be made huge (capacity) WITHOUT growing the per-token active compute -- the index
is product-key, so retrieval costs O(sqrt(E)) dot-products, not O(E).

Why this fits the counter method exactly (no approximation, no bias):
    A cell that a token did NOT retrieve contributed nothing to that token's output, so its gradient
    is EXACTLY zero. Updating only the retrieved rows is therefore the exact gradient, not a sparse
    approximation. The counter optimizer lives in the value table; only retrieved rows tick.

Layout (single memory head):
    query q = W_q ln(x)              -> split into two half-queries q1,q2 in R^{dk}
    sub-keys K1,K2 in R^{m x dk}     (m = sqrt(E) per half; the full key set is the m x m product)
    per half: top-k over m sub-keys; combine the two half-scorelists -> top-k over the m^2 cells
    weights = softmax(top-k scores); y = sum_j weights_j * Value[cell_j]

The router (W_q, K1, K2) are small fp Parameters trained by the outer optimizer (negligible:
O(d*dk + sqrt(E)*dk)). The value table is the bulk and is counter-state, self-updating in backward.
Pure PyTorch, CPU/CUDA.
"""
from __future__ import annotations

import math

import torch
import torch.nn as nn

from .counter import decode_state, encode_state, stochastic_round

__all__ = ["CounterMemoryFFN", "CounterValueMemory"]


class _CounterValueRead(torch.autograd.Function):
    """Reads counter value rows for the given flat cell ids and, on backward, applies the counter
    RMS+SR update to exactly those rows (the exact-for-active gradient). Returns no grad to its
    inputs: the value table is counter-state, not an fp leaf -- it self-updates here."""

    @staticmethod
    def forward(ctx, mem, flat_ids, tap):
        # mem: CounterValueMemory; flat_ids: [P] int64 of cells to read (P = M*k, with repeats).
        rows = mem._decode_rows(flat_ids)          # [P, d] fp visible value rows
        ctx.mem = mem
        ctx.save_for_backward(flat_ids)
        return rows

    @staticmethod
    def backward(ctx, grad_rows):
        (flat_ids,) = ctx.saved_tensors
        if ctx.mem.update_enabled:
            ctx.mem._update_active(flat_ids, grad_rows)
        return None, None, None


class CounterValueMemory(nn.Module):
    """E value rows of width d, stored as counter state (ternary t + counter c, per-row scale).
    Retrieval gathers rows by id; the update ticks ONLY the rows that were read (exact-for-active).
    The RMS+SR tick mirrors RMSCounterLinear, but over a scattered row subset instead of a tile."""

    def __init__(self, n_cells: int, dim: int, *, C: int = 11, lr: float = 0.04,
                 lr_scale: float = 2e-4, init_gain: float = 1.0, rms_beta: float = 0.9,
                 rms_eps: float = 1e-3, value_compute: str = "counter") -> None:
        super().__init__()
        self.E = int(n_cells)
        self.d = int(dim)
        self.C = int(C)
        self.lr = float(lr)
        self.lr_scale = float(lr_scale)
        self.rms_beta = float(rms_beta)
        self.rms_eps = float(rms_eps)
        self.update_enabled = True
        if value_compute not in {"counter", "fp"}:
            raise ValueError("value_compute must be 'counter' or 'fp'")
        self.value_compute = value_compute

        t0 = torch.randint(-1, 2, (self.E, self.d), dtype=torch.int16)
        c0 = torch.zeros_like(t0)
        self.register_buffer("state", encode_state(t0, c0, C))
        s0 = init_gain * math.sqrt(3.0 / (2.0 * self.d))
        self.register_buffer("scale", torch.full((self.E, 1), s0, dtype=torch.float32))
        self.register_buffer("v", torch.zeros((self.E, 1), dtype=torch.float32))
        if value_compute == "fp":   # diagnostic arm: fp values (isolates architecture from quant)
            self.fp_values = nn.Parameter(torch.randn(self.E, self.d) * s0)
        self.register_buffer("rows_touched", torch.zeros((), dtype=torch.int64), persistent=False)
        # cell-utilization diagnostics (the M2-style 'ever visible' for the memory): which cells are
        # EVER retrieved, and how often. Reveals router starvation (dead cells) at large E.
        self.register_buffer("ever_retrieved", torch.zeros(self.E, dtype=torch.bool), persistent=False)
        self.register_buffer("retrieval_count", torch.zeros(self.E, dtype=torch.int64), persistent=False)

    @torch.no_grad()
    def _note_use(self, flat_ids: torch.Tensor) -> None:
        self.ever_retrieved[flat_ids] = True
        self.retrieval_count.index_add_(0, flat_ids, torch.ones_like(flat_ids))

    def live_fraction(self) -> float:
        """Fraction of cells ever retrieved (1.0 = all used; low = router starves most cells)."""
        return float(self.ever_retrieved.float().mean())

    @torch.no_grad()
    def _decode_rows(self, flat_ids: torch.Tensor) -> torch.Tensor:
        if self.value_compute == "fp":
            return self.fp_values[flat_ids]
        t, _ = decode_state(self.state[flat_ids], self.C)
        return self.scale[flat_ids] * t.to(self.scale.dtype)

    def read(self, flat_ids: torch.Tensor, tap: torch.Tensor) -> torch.Tensor:
        self._note_use(flat_ids)
        if self.value_compute == "fp":
            return self.fp_values[flat_ids]   # plain autograd updates fp values
        return _CounterValueRead.apply(self, flat_ids, tap)

    @torch.no_grad()
    def _update_active(self, flat_ids: torch.Tensor, grad_rows: torch.Tensor) -> None:
        """Aggregate per-(read) gradients into per-UNIQUE-cell gradients, then RMS+SR-tick each
        touched cell once. A cell read by several tokens accumulates their grads first (the true
        gradient of a shared row), so it ticks once with the summed signal -- not once per read."""
        uniq, inv = torch.unique(flat_ids, return_inverse=True)            # [U], [P]
        g = torch.zeros((uniq.shape[0], self.d), dtype=grad_rows.dtype, device=grad_rows.device)
        g.index_add_(0, inv, grad_rows)                                    # [U, d] summed gradient
        self.rows_touched += uniq.numel()

        t, c = decode_state(self.state[uniq], self.C)
        t = t.to(torch.float32); c = c.to(torch.float32)
        s_i = self.scale[uniq]                                            # [U,1]

        g_sq = g.pow(2).mean(dim=1, keepdim=True)
        self.v[uniq] = self.rms_beta * self.v[uniq] + (1.0 - self.rms_beta) * g_sq
        denom = self.v[uniq].sqrt().clamp_min(self.rms_eps)
        grad_eff = g / denom

        grad_s = (g * t).sum(dim=1, keepdim=True) / math.sqrt(self.d)
        s_new = (s_i - self.lr_scale * grad_s).clamp_(1e-5, 10.0)

        c_rebased = c * (s_i / s_new)
        ticks = (-self.lr * grad_eff) * (self.C / s_new)
        cc = stochastic_round(c_rebased + ticks)
        carry = torch.trunc(cc / self.C)
        remainder = cc - carry * self.C
        proposed_t = t + carry
        new_t = proposed_t.clamp_(-1, 1)
        blocked = proposed_t != new_t
        remainder = torch.where(blocked, torch.sign(cc) * (self.C - 1), remainder
                                ).clamp_(-(self.C - 1), self.C - 1)
        self.state[uniq] = encode_state(new_t.to(torch.int16), remainder.to(torch.int16), self.C)
        self.scale[uniq] = s_new

    def persistent_bytes(self) -> int:
        if self.value_compute == "fp":
            return self.fp_values.numel() * 4
        return self.state.numel() + self.scale.numel() * 4 + self.v.numel() * 4


class CounterMemoryFFN(nn.Module):
    """Product-key memory used as an FFN replacement: ln(x) -> retrieve top-k of E cells -> readout.

    n_cells must be a perfect square (E = m^2; m sub-keys per half). active compute per token is
    O(d*dk + sqrt(E)*dk + k*d) -- sublinear in E, so capacity scales with E at fixed active FLOPs.
    """

    def __init__(self, dim: int, n_cells: int = 16384, k: int = 8, key_dim: int = 32, *,
                 C: int = 11, lr: float = 0.04, lr_scale: float = 2e-4,
                 value_compute: str = "counter", aux_loss_weight: float = 0.0) -> None:
        super().__init__()
        m = int(round(math.sqrt(n_cells)))
        if m * m != n_cells:
            raise ValueError(f"n_cells must be a perfect square, got {n_cells}")
        self.d = int(dim)
        self.m = m
        self.E = n_cells
        self.k = int(k)
        self.dk = int(key_dim)
        self.aux_loss_weight = float(aux_loss_weight)
        self.last_aux_loss = torch.zeros(())
        self.query = nn.Linear(dim, 2 * self.dk, bias=False)
        self.k1 = nn.Parameter(torch.randn(m, self.dk) / math.sqrt(self.dk))
        self.k2 = nn.Parameter(torch.randn(m, self.dk) / math.sqrt(self.dk))
        self.values = CounterValueMemory(n_cells, dim, C=C, lr=lr, lr_scale=lr_scale,
                                         value_compute=value_compute)

    @staticmethod
    def _balance(scores: torch.Tensor, idx: torch.Tensor, m: int) -> torch.Tensor:
        """Switch-style load-balance over sub-keys: m * sum_e frac_e * mean_prob_e. Minimized when
        sub-key usage is uniform -> the router spreads over cells instead of collapsing (fights the
        starvation that kills large-E memory). Computed on the m=sqrt(E) sub-keys (cheap)."""
        prob = torch.softmax(scores, dim=1).mean(0)            # [m] mean router prob per sub-key
        onehot = torch.zeros_like(scores).scatter_(1, idx, 1.0)
        frac = onehot.mean(0)
        frac = frac / frac.sum().clamp_min(1e-9)               # normalize to a distribution (sum 1)
        return m * (frac * prob).sum()                         # == 1 at uniform usage, >1 imbalanced

    def _retrieve(self, q: torch.Tensor):
        """q: [N, 2*dk] -> (weights [N,k], flat_ids [N,k]) via product-key top-k."""
        q1, q2 = q[:, :self.dk], q[:, self.dk:]
        s1 = q1 @ self.k1.t()                              # [N, m]
        s2 = q2 @ self.k2.t()
        ks = min(self.k, self.m)
        v1, i1 = s1.topk(ks, dim=1)                        # [N, ks]
        v2, i2 = s2.topk(ks, dim=1)
        if self.training and self.aux_loss_weight > 0:
            self.last_aux_loss = self._balance(s1, i1, self.m) + self._balance(s2, i2, self.m)
        # combine the two half-scorelists -> scores over the ks*ks candidate cells, take top-k
        cand = v1[:, :, None] + v2[:, None, :]            # [N, ks, ks]
        cand_id = i1[:, :, None] * self.m + i2[:, None, :]  # flat cell id within E
        cand = cand.reshape(cand.shape[0], -1)
        cand_id = cand_id.reshape(cand_id.shape[0], -1)
        sc, sel = cand.topk(self.k, dim=1)                # [N, k]
        flat_ids = torch.gather(cand_id, 1, sel)          # [N, k]
        weights = torch.softmax(sc, dim=1)                # [N, k]
        return weights, flat_ids

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        sh = x.shape
        h = x.reshape(-1, self.d)                          # [N, d]
        q = self.query(h)                                  # [N, 2dk]
        weights, flat_ids = self._retrieve(q)              # [N,k], [N,k]
        tap = (torch.zeros((), device=x.device, dtype=x.dtype, requires_grad=True)
               if torch.is_grad_enabled() else x.new_zeros(()))
        rows = self.values.read(flat_ids.reshape(-1), tap).reshape(h.shape[0], self.k, self.d)
        y = (weights.unsqueeze(-1) * rows).sum(dim=1)      # [N, d]
        return y.reshape(sh)

    def active_macs_per_token(self) -> int:
        """Multiply-accumulates touched per token (for the equal-active-compute comparison)."""
        ks = min(self.k, self.m)
        return (self.d * 2 * self.dk          # query projection
                + 2 * self.m * self.dk        # sub-key scores (the sqrt(E) term)
                + ks * ks                      # candidate combine
                + self.k * self.d)            # value readout

    def live_fraction(self) -> float:
        """Fraction of cells ever retrieved -- router-starvation diagnostic (low = many dead cells)."""
        return self.values.live_fraction()

    def persistent_bytes(self) -> int:
        """Counter value table (the bulk) + the small fp router (query proj + the two sub-key sets)."""
        router = (self.query.weight.numel() + self.k1.numel() + self.k2.numel()) * 4
        return self.values.persistent_bytes() + router