actlens 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- actlens/__init__.py +3 -0
- actlens/app.py +318 -0
- actlens/archs/__init__.py +7 -0
- actlens/archs/base.py +153 -0
- actlens/archs/gpt2.py +42 -0
- actlens/archs/llama.py +79 -0
- actlens/archs/registry.py +78 -0
- actlens/archs/rope.py +18 -0
- actlens/cache.py +81 -0
- actlens/capture.py +119 -0
- actlens/cli.py +75 -0
- actlens/colab.py +129 -0
- actlens/corpus.json +86 -0
- actlens/providers.py +125 -0
- actlens/slicing.py +340 -0
- actlens/static/assets/html2canvas-uQpzslGr.js +5 -0
- actlens/static/assets/index-DR5UFnGu.js +9 -0
- actlens/static/assets/index-TyrpFNMz.css +1 -0
- actlens/static/assets/index.es-E5o7ij1y.js +5 -0
- actlens/static/assets/jspdf.es.min-Bhouoq4q.js +79 -0
- actlens/static/assets/purify.es-Bvo9QlJ8.js +3 -0
- actlens/static/favicon.svg +9 -0
- actlens/static/index.html +14 -0
- actlens/wire.py +20 -0
- actlens-0.1.0.dist-info/METADATA +169 -0
- actlens-0.1.0.dist-info/RECORD +29 -0
- actlens-0.1.0.dist-info/WHEEL +4 -0
- actlens-0.1.0.dist-info/entry_points.txt +2 -0
- actlens-0.1.0.dist-info/licenses/LICENSE +21 -0
actlens/__init__.py
ADDED
actlens/app.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""FastAPI app: model management, runs, and windowed activation slices."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import gc
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
19
|
+
from fastapi.responses import FileResponse, PlainTextResponse, RedirectResponse
|
|
20
|
+
from fastapi.staticfiles import StaticFiles
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from . import slicing
|
|
24
|
+
from .cache import ActivationCache
|
|
25
|
+
from .capture import ATTN_ACT, Capture, Run
|
|
26
|
+
from .providers import ActivationProvider, NNsightProvider
|
|
27
|
+
from .wire import frame_response
|
|
28
|
+
|
|
29
|
+
DEFAULT_MODEL = "Qwen/Qwen3-0.6B"
|
|
30
|
+
PRESET_MODELS = [
|
|
31
|
+
{"id": "Qwen/Qwen3-0.6B", "label": "Qwen3-0.6B"},
|
|
32
|
+
{"id": "Qwen/Qwen2.5-0.5B", "label": "Qwen2.5-0.5B"},
|
|
33
|
+
{"id": "HuggingFaceTB/SmolLM2-360M", "label": "SmolLM2-360M"},
|
|
34
|
+
{"id": "openai-community/gpt2", "label": "GPT-2"},
|
|
35
|
+
]
|
|
36
|
+
MAX_RUNS = 3
|
|
37
|
+
TOKEN_COOKIE = "actlens_token"
|
|
38
|
+
DEFAULT_CACHE_MB = 2048
|
|
39
|
+
CORPUS = json.loads((Path(__file__).parent / "corpus.json").read_text())
|
|
40
|
+
_PACKAGED_DIST = Path(__file__).parent / "static" # bundled into the wheel
|
|
41
|
+
_DEV_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist" # source checkout
|
|
42
|
+
FRONTEND_DIST = _PACKAGED_DIST if (_PACKAGED_DIST / "index.html").is_file() else _DEV_DIST
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cache_budget_bytes() -> int:
|
|
46
|
+
try:
|
|
47
|
+
mb = float(os.environ.get("ACTLENS_CACHE_MB", DEFAULT_CACHE_MB))
|
|
48
|
+
except ValueError:
|
|
49
|
+
mb = DEFAULT_CACHE_MB
|
|
50
|
+
return int(mb * 2**20)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ModelManager:
|
|
54
|
+
"""Owns the single loaded model. All model work runs on one dedicated thread."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, factory: Callable[[str, str, str], ActivationProvider] = NNsightProvider,
|
|
57
|
+
cache_bytes: int | None = None):
|
|
58
|
+
self.factory = factory
|
|
59
|
+
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="model")
|
|
60
|
+
self.provider: ActivationProvider | None = None
|
|
61
|
+
self.state = "idle" # idle | loading | ready | error
|
|
62
|
+
self.target: str | None = None
|
|
63
|
+
self.error: str | None = None
|
|
64
|
+
self.runs: OrderedDict[str, Run] = OrderedDict()
|
|
65
|
+
self.cache = ActivationCache(cache_budget_bytes() if cache_bytes is None else cache_bytes)
|
|
66
|
+
self._lock = threading.Lock()
|
|
67
|
+
|
|
68
|
+
def status(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"state": self.state,
|
|
71
|
+
"model_id": self.provider.model_id if self.provider else self.target,
|
|
72
|
+
"target": self.target,
|
|
73
|
+
"error": self.error,
|
|
74
|
+
"info": self.provider.info if self.state == "ready" and self.provider else None,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def load(self, model_id: str, device: str = "auto", dtype: str = "float32") -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
if self.state == "loading":
|
|
80
|
+
raise HTTPException(409, "A model is already loading")
|
|
81
|
+
self.state, self.target, self.error = "loading", model_id, None
|
|
82
|
+
self.executor.submit(self._load, model_id, device, dtype)
|
|
83
|
+
|
|
84
|
+
def _load(self, model_id: str, device: str, dtype: str) -> None:
|
|
85
|
+
try:
|
|
86
|
+
if self.provider is not None:
|
|
87
|
+
self.provider.close()
|
|
88
|
+
self.provider = None
|
|
89
|
+
self.runs.clear()
|
|
90
|
+
self.cache.clear()
|
|
91
|
+
gc.collect()
|
|
92
|
+
self.provider = self.factory(model_id, device, dtype)
|
|
93
|
+
self.state = "ready"
|
|
94
|
+
except Exception as e: # surfaced to the UI via /api/status
|
|
95
|
+
self.error = f"{type(e).__name__}: {e}"
|
|
96
|
+
self.state = "error"
|
|
97
|
+
|
|
98
|
+
def _register(self, provider: ActivationProvider, text: str, max_tokens: int) -> Run:
|
|
99
|
+
t0 = time.perf_counter()
|
|
100
|
+
ids, tokens, truncated = provider.tokenize(text, max_tokens)
|
|
101
|
+
specs = {s.id: s for s in provider.activations()}
|
|
102
|
+
keys = ("n_layers", "hidden_size", "intermediate_size", "n_heads", "n_kv_heads", "head_dim")
|
|
103
|
+
return Run(run_id=uuid.uuid4().hex[:12], model_id=provider.model_id, text=text, token_ids=list(ids),
|
|
104
|
+
tokens=list(tokens), truncated=truncated, specs=specs,
|
|
105
|
+
model={k: provider.info.get(k) for k in keys}, elapsed_ms=(time.perf_counter() - t0) * 1000)
|
|
106
|
+
|
|
107
|
+
async def run(self, text: str, max_tokens: int) -> Run:
|
|
108
|
+
"""Tokenize and register a run. No activations are captured yet."""
|
|
109
|
+
if self.state != "ready" or self.provider is None:
|
|
110
|
+
raise HTTPException(409, f"Model not ready (state: {self.state})")
|
|
111
|
+
loop = asyncio.get_running_loop()
|
|
112
|
+
try:
|
|
113
|
+
run = await loop.run_in_executor(self.executor, self._register, self.provider, text, max_tokens)
|
|
114
|
+
except ValueError as e:
|
|
115
|
+
raise HTTPException(400, str(e))
|
|
116
|
+
self.runs[run.run_id] = run
|
|
117
|
+
while len(self.runs) > MAX_RUNS:
|
|
118
|
+
old, _ = self.runs.popitem(last=False)
|
|
119
|
+
self.cache.drop_run(old)
|
|
120
|
+
return run
|
|
121
|
+
|
|
122
|
+
def get(self, run_id: str) -> Run:
|
|
123
|
+
run = self.runs.get(run_id)
|
|
124
|
+
if run is None:
|
|
125
|
+
raise HTTPException(404, "Unknown run (it may have been evicted); run the prompt again")
|
|
126
|
+
self.runs.move_to_end(run_id)
|
|
127
|
+
return run
|
|
128
|
+
|
|
129
|
+
def capture(self, run: Run, act: str) -> Capture:
|
|
130
|
+
"""Blocking (call from a worker thread): return the cached capture, building it on the model thread if needed.
|
|
131
|
+
Concurrent callers for the same (run, act) share one forward pass."""
|
|
132
|
+
provider = self.provider
|
|
133
|
+
if provider is None or run.run_id not in self.runs or provider.model_id != run.model_id:
|
|
134
|
+
raise HTTPException(404, "Unknown run (it may have been evicted); run the prompt again")
|
|
135
|
+
|
|
136
|
+
def build() -> Capture:
|
|
137
|
+
arr = self.executor.submit(provider.capture, run.token_ids, act).result()
|
|
138
|
+
return Capture(act=act, arr=arr)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
cap = self.cache.get((run.run_id, act), build)
|
|
142
|
+
except HTTPException:
|
|
143
|
+
raise
|
|
144
|
+
except ValueError as e:
|
|
145
|
+
raise HTTPException(400, str(e))
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise HTTPException(500, f"Capture of {act!r} failed: {type(e).__name__}: {e}")
|
|
148
|
+
if run.run_id not in self.runs: # run was evicted while we were capturing
|
|
149
|
+
self.cache.drop_run(run.run_id)
|
|
150
|
+
return cap
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LoadRequest(BaseModel):
|
|
154
|
+
model_id: str
|
|
155
|
+
device: str = "auto"
|
|
156
|
+
dtype: str = "float32"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RunRequest(BaseModel):
|
|
160
|
+
text: str
|
|
161
|
+
max_tokens: int = 512
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_app(manager: ModelManager | None = None, autoload: bool = True, model_id: str = DEFAULT_MODEL,
|
|
165
|
+
device: str = "auto", dtype: str = "float32", token: str | None = None) -> FastAPI:
|
|
166
|
+
mgr = manager or ModelManager()
|
|
167
|
+
|
|
168
|
+
@asynccontextmanager
|
|
169
|
+
async def lifespan(_: FastAPI):
|
|
170
|
+
if autoload and mgr.state == "idle":
|
|
171
|
+
mgr.load(model_id, device, dtype)
|
|
172
|
+
yield
|
|
173
|
+
|
|
174
|
+
app = FastAPI(title="ActLens", lifespan=lifespan)
|
|
175
|
+
app.state.manager = mgr
|
|
176
|
+
|
|
177
|
+
if token:
|
|
178
|
+
# Access token for a server that is reachable from outside (e.g. a tunnel from Colab). Open
|
|
179
|
+
# `/?token=...` once: the token is stored in an HttpOnly cookie and the URL is cleaned up.
|
|
180
|
+
# Scripts can send `Authorization: Bearer <token>` instead.
|
|
181
|
+
def valid(candidate: str | None) -> bool:
|
|
182
|
+
return bool(candidate) and hmac.compare_digest(candidate.encode(), token.encode())
|
|
183
|
+
|
|
184
|
+
@app.middleware("http")
|
|
185
|
+
async def require_token(request: Request, call_next):
|
|
186
|
+
bearer = request.headers.get("authorization", "")
|
|
187
|
+
if valid(request.cookies.get(TOKEN_COOKIE)) or valid(bearer.removeprefix("Bearer ").strip()):
|
|
188
|
+
return await call_next(request)
|
|
189
|
+
if request.method == "GET" and valid(request.query_params.get("token")):
|
|
190
|
+
url = request.url.remove_query_params("token")
|
|
191
|
+
resp = RedirectResponse(url.path + (f"?{url.query}" if url.query else ""), status_code=303)
|
|
192
|
+
# Lax, not Strict: the link is usually opened from another site (Colab), and a Strict cookie is
|
|
193
|
+
# not sent on the redirect that follows a cross-site navigation.
|
|
194
|
+
https = "https" in (request.url.scheme, request.headers.get("x-forwarded-proto", ""))
|
|
195
|
+
resp.set_cookie(TOKEN_COOKIE, token, httponly=True, samesite="lax", secure=https)
|
|
196
|
+
return resp
|
|
197
|
+
return PlainTextResponse("ActLens: missing or invalid access token. Open the full link printed "
|
|
198
|
+
"by the server (it ends in ?token=...).", status_code=401)
|
|
199
|
+
|
|
200
|
+
def guarded(fn, *a, **kw):
|
|
201
|
+
try:
|
|
202
|
+
return fn(*a, **kw)
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
raise HTTPException(400, str(e))
|
|
205
|
+
|
|
206
|
+
def token_capture(run_id: str, act: str, layer: int | None = None) -> Capture:
|
|
207
|
+
"""Validate `act` (a token-kind activation of this run's model) and capture it lazily."""
|
|
208
|
+
run = mgr.get(run_id)
|
|
209
|
+
spec = run.specs.get(act)
|
|
210
|
+
if spec is None:
|
|
211
|
+
raise HTTPException(400, f"unknown act {act!r}; expected one of {list(run.specs)}")
|
|
212
|
+
if spec.kind != "token":
|
|
213
|
+
raise HTTPException(400, f"act {act!r} is an attention pattern; use the /attn endpoints")
|
|
214
|
+
if layer is not None and not 0 <= layer < spec.n_layers:
|
|
215
|
+
raise HTTPException(400, f"layer {layer} out of range for {act} (0..{spec.n_layers - 1})")
|
|
216
|
+
return mgr.capture(run, act)
|
|
217
|
+
|
|
218
|
+
def attn_capture(run_id: str) -> Capture:
|
|
219
|
+
run = mgr.get(run_id)
|
|
220
|
+
if ATTN_ACT not in run.specs:
|
|
221
|
+
raise HTTPException(400, "this model does not expose attention patterns")
|
|
222
|
+
return mgr.capture(run, ATTN_ACT)
|
|
223
|
+
|
|
224
|
+
# ----- models / corpus -----
|
|
225
|
+
@app.get("/api/status")
|
|
226
|
+
def status():
|
|
227
|
+
return {**mgr.status(), "presets": PRESET_MODELS}
|
|
228
|
+
|
|
229
|
+
@app.post("/api/models/load", status_code=202)
|
|
230
|
+
def load_model(req: LoadRequest):
|
|
231
|
+
mgr.load(req.model_id, req.device, req.dtype)
|
|
232
|
+
return mgr.status()
|
|
233
|
+
|
|
234
|
+
@app.get("/api/corpus")
|
|
235
|
+
def corpus():
|
|
236
|
+
return CORPUS
|
|
237
|
+
|
|
238
|
+
# ----- runs -----
|
|
239
|
+
@app.post("/api/run")
|
|
240
|
+
async def run(req: RunRequest):
|
|
241
|
+
r = await mgr.run(req.text, max(1, min(req.max_tokens, 1024)))
|
|
242
|
+
return {
|
|
243
|
+
"run_id": r.run_id, "model_id": r.model_id, "tokens": r.tokens, "token_ids": r.token_ids,
|
|
244
|
+
"truncated": r.truncated, "elapsed_ms": round(r.elapsed_ms, 1), "model": r.model,
|
|
245
|
+
"activations": [sp.info() for sp in r.specs.values()],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@app.get("/api/run/{run_id}/overview")
|
|
249
|
+
def overview(run_id: str, act: str = "resid_post", stat: str = "norm", dim: int = 0):
|
|
250
|
+
cap = token_capture(run_id, act)
|
|
251
|
+
arr, meta = guarded(slicing.overview, cap, stat, dim)
|
|
252
|
+
return frame_response(meta, arr)
|
|
253
|
+
|
|
254
|
+
@app.get("/api/run/{run_id}/slice")
|
|
255
|
+
def slice_(run_id: str, act: str, layer: int, t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128,
|
|
256
|
+
max_h: int = Query(512, ge=1, le=2048), max_w: int = Query(1024, ge=1, le=4096),
|
|
257
|
+
agg: str = "absmax", order: str = "natural"):
|
|
258
|
+
cap = token_capture(run_id, act, layer)
|
|
259
|
+
arr, meta = guarded(slicing.token_dim_slice, cap, layer, t0, t1, d0, d1, max_h, max_w, agg, order)
|
|
260
|
+
return frame_response(meta, arr)
|
|
261
|
+
|
|
262
|
+
@app.get("/api/run/{run_id}/profile")
|
|
263
|
+
def profile(run_id: str, act: str, layer: int, order: str = "natural", bins: int = Query(512, ge=8, le=4096)):
|
|
264
|
+
cap = token_capture(run_id, act, layer)
|
|
265
|
+
arr, meta = guarded(slicing.dim_profile, cap, layer, order, bins)
|
|
266
|
+
return frame_response(meta, arr)
|
|
267
|
+
|
|
268
|
+
@app.get("/api/run/{run_id}/stats")
|
|
269
|
+
def stats(run_id: str, act: str, layer: int, t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128,
|
|
270
|
+
order: str = "natural", clip: bool = False):
|
|
271
|
+
cap = token_capture(run_id, act, layer)
|
|
272
|
+
return guarded(slicing.token_region_stats, cap, layer, t0, t1, d0, d1, order, clip)
|
|
273
|
+
|
|
274
|
+
@app.get("/api/run/{run_id}/axis_stats")
|
|
275
|
+
def axis_stats(run_id: str, act: str, layer: int, axis: str = "channel", stat: str = "norm",
|
|
276
|
+
t0: int = 0, t1: int = 64, d0: int = 0, d1: int = 128, order: str = "natural",
|
|
277
|
+
clip: bool = False, top: int = 10, bins: int = 64):
|
|
278
|
+
cap = token_capture(run_id, act, layer)
|
|
279
|
+
return guarded(slicing.axis_stats, cap, layer, axis, stat, t0, t1, d0, d1, order, clip, top, bins)
|
|
280
|
+
|
|
281
|
+
@app.get("/api/run/{run_id}/attn")
|
|
282
|
+
def attn(run_id: str, layer: int, head: int = -1, q0: int = 0, q1: int = 100000, k0: int = 0, k1: int = 100000,
|
|
283
|
+
max_q: int = Query(512, ge=1, le=1024), max_k: int = Query(512, ge=1, le=1024), agg: str = "max"):
|
|
284
|
+
cap = attn_capture(run_id)
|
|
285
|
+
if not 0 <= layer < cap.n_layers:
|
|
286
|
+
raise HTTPException(400, f"layer {layer} out of range (0..{cap.n_layers - 1})")
|
|
287
|
+
arr, meta = guarded(slicing.attn_slice, cap, layer, head, q0, q1, k0, k1, max_q, max_k, agg)
|
|
288
|
+
return frame_response(meta, arr)
|
|
289
|
+
|
|
290
|
+
@app.get("/api/run/{run_id}/attn_overview")
|
|
291
|
+
def attn_overview(run_id: str, stat: str = "entropy"):
|
|
292
|
+
cap = attn_capture(run_id)
|
|
293
|
+
arr, meta = guarded(slicing.attn_overview, cap, stat)
|
|
294
|
+
return frame_response(meta, arr)
|
|
295
|
+
|
|
296
|
+
@app.get("/api/run/{run_id}/attn_stats")
|
|
297
|
+
def attn_stats(run_id: str, layer: int, head: int, q0: int = 0, q1: int = 100000, k0: int = 0, k1: int = 100000,
|
|
298
|
+
clip: bool = False):
|
|
299
|
+
cap = attn_capture(run_id)
|
|
300
|
+
if not (0 <= layer < cap.n_layers and 0 <= head < cap.n_heads):
|
|
301
|
+
raise HTTPException(400, "layer/head out of range")
|
|
302
|
+
return guarded(slicing.attn_region_stats, cap, layer, head, q0, q1, k0, k1, clip)
|
|
303
|
+
|
|
304
|
+
# ----- static frontend (production build) -----
|
|
305
|
+
if (FRONTEND_DIST / "index.html").is_file():
|
|
306
|
+
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
|
307
|
+
|
|
308
|
+
@app.get("/{path:path}")
|
|
309
|
+
def spa(path: str):
|
|
310
|
+
if path.startswith("api/"):
|
|
311
|
+
raise HTTPException(404)
|
|
312
|
+
f = FRONTEND_DIST / path
|
|
313
|
+
return FileResponse(f if path and f.is_file() else FRONTEND_DIST / "index.html")
|
|
314
|
+
|
|
315
|
+
return app
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
app = create_app()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Architecture adapters: how each model family exposes its activations. See `base.ArchAdapter`."""
|
|
2
|
+
from . import gpt2, llama # noqa: F401 (importing registers the built-in adapters)
|
|
3
|
+
from .base import ActDef, ArchAdapter, Dims, PreNormBlockAdapter, TraceCtx, get, has
|
|
4
|
+
from .registry import ADAPTERS, load_plugins, register, resolve_adapter, supported_model_types
|
|
5
|
+
|
|
6
|
+
__all__ = ["ActDef", "ArchAdapter", "Dims", "PreNormBlockAdapter", "TraceCtx", "ADAPTERS", "get", "has",
|
|
7
|
+
"load_plugins", "register", "resolve_adapter", "supported_model_types"]
|
actlens/archs/base.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The architecture-adapter contract.
|
|
2
|
+
|
|
3
|
+
An adapter answers two questions about one family of Hugging Face models: *which* activations does it expose, and
|
|
4
|
+
*how* is each one read from a block during a forward pass. Everything else (tokenizing, tracing, caching, the HTTP
|
|
5
|
+
API, the UI) is architecture-neutral and only sees the resulting `ActivationSpec`s and arrays.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Dims:
|
|
17
|
+
"""Model dimensions the provider and the specs need. `inter` is None for models without an MLP width."""
|
|
18
|
+
n_layers: int
|
|
19
|
+
hidden: int
|
|
20
|
+
inter: int | None
|
|
21
|
+
n_heads: int
|
|
22
|
+
n_kv_heads: int
|
|
23
|
+
head_dim: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TraceCtx:
|
|
28
|
+
"""Per-capture state handed to readers. `aux` holds values an adapter computes once before the blocks run
|
|
29
|
+
(see `ArchAdapter.prepare`), e.g. the RoPE cos/sin tables."""
|
|
30
|
+
n_tokens: int
|
|
31
|
+
aux: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def host(self, t: torch.Tensor, dtype: torch.dtype = torch.float32) -> torch.Tensor:
|
|
34
|
+
return t.detach().to(dtype).cpu()
|
|
35
|
+
|
|
36
|
+
def tok(self, t: torch.Tensor) -> torch.Tensor:
|
|
37
|
+
"""[1, T, C] or [1, T, heads, Dh] -> host float32 [T, C]."""
|
|
38
|
+
return self.host(t).reshape(self.n_tokens, -1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# (block, ctx) -> host tensor: [T, C] float32 for token activations, [H, T, T] float16 for attention patterns.
|
|
42
|
+
# Runs inside an nnsight trace, so `block.<module>.input/output` are lazy proxies; readers must touch modules in
|
|
43
|
+
# forward-execution order and use `ctx` to move data to the host.
|
|
44
|
+
Reader = Callable[[Any, TraceCtx], torch.Tensor]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ActDef:
|
|
49
|
+
"""How one activation is exposed on one architecture. `label`/`description` override the registry defaults
|
|
50
|
+
(use them to name the model's real modules); channels/n_heads/head_dim become the `ActivationSpec`."""
|
|
51
|
+
read: Reader
|
|
52
|
+
channels: int | None = None # None only for attention patterns
|
|
53
|
+
n_heads: int | None = None # set when the channel axis is heads x head_dim (attention patterns: n_heads)
|
|
54
|
+
head_dim: int | None = None
|
|
55
|
+
label: str | None = None
|
|
56
|
+
description: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ArchAdapter:
|
|
60
|
+
"""Base class. Subclass, fill in the class attributes, implement `probe`, `dims` and `acts`, and register with
|
|
61
|
+
`@register` (see `archs/gpt2.py` for a small complete example)."""
|
|
62
|
+
|
|
63
|
+
name: str = ""
|
|
64
|
+
# `config.model_type` values that select this adapter directly. Adapters are also tried structurally (`probe`),
|
|
65
|
+
# so a model with a different `model_type` but the same module layout works without being listed here.
|
|
66
|
+
model_types: tuple[str, ...] = ()
|
|
67
|
+
layers_path: str = "" # dotted path from the model to the list of blocks, e.g. "model.layers"
|
|
68
|
+
|
|
69
|
+
def probe(self, root: torch.nn.Module) -> list[str]:
|
|
70
|
+
"""Paths this adapter needs but `root` lacks. An empty list means the model is compatible."""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def dims(self, root: torch.nn.Module) -> Dims:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def acts(self, root: torch.nn.Module, dims: Dims) -> dict[str, ActDef]:
|
|
77
|
+
"""The activations this model exposes. Keys are registry ids (`capture.REGISTRY`); anything the model
|
|
78
|
+
does not have (QK-norm, RoPE, a gate, ...) is simply left out. Display order is the registry's."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
def prepare(self, model, ctx: TraceCtx, act: str) -> None:
|
|
82
|
+
"""Runs inside the trace before any block executes; may stash values in `ctx.aux` for readers.
|
|
83
|
+
Only do work `act` needs, since every hook adds cost."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ----- helpers shared by adapters -----
|
|
87
|
+
def get(obj, path: str):
|
|
88
|
+
"""Resolve a dotted path; all-digit parts index. Works on `nn.Module`s and nnsight envoys alike."""
|
|
89
|
+
for part in path.split("."):
|
|
90
|
+
obj = obj[int(part)] if part.isdigit() else getattr(obj, part)
|
|
91
|
+
return obj
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def has(obj, path: str) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
get(obj, path)
|
|
97
|
+
except (AttributeError, IndexError, TypeError, KeyError):
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def missing_paths(root, paths: list[str], block_paths: list[str], layers_path: str) -> list[str]:
|
|
103
|
+
"""Which of `paths` (relative to the model) and `block_paths` (relative to block 0) are absent from `root`."""
|
|
104
|
+
missing = [p for p in [layers_path, *paths] if not has(root, p)]
|
|
105
|
+
if missing:
|
|
106
|
+
return missing
|
|
107
|
+
return [f"{layers_path}.0.{p}" for p in block_paths if not has(get(root, layers_path)[0], p)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def layer_output(block) -> torch.Tensor:
|
|
111
|
+
out = block.output
|
|
112
|
+
return out[0] if isinstance(out, tuple) else out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PreNormBlockAdapter(ArchAdapter):
|
|
116
|
+
"""Sequential pre-norm blocks (`x + attn(norm1(x))`, then `x + mlp(norm2(x))`): Llama, Qwen, Mistral, GPT-2, ...
|
|
117
|
+
|
|
118
|
+
Implements the activations that do not depend on how the projections are laid out (residual stream, norms,
|
|
119
|
+
attention pattern/context/output, MLP output). Subclasses add q/k/v (+ RoPE, QK-norm) and the MLP internals.
|
|
120
|
+
Module names are relative to a block.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
norm1: str # pre-attention norm
|
|
124
|
+
attn: str
|
|
125
|
+
o_proj: str # attention output projection, relative to the block
|
|
126
|
+
norm2: str # pre-MLP norm
|
|
127
|
+
down_proj: str # MLP output projection, relative to the block
|
|
128
|
+
|
|
129
|
+
def shared_acts(self, d: Dims) -> dict[str, ActDef]:
|
|
130
|
+
n1, n2, o, dn = self.norm1, self.norm2, self.o_proj, self.down_proj
|
|
131
|
+
D, H, Dh = d.hidden, d.n_heads, d.head_dim
|
|
132
|
+
o_name, dn_name = o.rsplit(".", 1)[-1], dn.rsplit(".", 1)[-1] # labels name the module, not its path
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"resid_pre": ActDef(lambda b, c: c.tok(get(b, n1).input), D),
|
|
136
|
+
"resid_mid": ActDef(lambda b, c: c.tok(get(b, n2).input), D),
|
|
137
|
+
"resid_post": ActDef(lambda b, c: c.tok(layer_output(b)), D),
|
|
138
|
+
"attn_norm": ActDef(lambda b, c: c.tok(get(b, n1).output), D, label=f"attn_norm — {n1}"),
|
|
139
|
+
"attn_pattern": ActDef(lambda b, c: c.host(get(b, self.attn).output[1], torch.float16)[0],
|
|
140
|
+
None, H, None),
|
|
141
|
+
"attn_ctx": ActDef(lambda b, c: c.tok(get(b, o).input), H * Dh, H, Dh, label=f"attn_ctx — {o_name} input"),
|
|
142
|
+
"o": ActDef(lambda b, c: c.tok(get(b, o).output), D, label=f"o — {o_name} output"),
|
|
143
|
+
"mlp_norm": ActDef(lambda b, c: c.tok(get(b, n2).output), D, label=f"mlp_norm — {n2}"),
|
|
144
|
+
"down": ActDef(lambda b, c: c.tok(get(b, dn).output), D, label=f"down — {dn_name} output"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def probe_paths(self) -> tuple[list[str], list[str]]:
|
|
148
|
+
"""(model-level paths, block-level paths) that must exist; subclasses extend."""
|
|
149
|
+
return [], [self.norm1, self.norm2, self.attn, self.o_proj, self.down_proj]
|
|
150
|
+
|
|
151
|
+
def probe(self, root) -> list[str]:
|
|
152
|
+
model_paths, block_paths = self.probe_paths()
|
|
153
|
+
return missing_paths(root, model_paths, block_paths, self.layers_path)
|
actlens/archs/gpt2.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""GPT-2 (and DistilGPT-2): LayerNorm pre-norm blocks, fused QKV, learned absolute positions (no RoPE),
|
|
2
|
+
non-gated MLP. A compact example of adding an architecture: a layout, the fused-QKV split and the MLP internals."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import ActDef, Dims, PreNormBlockAdapter, get
|
|
6
|
+
from .registry import register
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register
|
|
10
|
+
class GPT2Adapter(PreNormBlockAdapter):
|
|
11
|
+
name = "gpt2"
|
|
12
|
+
model_types = ("gpt2",)
|
|
13
|
+
layers_path = "transformer.h"
|
|
14
|
+
norm1, attn, o_proj = "ln_1", "attn", "attn.c_proj"
|
|
15
|
+
norm2, down_proj = "ln_2", "mlp.c_proj"
|
|
16
|
+
|
|
17
|
+
def probe_paths(self):
|
|
18
|
+
model_paths, block_paths = super().probe_paths()
|
|
19
|
+
return ([*model_paths, "transformer.wte", "transformer.wpe"],
|
|
20
|
+
[*block_paths, "attn.c_attn", "mlp.c_fc", "mlp.c_proj"])
|
|
21
|
+
|
|
22
|
+
def dims(self, root) -> Dims:
|
|
23
|
+
cfg = root.config # GPT2Config maps hidden_size / num_attention_heads onto n_embd / n_head
|
|
24
|
+
return Dims(n_layers=len(get(root, self.layers_path)), hidden=cfg.hidden_size,
|
|
25
|
+
inter=getattr(cfg, "n_inner", None) or 4 * cfg.hidden_size, n_heads=cfg.num_attention_heads,
|
|
26
|
+
n_kv_heads=cfg.num_attention_heads, head_dim=cfg.hidden_size // cfg.num_attention_heads)
|
|
27
|
+
|
|
28
|
+
def acts(self, root, d: Dims) -> dict[str, ActDef]:
|
|
29
|
+
D, H, Dh, I = d.hidden, d.n_heads, d.head_dim, d.inter
|
|
30
|
+
out = self.shared_acts(d)
|
|
31
|
+
|
|
32
|
+
def qkv(i, name): # c_attn's output is [q | k | v] along the channel axis
|
|
33
|
+
return ActDef(lambda b, c: c.tok(b.attn.c_attn.output)[:, i * D:(i + 1) * D], D, H, Dh,
|
|
34
|
+
label=f"{name} — c_attn[{name}]")
|
|
35
|
+
|
|
36
|
+
out |= {
|
|
37
|
+
"q": qkv(0, "q"), "k": qkv(1, "k"), "v": qkv(2, "v"),
|
|
38
|
+
"up": ActDef(lambda b, c: c.tok(b.mlp.c_fc.output), I, label="up — c_fc"),
|
|
39
|
+
# c_proj's input is exactly act(c_fc(x)); reading it avoids depending on how `act` is implemented.
|
|
40
|
+
"mlp_act": ActDef(lambda b, c: c.tok(b.mlp.c_proj.input), I),
|
|
41
|
+
}
|
|
42
|
+
return out
|
actlens/archs/llama.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Llama-style decoders: Llama, Qwen2/2.5/3, Mistral, SmolLM, ... (gated MLP, RoPE, optional QK-norm, GQA)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
|
|
6
|
+
from .base import ActDef, Dims, PreNormBlockAdapter, TraceCtx, get, has
|
|
7
|
+
from .registry import register
|
|
8
|
+
from .rope import apply_rope
|
|
9
|
+
|
|
10
|
+
ROPE_ACTS = ("q_rope", "k_rope")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register
|
|
14
|
+
class LlamaAdapter(PreNormBlockAdapter):
|
|
15
|
+
name = "llama"
|
|
16
|
+
model_types = ("llama", "qwen2", "qwen3", "mistral")
|
|
17
|
+
layers_path = "model.layers"
|
|
18
|
+
norm1, attn, o_proj = "input_layernorm", "self_attn", "self_attn.o_proj"
|
|
19
|
+
norm2, down_proj = "post_attention_layernorm", "mlp.down_proj"
|
|
20
|
+
|
|
21
|
+
def probe_paths(self):
|
|
22
|
+
model_paths, block_paths = super().probe_paths()
|
|
23
|
+
return ([*model_paths, "model.embed_tokens"],
|
|
24
|
+
[*block_paths, "self_attn.q_proj", "self_attn.k_proj", "self_attn.v_proj",
|
|
25
|
+
"mlp.gate_proj", "mlp.up_proj", "mlp.act_fn"])
|
|
26
|
+
|
|
27
|
+
def dims(self, root) -> Dims:
|
|
28
|
+
cfg = root.config
|
|
29
|
+
n_heads = cfg.num_attention_heads
|
|
30
|
+
return Dims(n_layers=len(get(root, self.layers_path)), hidden=cfg.hidden_size,
|
|
31
|
+
inter=cfg.intermediate_size, n_heads=n_heads,
|
|
32
|
+
n_kv_heads=getattr(cfg, "num_key_value_heads", None) or n_heads,
|
|
33
|
+
head_dim=getattr(cfg, "head_dim", None) or cfg.hidden_size // n_heads)
|
|
34
|
+
|
|
35
|
+
def acts(self, root, d: Dims) -> dict[str, ActDef]:
|
|
36
|
+
block0 = get(root, self.layers_path)[0]
|
|
37
|
+
has_qk_norm = has(block0, "self_attn.q_norm") and has(block0, "self_attn.k_norm")
|
|
38
|
+
has_rope = has(root, "model.rotary_emb")
|
|
39
|
+
act_fn_is_module = isinstance(block0.mlp.act_fn, torch.nn.Module)
|
|
40
|
+
nH, nKV, Dh, I = d.n_heads, d.n_kv_heads, d.head_dim, d.inter
|
|
41
|
+
out = self.shared_acts(d)
|
|
42
|
+
|
|
43
|
+
def proj(name, heads):
|
|
44
|
+
return ActDef(lambda b, c: c.tok(get(b, f"self_attn.{name}_proj").output), heads * Dh, heads, Dh)
|
|
45
|
+
|
|
46
|
+
def qk_norm(name, heads):
|
|
47
|
+
return ActDef(lambda b, c: c.tok(get(b, f"self_attn.{name}_norm").output), heads * Dh, heads, Dh)
|
|
48
|
+
|
|
49
|
+
def rope(name, heads):
|
|
50
|
+
src = f"self_attn.{name}_norm" if has_qk_norm else f"self_attn.{name}_proj"
|
|
51
|
+
|
|
52
|
+
def read(b, c: TraceCtx):
|
|
53
|
+
x = c.host(get(b, src).output).reshape(c.n_tokens, heads, Dh)
|
|
54
|
+
return apply_rope(x, *c.aux["rope"]).reshape(c.n_tokens, -1)
|
|
55
|
+
return ActDef(read, heads * Dh, heads, Dh)
|
|
56
|
+
|
|
57
|
+
out |= {"q": proj("q", nH), "k": proj("k", nKV), "v": proj("v", nKV)}
|
|
58
|
+
if has_qk_norm:
|
|
59
|
+
out |= {"q_norm": qk_norm("q", nH), "k_norm": qk_norm("k", nKV)}
|
|
60
|
+
if has_rope:
|
|
61
|
+
out |= {"q_rope": rope("q", nH), "k_rope": rope("k", nKV)}
|
|
62
|
+
|
|
63
|
+
def silu(b, c):
|
|
64
|
+
if act_fn_is_module:
|
|
65
|
+
return c.tok(b.mlp.act_fn.output)
|
|
66
|
+
return c.tok(torch.nn.functional.silu(b.mlp.gate_proj.output))
|
|
67
|
+
|
|
68
|
+
out |= {
|
|
69
|
+
"gate": ActDef(lambda b, c: c.tok(b.mlp.gate_proj.output), I),
|
|
70
|
+
"up": ActDef(lambda b, c: c.tok(b.mlp.up_proj.output), I),
|
|
71
|
+
"silu": ActDef(silu, I),
|
|
72
|
+
"swiglu": ActDef(lambda b, c: c.tok(b.mlp.down_proj.input), I),
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def prepare(self, model, ctx: TraceCtx, act: str) -> None:
|
|
77
|
+
if act in ROPE_ACTS: # cos/sin are produced before the first block runs
|
|
78
|
+
cos, sin = model.model.rotary_emb.output
|
|
79
|
+
ctx.aux["rope"] = (ctx.host(cos), ctx.host(sin))
|