open-interpretability 0.0.20__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 (256) hide show
  1. open_interpretability-0.0.20.dist-info/METADATA +157 -0
  2. open_interpretability-0.0.20.dist-info/RECORD +256 -0
  3. open_interpretability-0.0.20.dist-info/WHEEL +4 -0
  4. open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
  5. open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
  6. openinterp/__init__.py +6 -0
  7. openinterp/agents/__init__.py +25 -0
  8. openinterp/agents/base.py +229 -0
  9. openinterp/agents/judge.py +149 -0
  10. openinterp/agents/proposer.py +471 -0
  11. openinterp/agents/proposer_profiles.py +79 -0
  12. openinterp/agents/review_measurements.py +173 -0
  13. openinterp/agents/reviewer.py +498 -0
  14. openinterp/agreement.py +63 -0
  15. openinterp/audit_options.py +79 -0
  16. openinterp/baseline.py +254 -0
  17. openinterp/benchmark.py +450 -0
  18. openinterp/blends.py +239 -0
  19. openinterp/builder/__init__.py +6 -0
  20. openinterp/builder/agents.py +260 -0
  21. openinterp/builder/aligned.py +41 -0
  22. openinterp/builder/charts.py +411 -0
  23. openinterp/builder/circuit_pages.py +37 -0
  24. openinterp/builder/circuits.py +264 -0
  25. openinterp/builder/compiler.py +495 -0
  26. openinterp/builder/corpora.py +98 -0
  27. openinterp/builder/coverage.py +46 -0
  28. openinterp/builder/dashboard.py +255 -0
  29. openinterp/builder/disk_memo.py +65 -0
  30. openinterp/builder/experiment.py +105 -0
  31. openinterp/builder/heat.py +121 -0
  32. openinterp/builder/hrefs.py +71 -0
  33. openinterp/builder/learned_baseline.py +204 -0
  34. openinterp/builder/model_facts.py +138 -0
  35. openinterp/builder/neuronpedia_export.py +107 -0
  36. openinterp/builder/parallel.py +100 -0
  37. openinterp/builder/parquet_export.py +458 -0
  38. openinterp/builder/pieces.py +127 -0
  39. openinterp/builder/pii.py +211 -0
  40. openinterp/builder/registry_stats.py +144 -0
  41. openinterp/builder/research.py +109 -0
  42. openinterp/builder/sections.py +28 -0
  43. openinterp/builder/significance.py +103 -0
  44. openinterp/builder/sister_graph.py +180 -0
  45. openinterp/builder/standing.py +185 -0
  46. openinterp/builder/statuses.py +34 -0
  47. openinterp/builder/stopped.py +91 -0
  48. openinterp/builder/stops.py +30 -0
  49. openinterp/builder/superpositions.py +102 -0
  50. openinterp/builder/templates/_agent_role_cells.html +9 -0
  51. openinterp/builder/templates/_causal_chart.html +67 -0
  52. openinterp/builder/templates/_circuit.html +8 -0
  53. openinterp/builder/templates/_dashboard.html +133 -0
  54. openinterp/builder/templates/_experiment.html +500 -0
  55. openinterp/builder/templates/_macros.html +82 -0
  56. openinterp/builder/templates/_patchscope.html +33 -0
  57. openinterp/builder/templates/_prior_art.html +24 -0
  58. openinterp/builder/templates/_specificity.html +21 -0
  59. openinterp/builder/templates/_steering.html +34 -0
  60. openinterp/builder/templates/_superposition.html +62 -0
  61. openinterp/builder/templates/_vocab_chart.html +88 -0
  62. openinterp/builder/templates/_wiring.html +44 -0
  63. openinterp/builder/templates/about.html +95 -0
  64. openinterp/builder/templates/agent.html +69 -0
  65. openinterp/builder/templates/agents.html +80 -0
  66. openinterp/builder/templates/base.html +103 -0
  67. openinterp/builder/templates/circuit.html +64 -0
  68. openinterp/builder/templates/circuits.html +46 -0
  69. openinterp/builder/templates/experiment.html +21 -0
  70. openinterp/builder/templates/hub.html +16 -0
  71. openinterp/builder/templates/index.html +387 -0
  72. openinterp/builder/templates/layer.html +69 -0
  73. openinterp/builder/templates/learned-baseline.html +44 -0
  74. openinterp/builder/templates/model.html +151 -0
  75. openinterp/builder/templates/models.html +47 -0
  76. openinterp/builder/templates/neuron.html +234 -0
  77. openinterp/builder/templates/paper.html +39 -0
  78. openinterp/builder/templates/redirect.html +1 -0
  79. openinterp/builder/templates/research.html +31 -0
  80. openinterp/builder/templates/user.html +54 -0
  81. openinterp/builder/templates/users.html +39 -0
  82. openinterp/builder/templates/wiki/experiment.md +271 -0
  83. openinterp/builder/templates/wiki/index.md +32 -0
  84. openinterp/builder/templates/wiki/neuron.md +30 -0
  85. openinterp/builder/templates/wiki/stop.md +36 -0
  86. openinterp/builder/tree.py +470 -0
  87. openinterp/builder/users.py +142 -0
  88. openinterp/builder/wiki.py +427 -0
  89. openinterp/checks.py +429 -0
  90. openinterp/cli.py +494 -0
  91. openinterp/cli_baseline.py +332 -0
  92. openinterp/cli_benchmark.py +300 -0
  93. openinterp/cli_build.py +128 -0
  94. openinterp/cli_check.py +51 -0
  95. openinterp/cli_concepts.py +76 -0
  96. openinterp/cli_corpus.py +252 -0
  97. openinterp/cli_costs.py +257 -0
  98. openinterp/cli_coverage.py +76 -0
  99. openinterp/cli_crossref.py +86 -0
  100. openinterp/cli_errata.py +142 -0
  101. openinterp/cli_evidence.py +495 -0
  102. openinterp/cli_evidence_store.py +93 -0
  103. openinterp/cli_explain.py +101 -0
  104. openinterp/cli_loop.py +125 -0
  105. openinterp/cli_memory.py +28 -0
  106. openinterp/cli_neuronpedia.py +175 -0
  107. openinterp/cli_pause.py +45 -0
  108. openinterp/cli_pole_effects.py +133 -0
  109. openinterp/cli_probing.py +119 -0
  110. openinterp/cli_registry.py +348 -0
  111. openinterp/cli_reproduce.py +446 -0
  112. openinterp/cli_review.py +302 -0
  113. openinterp/cli_scan.py +223 -0
  114. openinterp/cli_score.py +446 -0
  115. openinterp/cli_sisters.py +86 -0
  116. openinterp/cli_submission.py +217 -0
  117. openinterp/cli_superpositions.py +118 -0
  118. openinterp/cli_wiring.py +54 -0
  119. openinterp/codeid.py +73 -0
  120. openinterp/concepts.py +400 -0
  121. openinterp/core/__init__.py +59 -0
  122. openinterp/core/agent_names.py +76 -0
  123. openinterp/core/attribution.py +101 -0
  124. openinterp/core/cache_dirs.py +15 -0
  125. openinterp/core/causal.py +82 -0
  126. openinterp/core/confounds.py +103 -0
  127. openinterp/core/contributors.py +26 -0
  128. openinterp/core/corpora.py +126 -0
  129. openinterp/core/costs.py +430 -0
  130. openinterp/core/draws.py +109 -0
  131. openinterp/core/errata.py +244 -0
  132. openinterp/core/evidence.py +160 -0
  133. openinterp/core/examples.py +93 -0
  134. openinterp/core/exceptions.py +29 -0
  135. openinterp/core/explanation.py +130 -0
  136. openinterp/core/fence.py +42 -0
  137. openinterp/core/heavy_lock.py +114 -0
  138. openinterp/core/identifiers.py +198 -0
  139. openinterp/core/isolation.py +149 -0
  140. openinterp/core/ledger.py +130 -0
  141. openinterp/core/licenses.py +28 -0
  142. openinterp/core/manifold.py +117 -0
  143. openinterp/core/partners.py +32 -0
  144. openinterp/core/patchscope.py +59 -0
  145. openinterp/core/paths.py +40 -0
  146. openinterp/core/pause.py +96 -0
  147. openinterp/core/pricing.py +178 -0
  148. openinterp/core/prior_art.py +44 -0
  149. openinterp/core/probing.py +146 -0
  150. openinterp/core/profiles.py +221 -0
  151. openinterp/core/project.py +28 -0
  152. openinterp/core/run_environment.py +38 -0
  153. openinterp/core/schemas.py +484 -0
  154. openinterp/core/specificity.py +49 -0
  155. openinterp/core/stats.py +172 -0
  156. openinterp/core/steering.py +49 -0
  157. openinterp/core/strict.py +19 -0
  158. openinterp/core/takedowns.py +136 -0
  159. openinterp/core/transcripts.py +130 -0
  160. openinterp/core/wiring.py +80 -0
  161. openinterp/corpus.py +476 -0
  162. openinterp/corpus_cache.py +145 -0
  163. openinterp/corpus_layouts.py +108 -0
  164. openinterp/corpus_presets.py +168 -0
  165. openinterp/corpus_rebuild.py +93 -0
  166. openinterp/coverage.py +155 -0
  167. openinterp/crossref.py +442 -0
  168. openinterp/data/__init__.py +9 -0
  169. openinterp/data/offensive_words.txt +58 -0
  170. openinterp/data/public_suffix_list.dat +15771 -0
  171. openinterp/data/sample_corpus.txt +40 -0
  172. openinterp/draw_steps.py +359 -0
  173. openinterp/errata_checks.py +129 -0
  174. openinterp/evidence.py +462 -0
  175. openinterp/evidence_branch.py +119 -0
  176. openinterp/evidence_lenses.py +75 -0
  177. openinterp/experiment_loop.py +357 -0
  178. openinterp/explain.py +252 -0
  179. openinterp/half_precision.py +79 -0
  180. openinterp/harness/__init__.py +37 -0
  181. openinterp/harness/ane/__init__.py +9 -0
  182. openinterp/harness/ane/backend.py +324 -0
  183. openinterp/harness/ane/cache.py +276 -0
  184. openinterp/harness/ane/chunks.py +163 -0
  185. openinterp/harness/ane/convert.py +116 -0
  186. openinterp/harness/ane/forward.py +258 -0
  187. openinterp/harness/ane/qualified.py +40 -0
  188. openinterp/harness/annotations.py +329 -0
  189. openinterp/harness/attribution.py +96 -0
  190. openinterp/harness/causal.py +264 -0
  191. openinterp/harness/confounds.py +184 -0
  192. openinterp/harness/covariance.py +301 -0
  193. openinterp/harness/crossref.py +195 -0
  194. openinterp/harness/cuda_devices.py +88 -0
  195. openinterp/harness/dead.py +124 -0
  196. openinterp/harness/devices.py +401 -0
  197. openinterp/harness/dictionaries.py +496 -0
  198. openinterp/harness/dreaming.py +116 -0
  199. openinterp/harness/environment.py +64 -0
  200. openinterp/harness/explanation_scores.py +371 -0
  201. openinterp/harness/features.py +276 -0
  202. openinterp/harness/first_position.py +137 -0
  203. openinterp/harness/heads.py +226 -0
  204. openinterp/harness/hooks.py +499 -0
  205. openinterp/harness/input_attribution.py +367 -0
  206. openinterp/harness/jlens.py +183 -0
  207. openinterp/harness/layer_cache.py +301 -0
  208. openinterp/harness/layer_major.py +274 -0
  209. openinterp/harness/lookahead.py +93 -0
  210. openinterp/harness/manifold.py +200 -0
  211. openinterp/harness/memory.py +471 -0
  212. openinterp/harness/moe.py +134 -0
  213. openinterp/harness/patcher.py +322 -0
  214. openinterp/harness/patchscope.py +177 -0
  215. openinterp/harness/probing.py +383 -0
  216. openinterp/harness/scan.py +462 -0
  217. openinterp/harness/scan_backend.py +201 -0
  218. openinterp/harness/sister_passes.py +463 -0
  219. openinterp/harness/sisters.py +363 -0
  220. openinterp/harness/sites.py +101 -0
  221. openinterp/harness/specificity.py +107 -0
  222. openinterp/harness/steering.py +159 -0
  223. openinterp/harness/superpositions.py +116 -0
  224. openinterp/harness/units.py +203 -0
  225. openinterp/harness/wiring.py +432 -0
  226. openinterp/harness/wiring_activations.py +285 -0
  227. openinterp/langid.py +73 -0
  228. openinterp/names.py +129 -0
  229. openinterp/neuronpedia_baseline.py +88 -0
  230. openinterp/neuronpedia_review.py +148 -0
  231. openinterp/planner.py +466 -0
  232. openinterp/planner_effects.py +89 -0
  233. openinterp/planner_heads.py +92 -0
  234. openinterp/planner_models.py +174 -0
  235. openinterp/planner_sisters.py +122 -0
  236. openinterp/planner_states.py +57 -0
  237. openinterp/planner_superpositions.py +114 -0
  238. openinterp/pole_effects.py +102 -0
  239. openinterp/prior_art.py +143 -0
  240. openinterp/reproduce.py +450 -0
  241. openinterp/revision.py +45 -0
  242. openinterp/runner_cache.py +35 -0
  243. openinterp/score_controls.py +169 -0
  244. openinterp/sensitive.py +243 -0
  245. openinterp/show.py +236 -0
  246. openinterp/signing.py +209 -0
  247. openinterp/sisters.py +476 -0
  248. openinterp/superposition_finders.py +225 -0
  249. openinterp/sync/__init__.py +5 -0
  250. openinterp/sync/r2.py +280 -0
  251. openinterp/syntax.py +399 -0
  252. openinterp/takedown_checks.py +76 -0
  253. openinterp/toxicity.py +158 -0
  254. openinterp/verdict_regression.py +79 -0
  255. openinterp/wiring_backfill.py +170 -0
  256. openinterp/wordnet.py +362 -0
