logogram 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.
Files changed (54) hide show
  1. logogram/__init__.py +6 -0
  2. logogram/__main__.py +5 -0
  3. logogram/analysis.py +419 -0
  4. logogram/atp.py +120 -0
  5. logogram/backends/__init__.py +5 -0
  6. logogram/backends/base.py +202 -0
  7. logogram/backends/hub.py +375 -0
  8. logogram/backends/saes.py +277 -0
  9. logogram/backends/transformer_lens.py +872 -0
  10. logogram/cli.py +496 -0
  11. logogram/compare.py +177 -0
  12. logogram/datasets.py +159 -0
  13. logogram/direct.py +193 -0
  14. logogram/engine.py +550 -0
  15. logogram/examples/ioi-gpt2/.gitignore +3 -0
  16. logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
  17. logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
  18. logogram/examples/ioi-gpt2/project.json +6 -0
  19. logogram/exports.py +33 -0
  20. logogram/features.py +368 -0
  21. logogram/fileio.py +63 -0
  22. logogram/ioi.py +220 -0
  23. logogram/paths.py +204 -0
  24. logogram/project.py +444 -0
  25. logogram/prompts.py +204 -0
  26. logogram/research.py +84 -0
  27. logogram/results.py +240 -0
  28. logogram/runner.py +396 -0
  29. logogram/runs.py +98 -0
  30. logogram/sae.py +161 -0
  31. logogram/schema.py +302 -0
  32. logogram/server/__init__.py +1 -0
  33. logogram/server/app.py +1083 -0
  34. logogram/server/models.py +426 -0
  35. logogram/server/security.py +212 -0
  36. logogram/server/state.py +585 -0
  37. logogram/sites.py +249 -0
  38. logogram/spec.py +518 -0
  39. logogram/stats.py +171 -0
  40. logogram/steering.py +258 -0
  41. logogram/system.py +379 -0
  42. logogram/updates.py +194 -0
  43. logogram/verify.py +39 -0
  44. logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
  45. logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
  46. logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
  47. logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
  48. logogram/web_dist/favicon.svg +1 -0
  49. logogram/web_dist/index.html +15 -0
  50. logogram-0.1.0.dist-info/METADATA +550 -0
  51. logogram-0.1.0.dist-info/RECORD +54 -0
  52. logogram-0.1.0.dist-info/WHEEL +4 -0
  53. logogram-0.1.0.dist-info/entry_points.txt +2 -0
  54. logogram-0.1.0.dist-info/licenses/LICENSE +21 -0
