elidedb-qbe / python /elidedb /encprune.py
SudharshanR
ElideDB query by example: no text, no model at query time
a1dd5ba
Raw
History Blame Contribute Delete
12.8 kB
"""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