File size: 12,835 Bytes
a1dd5ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""FDNN cellular turnover applied to the SigLIP ENCODER β€” where the time is.

WHY HERE AND NOT THE LITTLE TOWER
---------------------------------
Ingest cost is measured, and it is not the database: byte-range decode runs at
2.7 ms/frame while the SigLIP vision tower runs at 27.7-90.3 ms/frame. So 97%
of ingest is one frozen 428M-parameter ViT. Pruning the 80k-parameter temporal
tower β€” which is what was done first β€” optimises 0.001% of the bill. The
encoder is the neuron population that matters.

THE NEURON, HERE
----------------
Each encoder layer is `fc1 (1152 -> 4304) -> gelu -> fc2 (4304 -> 1152)`.
Hidden unit *c* is a neuron in exactly FDNN's sense: it takes the residual
stream, applies its own nonlinearity, and writes back through its own column
of `fc2`. There are 27 x 4304 = 116,208 of them and they are 65% of the
tower's parameters.

Silencing one is `fc1.weight[c] = 0, fc1.bias[c] = 0` β€” gelu(0) = 0, so the
channel contributes nothing through fc2. Removing one for real is slicing
`fc1.weight[keep]`, `fc1.bias[keep]`, `fc2.weight[:, keep]`, which is a
genuine FLOP reduction, not a multiply by zero.

UTILIZATION IS DEFINED BY THE DATABASE'S WORKLOAD
-------------------------------------------------
FDNN measures utilization as the increase in validation loss when a neuron is
silenced. The equivalent here is NOT ImageNet accuracy β€” this encoder exists
to produce vectors that a retrieval index ranks with. So utilization is the
loss of EMBEDDING FIDELITY on frames from the actual corpus:

    fidelity = mean cosine( pruned_embedding, unpruned_embedding )

i.e. the unpruned encoder is its own teacher and the calibration set is the
user's own data. A channel that matters for photographs of dogs but never
fires on a robot arm in a toy kitchen is, for this database, dead weight.

Ablating 116,208 channels one at a time is not affordable (one forward pass
each). FDNN's own PPO feature vector already contains the cheap surrogates β€”
activation magnitude and downstream weight norm β€” and their product is the
standard structured-pruning saliency:

    saliency(c) = E_frames |act_c| * || fc2[:, c] ||

which is exactly "how much signal this neuron actually injects". Ablation is
still used, but per LAYER (27 measurements, affordable), to calibrate how much
each layer's saliency scale is worth. PPO then chooses keep-probabilities from
the same five features FDNN used, and the reward is the same shape:

    reward = -(fidelity loss ratio) - lambda * kept_fraction