logogram/steering.py ADDED
@@ -0,0 +1,258 @@
1
+ """Steering: add a direction to the residual stream at several strengths, and measure the effect.
2
+
3
+ At each steered site (a residual stream state at one token), the direction is the mean of
4
+ (other prompt - receiver prompt) over a training split of the pairs. Adding it at strength 1 to a
5
+ held-out receiver prompt moves that prompt's activation by the average difference between the two
6
+ prompts; the effect is normalized like patching, so 1 means the output moved as far as switching
7
+ to the other prompt. Directions are never applied to the prompts that computed them. A random
8
+ direction of the same length, at the same strengths, is the control: a direction that matters
9
+ should beat it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from logogram.backends.base import Cancelled, ModelBackend, ModelInfo, Patch
22
+ from logogram.engine import (
23
+ EngineError,
24
+ EngineResult,
25
+ LayerFn,
26
+ ProgressFn,
27
+ _answer_tensors,
28
+ _chunks,
29
+ _LayerSources,
30
+ _metric,
31
+ check_gap,
32
+ compute_baselines,
33
+ )
34
+ from logogram.prompts import PreparedPrompt, group_by_length
35
+ from logogram.sites import (
36
+ ResolvedSite,
37
+ ScopeError,
38
+ _check_site,
39
+ resolve_position,
40
+ site_label,
41
+ )
42
+ from logogram.spec import (
43
+ AllPositions,
44
+ LayerComponentsScope,
45
+ Site,
46
+ SitesScope,
47
+ Spec,
48
+ Steering,
49
+ strength_text,
50
+ )
51
+
52
+ RESIDUAL = ("resid_pre", "resid_mid", "resid_post")
53
+
54
+
55
+ def split_pairs(n: int, train_fraction: float, seed: int) -> tuple[list[int], list[int]]:
56
+ """Positions of the pairs that train the direction and of the held-out pairs it is measured
57
+ on: a seeded shuffle from the raw PCG64 stream, so the split is the same on every machine."""
58
+ n_train = int(round(n * train_fraction))
59
+ if n_train < 1 or n - n_train < 2:
60
+ raise EngineError(
61
+ f"Steering needs at least one prompt pair to compute the direction and two to measure "
62
+ f"it on, but {n} pairs with a training share of {train_fraction:.0%} leaves "
63
+ f"{n_train} and {n - n_train}. Use more prompts or another training share."
64
+ )
65
+ order = list(range(n))
66
+ raw = np.random.PCG64(seed).random_raw(n)
67
+ for t in range(n - 1, 0, -1):
68
+ j = int(raw[t] % np.uint64(t + 1))
69
+ order[t], order[j] = order[j], order[t]
70
+ return sorted(order[:n_train]), sorted(order[n_train:])
71
+
72
+
73
+ def steered_sites(spec: Spec, info: ModelInfo, prompts: list[PreparedPrompt]) -> list[Site]:
74
+ """The residual-stream sites a steering spec adds its direction at, one token per prompt."""
75
+ scope = spec.scope
76
+ if isinstance(scope, LayerComponentsScope):
77
+ if len(scope.components) != 1 or scope.components[0] not in RESIDUAL:
78
+ raise ScopeError(
79
+ "Steering adds a direction to the residual stream: choose exactly one residual "
80
+ "site (before, between or after the layer's blocks)."
81
+ )
82
+ sites = [
83
+ Site(kind=scope.components[0], layer=layer, position=scope.position)
84
+ for layer in range(info.n_layers)
85
+ ]
86
+ elif isinstance(scope, SitesScope):
87
+ sites = list(scope.sites)
88
+ if any(s.kind not in RESIDUAL for s in sites):
89
+ raise ScopeError(
90
+ "Steering adds a direction to the residual stream; heads, attention outputs and "
91
+ "MLP outputs can't be steered here. Choose residual sites."
92
+ )
93
+ else:
94
+ raise ScopeError(
95
+ "Steering runs at one residual site in every layer, or at chosen residual sites. "
96
+ "Choose “Attention and MLP per layer” with a single residual component, or pick sites."
97
+ )
98
+ for site in sites:
99
+ if isinstance(site.position, AllPositions):
100
+ raise ScopeError(
101
+ "Steering adds the direction at one token of each prompt. Choose the last token, a "
102
+ "named position or a token index."
103
+ )
104
+ _check_site(site, info)
105
+ for prompt in prompts:
106
+ resolve_position(site.position, prompt)
107
+ return sites
108
+
109
+
110
+ def run_steering(
111
+ spec: Spec,
112
+ backend: ModelBackend,
113
+ prompts: list[PreparedPrompt],
114
+ *,
115
+ on_progress: ProgressFn | None = None,
116
+ on_layer: LayerFn | None = None,
117
+ cancel: threading.Event | None = None,
118
+ on_start: Callable[[list[ResolvedSite], dict[str, Any]], None] | None = None,
119
+ ) -> EngineResult:
120
+ exp = spec.experiment
121
+ assert isinstance(exp, Steering)
122
+ info = backend.info
123
+ batch_size = spec.execution.batch_size
124
+ base = steered_sites(spec, info, prompts)
125
+ train_at, test_at = split_pairs(len(prompts), exp.train_fraction, exp.seed)
126
+ train = [prompts[i] for i in train_at]
127
+ test = [prompts[i] for i in test_at]
128
+ receiver = exp.apply_to
129
+ reference = "corrupt" if receiver == "clean" else "clean"
130
+
131
+ # The variants of each site: every strength along the direction, then along the control.
132
+ variants = [(c, False) for c in exp.coefficients]
133
+ if exp.control:
134
+ variants += [(c, True) for c in exp.coefficients]
135
+ keys = [
136
+ f"random {strength_text(c)}" if control else strength_text(c) for c, control in variants
137
+ ]
138
+ uniform = len({(s.kind, s.position.model_dump_json()) for s in base}) == 1
139
+ sites: list[ResolvedSite] = []
140
+ for b, site in enumerate(base):
141
+ for v, ((coefficient, control), key) in enumerate(zip(variants, keys, strict=True)):
142
+ sites.append(
143
+ ResolvedSite(
144
+ index=len(sites),
145
+ site=site,
146
+ row=b,
147
+ col=v,
148
+ label=f"{site_label(site)} {key}",
149
+ variant={"coefficient": coefficient, "control": control},
150
+ variant_key=key,
151
+ )
152
+ )
153
+ layout = {
154
+ "kind": "steering",
155
+ "site": base[0].kind if uniform else None,
156
+ "row_title": "Layer" if uniform else "Site",
157
+ "col_title": "Strength",
158
+ "rows": [
159
+ {"key": str(b), "label": str(s.layer) if uniform else site_label(s)}
160
+ for b, s in enumerate(base)
161
+ ],
162
+ "cols": [
163
+ {"key": key, "label": key, "coefficient": c, "control": control}
164
+ for (c, control), key in zip(variants, keys, strict=True)
165
+ ],
166
+ }
167
+ if on_start is not None:
168
+ on_start(sites, layout)
169
+
170
+ test_groups = group_by_length(test)
171
+ train_groups = group_by_length(train)
172
+ baselines = compute_baselines(backend, test, test_groups, batch_size, cancel)
173
+ warnings = check_gap(spec, baselines, test, receiver, reference)
174
+ n = len(test)
175
+ patched_ld = np.zeros((len(sites), n))
176
+ patched_prob = np.zeros((len(sites), n))
177
+ result = EngineResult(
178
+ sites=sites,
179
+ layout=layout,
180
+ prompts=test,
181
+ baselines=baselines,
182
+ receiver=receiver,
183
+ reference=reference,
184
+ patched_ld=patched_ld,
185
+ patched_prob=patched_prob,
186
+ warnings=warnings,
187
+ extra={
188
+ "steering": {
189
+ "train": [p.index for p in train],
190
+ "test": [p.index for p in test],
191
+ "norms": [],
192
+ }
193
+ },
194
+ )
195
+ model_dtype = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16}
196
+ dtype = model_dtype[info.dtype]
197
+ local_of = {i: li for g in test_groups for li, i in enumerate(g.members)}
198
+ total = len(sites) * n
199
+ done = 0
200
+ for b, site in enumerate(base):
201
+ probe = ResolvedSite(0, site, 0, 0, "")
202
+ kinds = [site.kind]
203
+ # The direction: mean (reference - receiver) over the training pairs, at the site's token.
204
+ toward = _LayerSources(
205
+ backend, train, train_groups, site.layer, kinds, reference, batch_size, cancel
206
+ )
207
+ away = _LayerSources(
208
+ backend, train, train_groups, site.layer, kinds, receiver, batch_size, cancel
209
+ )
210
+ direction = (
211
+ toward.table(site.kind, probe).double() - away.table(site.kind, probe).double()
212
+ ).mean(0)
213
+ norm = float(direction.norm())
214
+ result.extra["steering"]["norms"].append(norm)
215
+ control_direction = None
216
+ if exp.control:
217
+ generator = torch.Generator().manual_seed(exp.seed * 1_000_003 + b)
218
+ noise = torch.randn(direction.shape, generator=generator, dtype=torch.float64)
219
+ control_direction = noise / noise.norm() * norm
220
+ # The held-out receivers' own activations, to which the direction is added.
221
+ own = _LayerSources(
222
+ backend, test, test_groups, site.layer, kinds, receiver, batch_size, cancel
223
+ ).table(site.kind, probe)
224
+ positions = [resolve_position(site.position, p) for p in test]
225
+ first = b * len(variants)
226
+ for group in test_groups:
227
+ tokens = group.clean if receiver == "clean" else group.corrupt
228
+ rows = [(v, p) for v in range(len(variants)) for p in group.members]
229
+ for sl in _chunks(len(rows), batch_size):
230
+ if cancel is not None and cancel.is_set():
231
+ raise Cancelled()
232
+ chunk = rows[sl]
233
+ values = torch.stack(
234
+ [
235
+ own[p].double()
236
+ + variants[v][0]
237
+ * (control_direction if variants[v][1] else direction).to(own.device)
238
+ for v, p in chunk
239
+ ]
240
+ ).to(dtype)
241
+ patch = Patch(
242
+ kind=site.kind,
243
+ layer=site.layer,
244
+ values=values,
245
+ positions=torch.tensor([positions[p] for _, p in chunk], dtype=torch.long),
246
+ )
247
+ local = torch.tensor([local_of[p] for _, p in chunk], dtype=torch.long)
248
+ logits = backend.final_logits(tokens[local], patch)
249
+ ld, prob = _metric(logits, *_answer_tensors(test, [p for _, p in chunk]))
250
+ for (v, p), value_ld, value_prob in zip(chunk, ld, prob, strict=True):
251
+ patched_ld[first + v, p] = value_ld
252
+ patched_prob[first + v, p] = value_prob
253
+ done += len(chunk)
254
+ if on_progress is not None:
255
+ on_progress(done, total, site.layer)
256
+ if on_layer is not None:
257
+ on_layer(site.layer, list(range(first, first + len(variants))), result)
258
+ return result
logogram/system.py ADDED
@@ -0,0 +1,379 @@
1
+ """Hardware and environment report, with concrete fixes for common problems.
2
+
3
+ Used by the first-run system check, by ``logogram doctor`` and by the memory estimate. Nothing
4
+ here is written to disk or sent anywhere.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import functools
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import asdict, dataclass, field
17
+ from importlib.metadata import PackageNotFoundError, version
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import psutil
22
+
23
+ GB = 1024**3
24
+
25
+
26
+ @dataclass
27
+ class Issue:
28
+ severity: str # "error" | "warning" | "info"
29
+ title: str
30
+ detail: str
31
+ fix: str | None = None # a command or a short instruction
32
+
33
+
34
+ @dataclass
35
+ class GPU:
36
+ name: str
37
+ memory_total: int
38
+ memory_free: int | None
39
+ bf16: bool
40
+
41
+
42
+ @dataclass
43
+ class SystemReport:
44
+ os: str
45
+ machine: str
46
+ python: str
47
+ cpu: str
48
+ cpu_cores: int | None
49
+ cpu_threads: int | None
50
+ memory_total: int
51
+ memory_available: int
52
+ gpus: list[GPU]
53
+ backend: str # cuda | mps | cpu
54
+ torch: str
55
+ torch_cuda: str | None
56
+ transformer_lens: str | None
57
+ transformers: str | None
58
+ recommended_dtype: str
59
+ precision_note: str
60
+ hf_cache_free: int | None
61
+ issues: list[Issue] = field(default_factory=list)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ return asdict(self)
65
+
66
+
67
+ def _pkg(name: str) -> str | None:
68
+ try:
69
+ return version(name)
70
+ except PackageNotFoundError:
71
+ return None
72
+
73
+
74
+ @functools.cache
75
+ def cpu_name() -> str:
76
+ system = platform.system()
77
+ try:
78
+ if system == "Linux":
79
+ for line in Path("/proc/cpuinfo").read_text().splitlines():
80
+ if line.lower().startswith("model name"):
81
+ return line.split(":", 1)[1].strip()
82
+ elif system == "Darwin":
83
+ out = subprocess.run(
84
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=2,
88
+ )
89
+ if out.stdout.strip():
90
+ return out.stdout.strip()
91
+ elif system == "Windows":
92
+ name = platform.processor()
93
+ if name:
94
+ return name
95
+ except Exception: # noqa: BLE001
96
+ pass
97
+ return platform.processor() or platform.machine() or "Unknown CPU"
98
+
99
+
100
+ def _os_label() -> str:
101
+ system = platform.system()
102
+ if system == "Darwin":
103
+ return f"macOS {platform.mac_ver()[0]}"
104
+ if system == "Windows":
105
+ return f"Windows {platform.release()}"
106
+ if system == "Linux":
107
+ try:
108
+ info = platform.freedesktop_os_release()
109
+ return info.get("PRETTY_NAME") or info.get("NAME") or "Linux"
110
+ except OSError:
111
+ return "Linux"
112
+ return system or "Unknown"
113
+
114
+
115
+ def nvidia_driver_present() -> bool:
116
+ if Path("/proc/driver/nvidia/version").exists():
117
+ return True
118
+ return shutil.which("nvidia-smi") is not None
119
+
120
+
121
+ def _rosetta() -> bool:
122
+ if platform.system() != "Darwin" or platform.machine() != "x86_64":
123
+ return False
124
+ try:
125
+ out = subprocess.run(
126
+ ["sysctl", "-n", "sysctl.proc_translated"], capture_output=True, text=True, timeout=2
127
+ )
128
+ return out.stdout.strip() == "1"
129
+ except Exception: # noqa: BLE001
130
+ return False
131
+
132
+
133
+ def installed_as_uv_tool() -> bool:
134
+ return f"uv{os.sep}tools{os.sep}logogram" in sys.prefix.replace("/", os.sep)
135
+
136
+
137
+ def reinstall_command(backend: str) -> str:
138
+ if installed_as_uv_tool():
139
+ return f"uv tool install --reinstall --torch-backend={backend} logogram"
140
+ return f"uv pip install --reinstall torch --torch-backend={backend}"
141
+
142
+
143
+ def hf_cache_dir() -> Path:
144
+ from huggingface_hub import constants
145
+
146
+ return Path(constants.HF_HUB_CACHE)
147
+
148
+
149
+ def system_report() -> SystemReport:
150
+ import torch
151
+
152
+ vm = psutil.virtual_memory()
153
+ gpus: list[GPU] = []
154
+ backend = "cpu"
155
+ if torch.cuda.is_available():
156
+ backend = "cuda"
157
+ for i in range(torch.cuda.device_count()):
158
+ props = torch.cuda.get_device_properties(i)
159
+ free = None
160
+ with contextlib.suppress(Exception):
161
+ free, _ = torch.cuda.mem_get_info(i)
162
+ gpus.append(
163
+ GPU(
164
+ name=props.name,
165
+ memory_total=int(props.total_memory),
166
+ memory_free=int(free) if free is not None else None,
167
+ bf16=bool(torch.cuda.is_bf16_supported()),
168
+ )
169
+ )
170
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
171
+ backend = "mps"
172
+ gpus.append(
173
+ GPU(name="Apple GPU (Metal)", memory_total=int(vm.total), memory_free=None, bf16=True)
174
+ )
175
+
176
+ issues: list[Issue] = []
177
+ torch_cuda = torch.version.cuda
178
+ if backend != "cuda" and nvidia_driver_present():
179
+ if torch_cuda is None:
180
+ issues.append(
181
+ Issue(
182
+ "warning",
183
+ "Your NVIDIA GPU isn't being used",
184
+ "This PyTorch build is CPU-only, so experiments run on the CPU. Reinstall "
185
+ "PyTorch with CUDA support to use the GPU.",
186
+ reinstall_command("auto"),
187
+ )
188
+ )
189
+ else:
190
+ issues.append(
191
+ Issue(
192
+ "warning",
193
+ "PyTorch can't reach your NVIDIA GPU",
194
+ f"PyTorch was built for CUDA {torch_cuda}, but the NVIDIA driver can't run it. "
195
+ "Update the NVIDIA driver, or install a PyTorch build for an older CUDA "
196
+ "version (for example cu126).",
197
+ reinstall_command("cu126"),
198
+ )
199
+ )
200
+ if platform.system() == "Darwin" and backend == "cpu":
201
+ if _rosetta():
202
+ issues.append(
203
+ Issue(
204
+ "warning",
205
+ "Python is running under Rosetta",
206
+ "This is an Intel (x86_64) Python on Apple Silicon, so the GPU (MPS) can't be "
207
+ "used. Install a native arm64 Python and reinstall Logogram with it.",
208
+ "uv python install 3.12 && uv tool install --reinstall --python 3.12 logogram",
209
+ )
210
+ )
211
+ elif platform.machine() == "arm64":
212
+ issues.append(
213
+ Issue(
214
+ "warning",
215
+ "Apple GPU (MPS) unavailable",
216
+ "PyTorch can't use Metal on this Mac. MPS needs macOS 12.3 or later.",
217
+ "Update macOS, then reinstall PyTorch.",
218
+ )
219
+ )
220
+ if vm.total < 8 * GB:
221
+ issues.append(
222
+ Issue(
223
+ "warning",
224
+ "Less than 8 GB of memory",
225
+ "GPT-2 small fits, but larger models may not. Close other applications before "
226
+ "running sweeps.",
227
+ )
228
+ )
229
+ cache_free = None
230
+ try:
231
+ cache = hf_cache_dir()
232
+ probe = cache if cache.exists() else Path.home()
233
+ cache_free = shutil.disk_usage(probe).free
234
+ if cache_free < 2 * GB:
235
+ issues.append(
236
+ Issue(
237
+ "warning",
238
+ "Low disk space for model downloads",
239
+ f"Only {cache_free / GB:.1f} GB is free where Hugging Face stores models. "
240
+ "GPT-2 small needs about 0.6 GB.",
241
+ "Free some space, or set HF_HOME to a disk with more room.",
242
+ )
243
+ )
244
+ except Exception: # noqa: BLE001
245
+ pass
246
+
247
+ bf16 = any(g.bf16 for g in gpus)
248
+ note = "float32 keeps results exact; use it whenever the model fits."
249
+ if backend == "cuda" and bf16:
250
+ note += " bfloat16 is available on this GPU for models that don't fit in float32."
251
+ elif backend == "cpu":
252
+ note += " 16-bit types are slow on most CPUs."
253
+
254
+ try:
255
+ cores = psutil.cpu_count(logical=False)
256
+ except Exception: # noqa: BLE001
257
+ cores = None
258
+ return SystemReport(
259
+ os=_os_label(),
260
+ machine=platform.machine(),
261
+ python=platform.python_version(),
262
+ cpu=cpu_name(),
263
+ cpu_cores=cores,
264
+ cpu_threads=psutil.cpu_count(logical=True),
265
+ memory_total=int(vm.total),
266
+ memory_available=int(vm.available),
267
+ gpus=gpus,
268
+ backend=backend,
269
+ torch=torch.__version__,
270
+ torch_cuda=torch_cuda,
271
+ transformer_lens=_pkg("transformer-lens"),
272
+ transformers=_pkg("transformers"),
273
+ recommended_dtype="float32",
274
+ precision_note=note,
275
+ hf_cache_free=cache_free,
276
+ issues=issues,
277
+ )
278
+
279
+
280
+ # -- memory estimate ---------------------------------------------------------------------------
281
+
282
+ DTYPE_BYTES = {"float32": 4, "float16": 2, "bfloat16": 2}
283
+
284
+
285
+ @dataclass
286
+ class MemoryEstimate:
287
+ device: str
288
+ dtype: str
289
+ n_params: int
290
+ weights: int
291
+ activations: int
292
+ margin: int
293
+ total: int
294
+ available: int
295
+ verdict: str # fits | tight | wont_fit
296
+ explanation: str
297
+
298
+ def to_dict(self) -> dict[str, Any]:
299
+ return asdict(self)
300
+
301
+
302
+ def available_memory(device: str) -> int:
303
+ import torch
304
+
305
+ if device == "cuda" and torch.cuda.is_available():
306
+ free, _ = torch.cuda.mem_get_info()
307
+ return int(free)
308
+ if device == "mps":
309
+ try:
310
+ return int(torch.mps.recommended_max_memory()) - int(
311
+ torch.mps.current_allocated_memory()
312
+ )
313
+ except Exception: # noqa: BLE001
314
+ pass
315
+ return int(psutil.virtual_memory().available)
316
+
317
+
318
+ def estimate_memory(
319
+ *,
320
+ n_params: int,
321
+ n_layers: int,
322
+ n_heads: int,
323
+ d_model: int,
324
+ d_mlp: int,
325
+ d_vocab: int,
326
+ dtype: str,
327
+ device: str,
328
+ batch_size: int = 64,
329
+ seq_len: int = 32,
330
+ loaded_bytes: int = 0,
331
+ ) -> MemoryEstimate:
332
+ """Weights + activations of one batch + a safety margin, against free memory.
333
+
334
+ ``loaded_bytes`` is memory held by a model that loading this one would replace.
335
+ """
336
+ b = DTYPE_BYTES[dtype]
337
+ weights = n_params * b
338
+ tokens = batch_size * seq_len
339
+ # One forward pass: residual-sized tensors per block, MLP hidden states, attention scores and
340
+ # patterns, final logits for the last position, plus one layer of cached source activations.
341
+ per_layer = tokens * (8 * d_model + 2 * d_mlp) * b + 3 * batch_size * n_heads * seq_len**2 * b
342
+ activations = per_layer * 2 + batch_size * d_vocab * 4 + 3 * tokens * d_model * b
343
+ margin = int(0.15 * (weights + activations)) + (
344
+ 512 * 1024**2 if device == "cuda" else 256 * 1024**2
345
+ )
346
+ total = weights + activations + margin
347
+ available = available_memory(device) + loaded_bytes
348
+ if total <= 0.8 * available:
349
+ verdict = "fits"
350
+ elif total <= available:
351
+ verdict = "tight"
352
+ else:
353
+ verdict = "wont_fit"
354
+ where = {"cuda": "GPU memory", "mps": "unified memory", "cpu": "RAM"}.get(device, "memory")
355
+ explanation = (
356
+ f"{n_params / 1e6:,.0f}M stored values × {b} bytes ({dtype}) for weights; activations for a "
357
+ f"batch of {batch_size} prompts of {seq_len} tokens; a margin of 15% plus runtime "
358
+ f"overhead. Compared with free {where}."
359
+ )
360
+ return MemoryEstimate(
361
+ device=device,
362
+ dtype=dtype,
363
+ n_params=n_params,
364
+ weights=weights,
365
+ activations=activations,
366
+ margin=margin,
367
+ total=total,
368
+ available=available,
369
+ verdict=verdict,
370
+ explanation=explanation,
371
+ )
372
+
373
+
374
+ def format_bytes(n: int | float | None) -> str:
375
+ if n is None:
376
+ return "—"
377
+ if n >= GB:
378
+ return f"{n / GB:.1f} GB"
379
+ return f"{n / 1024**2:.0f} MB"