@@ -0,0 +1,37 @@
1
+ """The measurement harness: hooks, scans, patching and readouts on the model itself."""
2
+
3
+ from openinterp.harness.attribution import attribution_patching, top_attributed_neurons
4
+ from openinterp.harness.hooks import (
5
+ NeuronHookRunner,
6
+ activation_histogram,
7
+ isolate_pole,
8
+ pole_strength,
9
+ replace_pole,
10
+ )
11
+ from openinterp.harness.lookahead import measure_lookahead, output_token_projection
12
+ from openinterp.harness.patcher import (
13
+ AblationResult,
14
+ ActivationPatcher,
15
+ PatchResult,
16
+ logit_diff_metric,
17
+ token_logit_metric,
18
+ )
19
+ from openinterp.harness.scan import top_activating_examples
20
+
21
+ __all__ = [
22
+ "AblationResult",
23
+ "ActivationPatcher",
24
+ "NeuronHookRunner",
25
+ "PatchResult",
26
+ "activation_histogram",
27
+ "attribution_patching",
28
+ "isolate_pole",
29
+ "logit_diff_metric",
30
+ "measure_lookahead",
31
+ "output_token_projection",
32
+ "pole_strength",
33
+ "replace_pole",
34
+ "token_logit_metric",
35
+ "top_activating_examples",
36
+ "top_attributed_neurons",
37
+ ]
@@ -0,0 +1,9 @@
1
+ """Running a scan's forward pass on Apple's Neural Engine (ANE), through Core ML.
2
+
3
+ - ``forward.py``: the forward pass, written so the ANE computes it well.
4
+ - ``convert.py``: conversion to a compiled Core ML model (coremltools, on Apple-silicon Macs).
5
+ - ``cache.py``: compiled models on disk, built once per machine.
6
+ - ``backend.py``: :class:`~openinterp.harness.ane.backend.AneScan`, the scan's pass.
7
+
8
+ docs/analyses/ane-feasibility.md has the measurements behind it.
9
+ """
@@ -0,0 +1,324 @@
1
+ """A scan's forward pass on Apple's Neural Engine (ANE), through Core ML.
2
+
3
+ :class:`AneScan` gives the same :class:`~openinterp.harness.scan.Chunk` objects as the
4
+ torch pass. It reads the same batches (``batches`` in ``harness/scan.py``), so the
5
+ tokens, padding and counted tokens are the same. The ANE needs input shapes that do not
6
+ change. So each batch goes in the smallest of :data:`SEQS` tokens that holds its longest
7
+ piece, with ``2,048 / seq`` rows. With causal attention, padding on the right does not
8
+ change the real tokens' activations. If a piece is longer than the largest shape, the
9
+ scan runs on torch.
10
+
11
+ The ANE computes in fp16, so its activations are about 1% from fp32. The scan therefore
12
+ measures its top candidates again on the torch device (``TopExamples.rescore`` in
13
+ ``harness/scan.py``). Then the numbers that ``open-interpretability reproduce`` checks are exact.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import tempfile
21
+ import threading
22
+ from collections.abc import Iterator, Sequence
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+
26
+ import torch
27
+
28
+ from openinterp.harness.ane import chunks, convert
29
+ from openinterp.harness.ane.cache import (
30
+ MAX_ERROR,
31
+ META,
32
+ MODEL,
33
+ ConversionCache,
34
+ ConversionRejected,
35
+ fields,
36
+ )
37
+ from openinterp.harness.ane.forward import ToLayer, calibration_ids, shape_inputs, supported
38
+ from openinterp.harness.ane.qualified import qualified, too_deep
39
+
40
+ if TYPE_CHECKING:
41
+ from openinterp.harness.hooks import NeuronHookRunner
42
+ from openinterp.harness.scan import Chunk, Piece
43
+
44
+ NAME = "coreml-ane" # recorded as the scan's backend
45
+ SEQS = (128, 256, 512, 1024, 2048)
46
+ SHAPE_TOKENS = 2048 # rows × seq of each shape, as BATCH_TOKENS
47
+ PREPARE_ENV = "OPENINTERP_ANE_PREPARE_MINUTES"
48
+ # The longest that a scan waits for its models to convert and compile, before it runs on
49
+ # torch. A first compile can take minutes for each shape (Qwen2.5-0.5B: about 1-3 min).
50
+ DEFAULT_PREPARE_MINUTES = 20.0
51
+ # Peak memory of a conversion, as a multiple of the fp32 weights it converts. Measured:
52
+ # 4.2 (GPT-2 L11), 3.1 (Qwen2.5-0.5B L23) and 2.5 (Qwen2.5-1.5B L14), with a margin.
53
+ CONVERSION_OVERHEAD = 4.5
54
+
55
+
56
+ def shape_for(length: int, longest: int) -> tuple[int, int] | None:
57
+ """``(rows, seq)`` of the smallest shape that holds ``length`` tokens, or None."""
58
+ for seq in SEQS:
59
+ if length <= seq <= longest:
60
+ return SHAPE_TOKENS // seq, seq
61
+ return None
62
+
63
+
64
+ class AneScan:
65
+ """Runs a scan's forward pass on the ANE, for one model."""
66
+
67
+ name = NAME
68
+
69
+ def __init__(self, model, model_name: str, revision: str | None, cache) -> None:
70
+ self.model = model
71
+ self.model_name = model_name
72
+ self.revision = revision
73
+ self.cache: ConversionCache | None = cache
74
+ self._loaded: dict[tuple[int, int, int], list] = {}
75
+ self.paths: dict[tuple[int, int, int], list[Path]] = {} # each pass's chunk models
76
+ self._scratch: list[Path] = []
77
+ self.used = False # True once a scan ran on it
78
+ self.noted: set[str] = set() # reasons already shown, so each shows once
79
+ self.broken: str | None = None # why it may not run again in this process
80
+ self._decided: dict[tuple, str | None] = {}
81
+
82
+ @classmethod
83
+ def for_model(cls, model, model_name: str, revision: str | None) -> AneScan | None:
84
+ """An :class:`AneScan` for ``model``, or None if it can't or may not use the ANE.
85
+
86
+ It needs coremltools, a supported architecture, and a model that passed
87
+ qualification (``harness/ane/qualified.py``).
88
+ """
89
+ if not convert.available() or not supported(model) or not qualified(model_name):
90
+ return None
91
+ return cls(model, model_name, revision, ConversionCache.default())
92
+
93
+ def longest(self) -> int:
94
+ """The longest piece it can run: the largest shape, or the model's context."""
95
+ cfg = self.model.config
96
+ context = getattr(cfg, "n_positions", None) or getattr(cfg, "max_position_embeddings", None)
97
+ return min(SEQS[-1], context or SEQS[-1])
98
+
99
+ def prepare(self, lengths: Sequence[int], layer: int, batch_tokens: int) -> str | None:
100
+ """Make ready every model a scan of pieces of these token lengths needs.
101
+
102
+ Return why the ANE can't run that scan, or None if it can. The answer is kept,
103
+ so every part of one scan (its cache key, its pass, its rescoring) agrees.
104
+ """
105
+ if self.broken:
106
+ return self.broken
107
+ key = (layer, batch_tokens, tuple(lengths))
108
+ if key not in self._decided:
109
+ self._decided[key] = self._prepare(lengths, layer, batch_tokens)
110
+ return self._decided[key]
111
+
112
+ def _prepare(self, lengths: Sequence[int], layer: int, batch_tokens: int) -> str | None:
113
+ if deep := too_deep(self.model_name, layer):
114
+ return deep
115
+ shapes = needed_shapes(lengths, batch_tokens, self.longest())
116
+ if shapes is None:
117
+ too_long = max(lengths, default=0)
118
+ return f"a piece has {too_long} tokens, more than the ANE's {self.longest()}"
119
+ missing = any(not self._cached(layer, *shape) for shape in shapes)
120
+ if missing and (short := self._memory_short(layer)):
121
+ return short
122
+ return self._ready_in_time(layer, sorted(shapes))
123
+
124
+ def _ready_in_time(self, layer: int, shapes: list) -> str | None:
125
+ """Load (and if needed convert) ``shapes`` within the time limit; else say why not.
126
+
127
+ The work runs on a background thread, because a call into Core ML can't be
128
+ stopped. If it takes longer than the limit, the ANE is not used again in this
129
+ process, and the scan runs on torch.
130
+ """
131
+ done, failed = threading.Event(), []
132
+
133
+ def work() -> None:
134
+ try:
135
+ for shape in shapes:
136
+ self.compiled(layer, *shape)
137
+ except Exception as exc: # any failure to convert: the scan runs on torch
138
+ failed.append(exc)
139
+ finally:
140
+ done.set()
141
+
142
+ threading.Thread(target=work, daemon=True, name="ane-prepare").start()
143
+ minutes = prepare_minutes()
144
+ if not done.wait(timeout=minutes * 60):
145
+ self.broken = (
146
+ f"converting and compiling for the ANE took more than {minutes:g} minutes "
147
+ f"(${PREPARE_ENV}); a later run tries again"
148
+ )
149
+ return self.broken
150
+ if failed:
151
+ return f"its conversion failed ({type(failed[0]).__name__}: {failed[0]})"
152
+ return None
153
+
154
+ def _cached(self, layer: int, rows: int, seq: int) -> bool:
155
+ if (layer, rows, seq) in self._loaded:
156
+ return True
157
+ return all(self._entry(link, rows, seq) for link in chunks.chain(self.model, layer))
158
+
159
+ def _entry(self, link: tuple[int, int, bool], rows: int, seq: int) -> Path | None:
160
+ """The cached compiled model of one chunk, or None if the cache has none."""
161
+ if self.cache is None:
162
+ return None
163
+ start, last, final = link
164
+ key_fields = fields(self.model_name, self.revision, last, rows, seq, start, not final)
165
+ entry = self.cache.path(key_fields)
166
+ return entry / MODEL if (entry / META).exists() else None
167
+
168
+ def _memory_short(self, layer: int) -> str | None:
169
+ """Why converting the chunks of a pass to ``layer`` would not fit in memory, or None.
170
+
171
+ Chunks are converted one at a time, so the largest chunk sets the need.
172
+ """
173
+ from openinterp.harness.memory import available_bytes
174
+
175
+ links = chunks.chain(self.model, layer)
176
+ largest = max(chunks.chunk_bytes(self.model, start, last) for start, last, _ in links)
177
+ need = int(largest * CONVERSION_OVERHEAD)
178
+ have = available_bytes("cpu")
179
+ if have is None or need <= have:
180
+ return None
181
+ return f"converting it needs about {need / 2**30:.1f} GB and {have / 2**30:.1f} GB is free"
182
+
183
+ def chunks(
184
+ self, runner: NeuronHookRunner, pieces: Sequence[Piece], layer: int, batch_tokens: int
185
+ ) -> Iterator[Chunk]:
186
+ """``layer``'s activations over ``pieces``, batch by batch, from the ANE."""
187
+ from openinterp.harness.scan import Chunk, batches
188
+
189
+ for batch in batches(runner, pieces, batch_tokens):
190
+ acts = self.batch_acts(batch, layer)
191
+ mask = batch.mask
192
+ yield Chunk(
193
+ pieces=batch.pieces,
194
+ lengths=mask.sum(1).tolist(),
195
+ ids=batch.ids[mask],
196
+ positions=batch.positions[mask],
197
+ counted=batch.counted[mask],
198
+ acts=acts[mask],
199
+ )
200
+
201
+ def batch_acts(self, batch, layer: int) -> torch.Tensor:
202
+ """``layer``'s activations on one scan batch, ``[pieces, width, d_mlp]`` in fp32.
203
+
204
+ The batch goes in the smallest shape that holds it, in parts of that shape's rows.
205
+ Each part goes through the chain of chunks (``chunks.py``).
206
+ """
207
+ width = batch.ids.shape[1]
208
+ rows, seq = shape_for(width, self.longest()) or (0, 0)
209
+ if not rows:
210
+ raise ValueError(f"no ANE shape holds {width} tokens")
211
+ from openinterp.harness.scan_backend import AneError
212
+
213
+ try:
214
+ models = self.compiled(layer, rows, seq)
215
+ parts = []
216
+ for start in range(0, len(batch.pieces), rows):
217
+ x = torch.zeros((rows, seq), dtype=torch.long)
218
+ part = batch.ids[start : start + rows]
219
+ x[: len(part), :width] = part
220
+ for model, extra in models:
221
+ x = convert.predict(model, x, extra)
222
+ parts.append(x[: len(part), :width])
223
+ except Exception as exc: # Core ML's errors have no common type
224
+ raise AneError(f"{type(exc).__name__}: {exc}") from exc
225
+ return torch.cat(parts)
226
+
227
+ def compiled(self, layer: int, rows: int, seq: int) -> list:
228
+ """The loaded Core ML models of a pass to ``layer`` at ``(rows, seq)``, chunk by chunk.
229
+
230
+ Each comes with its other inputs (``forward.shape_inputs``), ready for ``predict``.
231
+ """
232
+ key = (layer, rows, seq)
233
+ if key not in self._loaded:
234
+ links = chunks.chain(self.model, layer)
235
+ paths = [self._entry(link, rows, seq) for link in links]
236
+ if not all(paths):
237
+ paths = self._build(links, rows, seq)
238
+ self.paths[key] = paths
239
+ cfg = self.model.config
240
+ self._loaded[key] = [
241
+ (convert.load(path), convert.feed(shape_inputs(cfg, seq, start)))
242
+ for path, (start, _, _) in zip(paths, links, strict=True)
243
+ ]
244
+ return self._loaded[key]
245
+
246
+ def _build(self, links: list, rows: int, seq: int) -> list[Path]:
247
+ """Convert and check each chunk that the cache doesn't hold, in order.
248
+
249
+ Each chunk's fp32 output is the next chunk's input. On the calibration tokens it
250
+ sets the next chunk's scales, the same for every shape. On random tokens of the
251
+ shape, it checks the next chunk. Only one chunk's weights are in memory at a time.
252
+ """
253
+ cfg = self.model.config
254
+ x = torch.randint(
255
+ 0, cfg.vocab_size, (rows, seq), generator=torch.Generator().manual_seed(2)
256
+ )
257
+ calibration = calibration_ids(cfg)
258
+ paths = []
259
+ for start, last, final in links:
260
+ source = chunks.part(self.model, self.model_name, self.revision, start, last)
261
+ net = ToLayer(source, last, rows, seq, start, not final, sample=calibration).eval()
262
+ with torch.no_grad():
263
+ ref = net(x)
264
+ with net.shaped(*calibration.shape[:2]):
265
+ calibrated = net(calibration)
266
+ key_fields = fields(self.model_name, self.revision, last, rows, seq, start, not final)
267
+ paths.append(self._get(key_fields, net, x, ref))
268
+ x, calibration = ref, calibrated
269
+ del source, net
270
+ return paths
271
+
272
+ def _get(self, key_fields: dict, net: ToLayer, x: torch.Tensor, ref: torch.Tensor) -> Path:
273
+ """One chunk's compiled model, from the cache or built now, and checked if built."""
274
+
275
+ def build(dest: Path) -> None:
276
+ convert.compile_to(net, x, dest)
277
+
278
+ def check(path: Path) -> float:
279
+ got = convert.predict(convert.load(path), x, convert.feed(net.extra))
280
+ return float((ref - got).pow(2).mean().sqrt() / ref.pow(2).mean().sqrt())
281
+
282
+ if self.cache is None:
283
+ return self._uncached(build, check)
284
+ return self.cache.get(key_fields, build, check)
285
+
286
+ def _uncached(self, build, check) -> Path:
287
+ """Build into a temporary directory, for a run with the cache turned off."""
288
+ work = Path(tempfile.mkdtemp(prefix="openinterp-ane-"))
289
+ self._scratch.append(work)
290
+ path = work / "model.mlmodelc"
291
+ build(path)
292
+ error = check(path)
293
+ if not error <= MAX_ERROR:
294
+ raise ConversionRejected(f"relative error {error:.4f} is more than {MAX_ERROR}")
295
+ return path
296
+
297
+ def close(self) -> None:
298
+ """Delete the temporary models of a run with the cache turned off."""
299
+ for work in self._scratch:
300
+ shutil.rmtree(work, ignore_errors=True)
301
+ self._scratch.clear()
302
+
303
+
304
+ def needed_shapes(lengths: Sequence[int], batch_tokens: int, longest: int) -> set | None:
305
+ """The ``(rows, seq)`` shapes that a scan's batches need, or None if one has none.
306
+
307
+ The batches are those of ``batches`` in ``harness/scan.py``: each is as wide as its
308
+ longest piece.
309
+ """
310
+ from openinterp.harness.scan import plan_batches
311
+
312
+ shapes, start = set(), 0
313
+ for size in plan_batches(list(lengths), batch_tokens):
314
+ shape = shape_for(max(lengths[start : start + size]), longest)
315
+ if shape is None:
316
+ return None
317
+ shapes.add(shape)
318
+ start += size
319
+ return shapes
320
+
321
+
322
+ def prepare_minutes() -> float:
323
+ """``$OPENINTERP_ANE_PREPARE_MINUTES``, else :data:`DEFAULT_PREPARE_MINUTES`."""
324
+ return float(os.environ.get(PREPARE_ENV) or DEFAULT_PREPARE_MINUTES)
@@ -0,0 +1,276 @@
1
+ """On-disk cache of compiled Core ML models, so a machine converts each model only once.
2
+
3
+ Converting a model to one layer takes 5–50 s. The ANE also compiles a model when it first
4
+ loads it, which takes 10–35 s. macOS keeps that compile, keyed by the model's path. A
5
+ later load from the same path takes 0.2 s, but a copy at a new path compiles again. So
6
+ an entry is built in a temporary directory and renamed to its final path. Then it is
7
+ loaded there once, which warms the ANE's compile, and checked (:data:`MAX_ERROR`).
8
+
9
+ An entry is keyed by everything that changes the compiled model:
10
+
11
+ - the model, its revision, the chunk's layers and the input shape;
12
+ - a hash of the source of the conversion code (``forward.py`` and ``convert.py``),
13
+ so any change to that code makes new entries;
14
+ - the coremltools and torch versions;
15
+ - the macOS version and the chip, because the ANE's compiler comes with macOS.
16
+
17
+ An entry is ``<root>/<model>/L<layer>/<key>/`` with ``model.mlmodelc`` and
18
+ ``meta.json``. It is complete only when ``meta.json`` exists, which is written last.
19
+ A lock per key stops two processes from building one entry at the same time. Once the
20
+ cache is larger than its cap, the least recently used entries go.
21
+
22
+ The weights of one chunk are the same file for every input shape
23
+ (``forward.shape_inputs``). So each weight file is kept once, in ``<root>/.blobs/``
24
+ under its SHA-256, and each entry holds a hard link to it. A blob that no entry links to
25
+ goes when the cache next evicts. The cache's size counts each file once.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import fcntl
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import platform
35
+ import secrets
36
+ import shutil
37
+ import subprocess
38
+ from collections.abc import Callable, Iterator
39
+ from contextlib import contextmanager, suppress
40
+ from datetime import UTC, datetime
41
+ from pathlib import Path
42
+
43
+ import torch
44
+
45
+ from openinterp.core.cache_dirs import cache_root
46
+ from openinterp.harness.ane import convert
47
+
48
+ FORMAT = 3 # 2: chunks of layers; 3: shape tables as inputs, one calibration sample
49
+ ENV = "OPENINTERP_COREML_CACHE" # cache directory, or "off"
50
+ SIZE_ENV = "OPENINTERP_COREML_CACHE_GB"
51
+ DEFAULT_GB = 20.0
52
+ MAX_ERROR = 0.03 # largest relative RMS error against fp32 (measured: 0.003 to 0.011)
53
+ SOURCES = ("forward.py", "convert.py", "chunks.py") # the code that sets what an entry holds
54
+ MODEL = "model.mlmodelc"
55
+ META = "meta.json"
56
+ BLOBS = ".blobs"
57
+
58
+
59
+ class ConversionRejected(RuntimeError):
60
+ """A converted model did not agree with fp32 closely enough to use."""
61
+
62
+
63
+ def code_digest() -> str:
64
+ """A hash of the source of the conversion code."""
65
+ digest = hashlib.sha256()
66
+ for name in SOURCES:
67
+ digest.update((Path(__file__).parent / name).read_bytes())
68
+ return digest.hexdigest()[:16]
69
+
70
+
71
+ def machine() -> dict:
72
+ """The macOS version and the chip, which set what the ANE's compiler makes."""
73
+ chip = None
74
+ with suppress(OSError, subprocess.SubprocessError):
75
+ chip = subprocess.run(
76
+ ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, check=True
77
+ ).stdout.strip()
78
+ return {"macos": platform.mac_ver()[0] or None, "chip": chip or platform.machine()}
79
+
80
+
81
+ def fields(
82
+ model: str,
83
+ revision: str | None,
84
+ layer: int,
85
+ rows: int,
86
+ seq: int,
87
+ start: int = 0,
88
+ resid_out=False,
89
+ ) -> dict:
90
+ """What an entry is keyed by: one chunk, layers ``start..layer`` (``chunks.py``)."""
91
+ return {
92
+ "format": FORMAT,
93
+ "model": model,
94
+ "revision": revision,
95
+ "layer": layer,
96
+ "start": start,
97
+ "resid_out": resid_out,
98
+ "shape": [rows, seq],
99
+ "code": code_digest(),
100
+ "coremltools": convert.version(),
101
+ "torch": torch.__version__,
102
+ **machine(),
103
+ }
104
+
105
+
106
+ class ConversionCache:
107
+ """Compiled Core ML models on disk, built once per machine and key."""
108
+
109
+ def __init__(self, root: Path, max_bytes: int | None = None) -> None:
110
+ self.root = root
111
+ self.max_bytes = max_bytes
112
+
113
+ @classmethod
114
+ def default(cls) -> ConversionCache | None:
115
+ """The cache at ``$OPENINTERP_COREML_CACHE`` (default ``~/.cache/openinterp/coreml``)."""
116
+ root = cache_root(ENV, "coreml")
117
+ if root is None:
118
+ return None
119
+ gb = float(os.environ.get(SIZE_ENV) or DEFAULT_GB)
120
+ return cls(root, int(gb * 2**30))
121
+
122
+ def path(self, key_fields: dict) -> Path:
123
+ """The entry directory for ``key_fields``."""
124
+ key = hashlib.sha256(json.dumps(key_fields, sort_keys=True).encode()).hexdigest()[:20]
125
+ model = key_fields["model"].replace("/", "--")
126
+ return self.root / model / f"L{key_fields['layer']}" / key
127
+
128
+ def get(
129
+ self,
130
+ key_fields: dict,
131
+ build: Callable[[Path], None],
132
+ check: Callable[[Path], float],
133
+ ) -> Path:
134
+ """The compiled model for ``key_fields``; build and check it if the cache has none.
135
+
136
+ ``build`` writes a ``.mlmodelc`` to the path it gets. ``check`` loads the model
137
+ at its final path and returns its relative error against fp32. An entry with an
138
+ error above :data:`MAX_ERROR` is deleted, and this raises :class:`ConversionRejected`.
139
+ """
140
+ entry = self.path(key_fields)
141
+ if (entry / META).exists():
142
+ self._touch(entry)
143
+ return entry / MODEL
144
+ with self._lock(entry):
145
+ if not (entry / META).exists():
146
+ self._build(entry, key_fields, build, check)
147
+ self.evict(keep=entry)
148
+ return entry / MODEL
149
+
150
+ def _build(self, entry: Path, key_fields: dict, build, check) -> None:
151
+ shutil.rmtree(entry, ignore_errors=True) # left by a build that stopped
152
+ tmp = entry.parent / f".tmp-{entry.name}-{secrets.token_hex(4)}"
153
+ try:
154
+ tmp.mkdir(parents=True)
155
+ build(tmp / MODEL)
156
+ os.rename(tmp, entry)
157
+ finally:
158
+ shutil.rmtree(tmp, ignore_errors=True)
159
+ error = check(entry / MODEL) # at the final path, so macOS keeps the ANE's compile
160
+ if not error <= MAX_ERROR:
161
+ shutil.rmtree(entry, ignore_errors=True)
162
+ raise ConversionRejected(f"relative error {error:.4f} is more than {MAX_ERROR}")
163
+ self._share(entry / MODEL)
164
+ meta = {"fields": key_fields, "error": error, "built": _now(), "last_used": _now()}
165
+ _write_json(entry / META, meta)
166
+
167
+ def _share(self, model: Path) -> None:
168
+ """Replace each weight file in ``model`` by a hard link to the blob with its contents."""
169
+ blobs = self.root / BLOBS
170
+ blobs.mkdir(parents=True, exist_ok=True)
171
+ for weights in model.glob("weights/*.bin"):
172
+ blob = blobs / f"{_sha256(weights)}.bin"
173
+ if not blob.exists():
174
+ with suppress(FileExistsError): # another process stored it first
175
+ os.link(weights, blob)
176
+ continue
177
+ if os.path.samefile(blob, weights):
178
+ continue
179
+ tmp = weights.with_name(f".{weights.name}.{secrets.token_hex(4)}")
180
+ os.link(blob, tmp)
181
+ os.replace(tmp, weights) # atomic: the file is whole at every moment
182
+
183
+ def size(self) -> int:
184
+ """Bytes on disk: each file once, however many entries link to it."""
185
+ seen, total = set(), 0
186
+ for path in self.root.rglob("*"):
187
+ with suppress(OSError):
188
+ info = path.stat()
189
+ if path.is_file() and (info.st_dev, info.st_ino) not in seen:
190
+ seen.add((info.st_dev, info.st_ino))
191
+ total += info.st_size
192
+ return total
193
+
194
+ def _drop_old_formats(self) -> list[Path]:
195
+ """Delete the entries of another :data:`FORMAT`. No key can find them again."""
196
+ gone = []
197
+ for meta in self.root.glob(f"*/L*/*/{META}"):
198
+ with suppress(OSError, ValueError):
199
+ if json.loads(meta.read_text())["fields"].get("format") != FORMAT:
200
+ shutil.rmtree(meta.parent, ignore_errors=True)
201
+ gone.append(meta.parent)
202
+ if gone:
203
+ self._sweep()
204
+ return gone
205
+
206
+ def _sweep(self) -> None:
207
+ """Delete each blob that no entry links to."""
208
+ for blob in (self.root / BLOBS).glob("*.bin"):
209
+ with suppress(OSError):
210
+ if blob.stat().st_nlink == 1:
211
+ blob.unlink()
212
+
213
+ @contextmanager
214
+ def _lock(self, entry: Path) -> Iterator[None]:
215
+ locks = self.root / ".locks"
216
+ locks.mkdir(parents=True, exist_ok=True)
217
+ with open(locks / f"{entry.name}.lock", "w") as handle:
218
+ fcntl.flock(handle, fcntl.LOCK_EX)
219
+ try:
220
+ yield
221
+ finally:
222
+ fcntl.flock(handle, fcntl.LOCK_UN)
223
+
224
+ def _touch(self, entry: Path) -> None:
225
+ with suppress(OSError, ValueError):
226
+ meta = json.loads((entry / META).read_text())
227
+ meta["last_used"] = _now()
228
+ _write_json(entry / META, meta)
229
+
230
+ def entries(self) -> list[tuple[Path, int, str]]:
231
+ """Each complete entry, its size in bytes, and when it was last used."""
232
+ found = []
233
+ for meta in self.root.glob(f"*/L*/*/{META}"):
234
+ with suppress(OSError, ValueError):
235
+ used = json.loads(meta.read_text()).get("last_used", "")
236
+ size = sum(f.stat().st_size for f in meta.parent.rglob("*") if f.is_file())
237
+ found.append((meta.parent, size, used))
238
+ return found
239
+
240
+ def evict(self, keep: Path | None = None) -> list[Path]:
241
+ """Delete old entries, then the least recently used, until the cache is inside its cap.
242
+
243
+ Old entries are those of another :data:`FORMAT`. An entry frees its weights only
244
+ if no other entry links to them.
245
+ """
246
+ gone = self._drop_old_formats()
247
+ if self.max_bytes is None:
248
+ return gone
249
+ self._sweep()
250
+ for path, _, _ in sorted(self.entries(), key=lambda e: e[2]):
251
+ if self.size() <= self.max_bytes:
252
+ break
253
+ if path == keep:
254
+ continue
255
+ shutil.rmtree(path, ignore_errors=True)
256
+ self._sweep()
257
+ gone.append(path)
258
+ return gone
259
+
260
+
261
+ def _sha256(path: Path) -> str:
262
+ digest = hashlib.sha256()
263
+ with open(path, "rb") as handle:
264
+ for block in iter(lambda: handle.read(1 << 24), b""):
265
+ digest.update(block)
266
+ return digest.hexdigest()
267
+
268
+
269
+ def _now() -> str:
270
+ return datetime.now(UTC).isoformat()
271
+
272
+
273
+ def _write_json(path: Path, data: dict) -> None:
274
+ tmp = path.with_name(f".{path.name}.{secrets.token_hex(4)}")
275
+ tmp.write_text(json.dumps(data, indent=1, sort_keys=True))
276
+ os.replace(tmp, path)