"""
from __future__ import annotations

import time

import mlx.core as mx
import numpy as np


# ===========================================================================
# 0. Reaching the layers
# ===========================================================================
def vision_layers(model):
    """The encoder layer list of a SigLIP vision tower."""
    vm = model.vision_model
    vm = getattr(vm, "vision_model", vm)
    return list(vm.encoder.layers)


def mlp_width(layer):
    return np.array(layer.mlp.fc1.bias).shape[0]


class _Tap:
    """Wraps an nn.Linear so the mean |output| per channel is recorded.

    MLX calls `self.fc1(x)` by attribute, so swapping the attribute is enough
    to observe it β€” no hooks, no forked forward pass that could drift from the
    real one.
    """

    def __init__(self, inner):
        self.inner = inner
        self.acc = None
        self.n = 0

    def __call__(self, x):
        out = self.inner(x)
        a = mx.mean(mx.abs(out).reshape(-1, out.shape[-1]), axis=0)
        self.acc = a if self.acc is None else self.acc + a
        self.n += 1
        return out


# ===========================================================================
# 1. Saliency: activation magnitude x downstream weight norm
# ===========================================================================
def channel_saliency(model, images, model_id, batch=16):
    """Per-layer array of per-channel saliency, measured on REAL frames."""
    from .embeddings import _embed_images
    layers = vision_layers(model)
    taps = []
    for lyr in layers:
        t = _Tap(lyr.mlp.fc1)
        lyr.mlp.fc1 = t
        taps.append(t)
    try:
        for i in range(0, len(images), batch):
            _embed_images(images[i:i + batch], model_id)
    finally:
        for lyr, t in zip(layers, taps):
            lyr.mlp.fc1 = t.inner

    out = []
    for lyr, t in zip(layers, taps):
        act = np.array(t.acc) / max(t.n, 1)
        down = np.linalg.norm(np.array(lyr.mlp.fc2.weight), axis=0)
        out.append({"act": act.astype(np.float32),
                    "down": down.astype(np.float32),
                    "saliency": (act * down).astype(np.float32)})
    return out


# ===========================================================================
# 2. Masking and compaction
# ===========================================================================
def _snapshot(layers):
    return [(np.array(l.mlp.fc1.weight), np.array(l.mlp.fc1.bias))
            for l in layers]


def _restore(layers, snap):
    for l, (w, b) in zip(layers, snap):
        l.mlp.fc1.weight = mx.array(w)
        l.mlp.fc1.bias = mx.array(b)


def apply_masks(layers, snap, masks):
    """Silence channels: zeroing the fc1 row makes gelu(0)=0 downstream."""
    for l, (w, b), m in zip(layers, snap, masks):
        keep = m.astype(np.float32)[:, None]
        l.mlp.fc1.weight = mx.array(w * keep)
        l.mlp.fc1.bias = mx.array(b * keep[:, 0])


def compact_mlps(model, masks):
    """Physically delete the dead channels. This is where speed comes from."""
    layers = vision_layers(model)
    removed = 0
    for l, m in zip(layers, masks):
        keep = np.where(m > 0.5)[0]
        if len(keep) == mlp_width(l):
            continue
        removed += mlp_width(l) - len(keep)
        l.mlp.fc1.weight = mx.array(np.array(l.mlp.fc1.weight)[keep])
        l.mlp.fc1.bias = mx.array(np.array(l.mlp.fc1.bias)[keep])
        l.mlp.fc2.weight = mx.array(np.array(l.mlp.fc2.weight)[:, keep])
    mx.eval(model.parameters())
    return removed


# ===========================================================================
# 3. Fidelity against the unpruned teacher, on the user's own frames
# ===========================================================================
def fidelity(model_id, images, teacher, batch=16):
    from .embeddings import _embed_images
    out = []
    for i in range(0, len(images), batch):
        out.append(_embed_images(images[i:i + batch], model_id))
    v = np.concatenate(out, axis=0)
    return float((v * teacher).sum(axis=1).mean())


# ===========================================================================
# 4. Reverse attention (unchanged in spirit from FDNN)
# ===========================================================================
def reverse_attention(importance, temperature=1.0):
    imp = np.asarray(importance, dtype=np.float64)
    imp = (imp - imp.mean()) / (imp.std() + 1e-8)
    logits = -imp / max(temperature, 1e-6)
    logits -= logits.max()
    e = np.exp(logits)
    return (e / (e.sum() + 1e-12)).astype(np.float32)


# ===========================================================================
# 5. The cycle
# ===========================================================================
def prune_encoder(model_id, images, keep=0.5, layer_probe=True, iters=12,
                  lam=0.35, batch=16, verbose=True, seed=0):
    """Prune MLP channels of the vision tower against corpus fidelity.

    `keep` is the global target fraction of MLP channels to retain. The
    per-layer budget is not uniform: layers whose ablation barely moves
    fidelity give up more channels than layers that matter, which is the whole
    point of measuring instead of assuming.

    Returns (model, report). The returned model is COMPACTED β€” smaller
    matmuls, not masked ones.
    """
    from .embeddings import _embed_images, _load_model
    rng = np.random.default_rng(seed)
    model, _ = _load_model(model_id)
    layers = vision_layers(model)
    widths = [mlp_width(l) for l in layers]
    snap = _snapshot(layers)

    teacher = np.concatenate(
        [_embed_images(images[i:i + batch], model_id)
         for i in range(0, len(images), batch)], axis=0)

    t0 = time.time()
    sal = channel_saliency(model, images, model_id, batch=batch)
    if verbose:
        print(f"  saliency over {len(images)} corpus frames "
              f"({time.time() - t0:.1f}s)", flush=True)

    # ---- per-layer ablation: how much does this layer matter at all? -------
    layer_cost = np.ones(len(layers), dtype=np.float64)
    if layer_probe:
        for li in range(len(layers)):
            masks = [np.ones(w, np.float32) for w in widths]
            masks[li][:] = 0.0
            apply_masks(layers, snap, masks)
            layer_cost[li] = max(1.0 - fidelity(model_id, images[:batch],
                                                teacher[:batch], batch), 1e-6)
        _restore(layers, snap)
        if verbose:
            order = np.argsort(layer_cost)
            print(f"  layer ablation: cheapest {order[:4].tolist()} "
                  f"costliest {order[-4:].tolist()}", flush=True)

    # ---- allocate the budget across layers by measured importance ---------
    # A layer that costs little when removed entirely can afford to lose more
    # of its channels. Normalised so the global kept fraction hits `keep`.
    w = layer_cost / layer_cost.sum()
    share = w / w.mean()                       # 1.0 == average importance
    per_layer_keep = np.clip(keep * share, 0.05, 1.0)
    total = sum(widths)
    scale = (keep * total) / sum(k * n for k, n in zip(per_layer_keep, widths))
    per_layer_keep = np.clip(per_layer_keep * scale, 0.05, 1.0)

    masks = []
    for li, (s, wdt) in enumerate(zip(sal, widths)):
        n_keep = max(int(round(per_layer_keep[li] * wdt)), 1)
        idx = np.argsort(-s["saliency"])[:n_keep]
        m = np.zeros(wdt, np.float32)
        m[idx] = 1.0
        masks.append(m)

    apply_masks(layers, snap, masks)
    fid = fidelity(model_id, images, teacher, batch)
    kept = sum(m.sum() for m in masks) / total
    if verbose:
        print(f"  saliency prune: kept {kept:.1%}, fidelity {fid:.4f}",
              flush=True)

    # ---- PPO refinement over the per-layer budget --------------------------
    # The candidate set is the 27 per-layer keep fractions rather than 116,208
    # independent channels: one forward pass per sampled mask makes per-channel
    # sampling unaffordable, and the per-layer budget is where the leverage
    # actually is (saliency already orders channels within a layer).
    best = (fid, [m.copy() for m in masks], kept)
    ra = reverse_attention(layer_cost)
    logit = np.zeros(len(layers))
    for it in range(iters):
        cand = per_layer_keep * (1.0 + 0.25 * np.tanh(logit)
                                 + 0.15 * rng.standard_normal(len(layers)))
        cand = np.clip(cand, 0.05, 1.0)
        cand *= (keep * total) / sum(c * n for c, n in zip(cand, widths))
        cand = np.clip(cand, 0.05, 1.0)
        trial = []
        for li, (s, wdt) in enumerate(zip(sal, widths)):
            n_keep = max(int(round(cand[li] * wdt)), 1)
            idx = np.argsort(-s["saliency"])[:n_keep]
            m = np.zeros(wdt, np.float32)
            m[idx] = 1.0
            trial.append(m)
        apply_masks(layers, snap, trial)
        f = fidelity(model_id, images[:batch * 2], teacher[:batch * 2], batch)
        k = sum(m.sum() for m in trial) / total
        reward = -(1.0 - f) - lam * k
        best_reward = -(1.0 - best[0]) - lam * best[2]
        if reward > best_reward:
            # Direction of the accepted move, computed BEFORE the budget is
            # updated β€” comparing `cand` against itself would make every sign
            # zero and the search a pure random walk.
            step = np.sign(cand - per_layer_keep)
            best = (f, [m.copy() for m in trial], k)
            per_layer_keep = cand
            logit += 0.5 * ra * step
        if verbose and (it % 4 == 0 or it == iters - 1):
            print(f"  PPO {it:2d} | fidelity {f:.4f} | kept {k:.1%} | "
                  f"reward {reward:+.4f}", flush=True)

    fid, masks, kept = best
    _restore(layers, snap)
    removed = compact_mlps(model, masks)
    final = fidelity(model_id, images, teacher, batch)
    report = {"model": model_id, "kept_fraction": float(kept),
              "channels_removed": int(removed),
              "channels_total": int(total),
              "fidelity": float(final),
              "layer_keep": [float(x) for x in per_layer_keep]}
    if verbose:
        print(f"  compacted: removed {removed:,}/{total:,} MLP channels, "
              f"fidelity {final:.4f}", flush=True)
    return model, report