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.
- open_interpretability-0.0.20.dist-info/METADATA +157 -0
- open_interpretability-0.0.20.dist-info/RECORD +256 -0
- open_interpretability-0.0.20.dist-info/WHEEL +4 -0
- open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
- open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
- openinterp/__init__.py +6 -0
- openinterp/agents/__init__.py +25 -0
- openinterp/agents/base.py +229 -0
- openinterp/agents/judge.py +149 -0
- openinterp/agents/proposer.py +471 -0
- openinterp/agents/proposer_profiles.py +79 -0
- openinterp/agents/review_measurements.py +173 -0
- openinterp/agents/reviewer.py +498 -0
- openinterp/agreement.py +63 -0
- openinterp/audit_options.py +79 -0
- openinterp/baseline.py +254 -0
- openinterp/benchmark.py +450 -0
- openinterp/blends.py +239 -0
- openinterp/builder/__init__.py +6 -0
- openinterp/builder/agents.py +260 -0
- openinterp/builder/aligned.py +41 -0
- openinterp/builder/charts.py +411 -0
- openinterp/builder/circuit_pages.py +37 -0
- openinterp/builder/circuits.py +264 -0
- openinterp/builder/compiler.py +495 -0
- openinterp/builder/corpora.py +98 -0
- openinterp/builder/coverage.py +46 -0
- openinterp/builder/dashboard.py +255 -0
- openinterp/builder/disk_memo.py +65 -0
- openinterp/builder/experiment.py +105 -0
- openinterp/builder/heat.py +121 -0
- openinterp/builder/hrefs.py +71 -0
- openinterp/builder/learned_baseline.py +204 -0
- openinterp/builder/model_facts.py +138 -0
- openinterp/builder/neuronpedia_export.py +107 -0
- openinterp/builder/parallel.py +100 -0
- openinterp/builder/parquet_export.py +458 -0
- openinterp/builder/pieces.py +127 -0
- openinterp/builder/pii.py +211 -0
- openinterp/builder/registry_stats.py +144 -0
- openinterp/builder/research.py +109 -0
- openinterp/builder/sections.py +28 -0
- openinterp/builder/significance.py +103 -0
- openinterp/builder/sister_graph.py +180 -0
- openinterp/builder/standing.py +185 -0
- openinterp/builder/statuses.py +34 -0
- openinterp/builder/stopped.py +91 -0
- openinterp/builder/stops.py +30 -0
- openinterp/builder/superpositions.py +102 -0
- openinterp/builder/templates/_agent_role_cells.html +9 -0
- openinterp/builder/templates/_causal_chart.html +67 -0
- openinterp/builder/templates/_circuit.html +8 -0
- openinterp/builder/templates/_dashboard.html +133 -0
- openinterp/builder/templates/_experiment.html +500 -0
- openinterp/builder/templates/_macros.html +82 -0
- openinterp/builder/templates/_patchscope.html +33 -0
- openinterp/builder/templates/_prior_art.html +24 -0
- openinterp/builder/templates/_specificity.html +21 -0
- openinterp/builder/templates/_steering.html +34 -0
- openinterp/builder/templates/_superposition.html +62 -0
- openinterp/builder/templates/_vocab_chart.html +88 -0
- openinterp/builder/templates/_wiring.html +44 -0
- openinterp/builder/templates/about.html +95 -0
- openinterp/builder/templates/agent.html +69 -0
- openinterp/builder/templates/agents.html +80 -0
- openinterp/builder/templates/base.html +103 -0
- openinterp/builder/templates/circuit.html +64 -0
- openinterp/builder/templates/circuits.html +46 -0
- openinterp/builder/templates/experiment.html +21 -0
- openinterp/builder/templates/hub.html +16 -0
- openinterp/builder/templates/index.html +387 -0
- openinterp/builder/templates/layer.html +69 -0
- openinterp/builder/templates/learned-baseline.html +44 -0
- openinterp/builder/templates/model.html +151 -0
- openinterp/builder/templates/models.html +47 -0
- openinterp/builder/templates/neuron.html +234 -0
- openinterp/builder/templates/paper.html +39 -0
- openinterp/builder/templates/redirect.html +1 -0
- openinterp/builder/templates/research.html +31 -0
- openinterp/builder/templates/user.html +54 -0
- openinterp/builder/templates/users.html +39 -0
- openinterp/builder/templates/wiki/experiment.md +271 -0
- openinterp/builder/templates/wiki/index.md +32 -0
- openinterp/builder/templates/wiki/neuron.md +30 -0
- openinterp/builder/templates/wiki/stop.md +36 -0
- openinterp/builder/tree.py +470 -0
- openinterp/builder/users.py +142 -0
- openinterp/builder/wiki.py +427 -0
- openinterp/checks.py +429 -0
- openinterp/cli.py +494 -0
- openinterp/cli_baseline.py +332 -0
- openinterp/cli_benchmark.py +300 -0
- openinterp/cli_build.py +128 -0
- openinterp/cli_check.py +51 -0
- openinterp/cli_concepts.py +76 -0
- openinterp/cli_corpus.py +252 -0
- openinterp/cli_costs.py +257 -0
- openinterp/cli_coverage.py +76 -0
- openinterp/cli_crossref.py +86 -0
- openinterp/cli_errata.py +142 -0
- openinterp/cli_evidence.py +495 -0
- openinterp/cli_evidence_store.py +93 -0
- openinterp/cli_explain.py +101 -0
- openinterp/cli_loop.py +125 -0
- openinterp/cli_memory.py +28 -0
- openinterp/cli_neuronpedia.py +175 -0
- openinterp/cli_pause.py +45 -0
- openinterp/cli_pole_effects.py +133 -0
- openinterp/cli_probing.py +119 -0
- openinterp/cli_registry.py +348 -0
- openinterp/cli_reproduce.py +446 -0
- openinterp/cli_review.py +302 -0
- openinterp/cli_scan.py +223 -0
- openinterp/cli_score.py +446 -0
- openinterp/cli_sisters.py +86 -0
- openinterp/cli_submission.py +217 -0
- openinterp/cli_superpositions.py +118 -0
- openinterp/cli_wiring.py +54 -0
- openinterp/codeid.py +73 -0
- openinterp/concepts.py +400 -0
- openinterp/core/__init__.py +59 -0
- openinterp/core/agent_names.py +76 -0
- openinterp/core/attribution.py +101 -0
- openinterp/core/cache_dirs.py +15 -0
- openinterp/core/causal.py +82 -0
- openinterp/core/confounds.py +103 -0
- openinterp/core/contributors.py +26 -0
- openinterp/core/corpora.py +126 -0
- openinterp/core/costs.py +430 -0
- openinterp/core/draws.py +109 -0
- openinterp/core/errata.py +244 -0
- openinterp/core/evidence.py +160 -0
- openinterp/core/examples.py +93 -0
- openinterp/core/exceptions.py +29 -0
- openinterp/core/explanation.py +130 -0
- openinterp/core/fence.py +42 -0
- openinterp/core/heavy_lock.py +114 -0
- openinterp/core/identifiers.py +198 -0
- openinterp/core/isolation.py +149 -0
- openinterp/core/ledger.py +130 -0
- openinterp/core/licenses.py +28 -0
- openinterp/core/manifold.py +117 -0
- openinterp/core/partners.py +32 -0
- openinterp/core/patchscope.py +59 -0
- openinterp/core/paths.py +40 -0
- openinterp/core/pause.py +96 -0
- openinterp/core/pricing.py +178 -0
- openinterp/core/prior_art.py +44 -0
- openinterp/core/probing.py +146 -0
- openinterp/core/profiles.py +221 -0
- openinterp/core/project.py +28 -0
- openinterp/core/run_environment.py +38 -0
- openinterp/core/schemas.py +484 -0
- openinterp/core/specificity.py +49 -0
- openinterp/core/stats.py +172 -0
- openinterp/core/steering.py +49 -0
- openinterp/core/strict.py +19 -0
- openinterp/core/takedowns.py +136 -0
- openinterp/core/transcripts.py +130 -0
- openinterp/core/wiring.py +80 -0
- openinterp/corpus.py +476 -0
- openinterp/corpus_cache.py +145 -0
- openinterp/corpus_layouts.py +108 -0
- openinterp/corpus_presets.py +168 -0
- openinterp/corpus_rebuild.py +93 -0
- openinterp/coverage.py +155 -0
- openinterp/crossref.py +442 -0
- openinterp/data/__init__.py +9 -0
- openinterp/data/offensive_words.txt +58 -0
- openinterp/data/public_suffix_list.dat +15771 -0
- openinterp/data/sample_corpus.txt +40 -0
- openinterp/draw_steps.py +359 -0
- openinterp/errata_checks.py +129 -0
- openinterp/evidence.py +462 -0
- openinterp/evidence_branch.py +119 -0
- openinterp/evidence_lenses.py +75 -0
- openinterp/experiment_loop.py +357 -0
- openinterp/explain.py +252 -0
- openinterp/half_precision.py +79 -0
- openinterp/harness/__init__.py +37 -0
- openinterp/harness/ane/__init__.py +9 -0
- openinterp/harness/ane/backend.py +324 -0
- openinterp/harness/ane/cache.py +276 -0
- openinterp/harness/ane/chunks.py +163 -0
- openinterp/harness/ane/convert.py +116 -0
- openinterp/harness/ane/forward.py +258 -0
- openinterp/harness/ane/qualified.py +40 -0
- openinterp/harness/annotations.py +329 -0
- openinterp/harness/attribution.py +96 -0
- openinterp/harness/causal.py +264 -0
- openinterp/harness/confounds.py +184 -0
- openinterp/harness/covariance.py +301 -0
- openinterp/harness/crossref.py +195 -0
- openinterp/harness/cuda_devices.py +88 -0
- openinterp/harness/dead.py +124 -0
- openinterp/harness/devices.py +401 -0
- openinterp/harness/dictionaries.py +496 -0
- openinterp/harness/dreaming.py +116 -0
- openinterp/harness/environment.py +64 -0
- openinterp/harness/explanation_scores.py +371 -0
- openinterp/harness/features.py +276 -0
- openinterp/harness/first_position.py +137 -0
- openinterp/harness/heads.py +226 -0
- openinterp/harness/hooks.py +499 -0
- openinterp/harness/input_attribution.py +367 -0
- openinterp/harness/jlens.py +183 -0
- openinterp/harness/layer_cache.py +301 -0
- openinterp/harness/layer_major.py +274 -0
- openinterp/harness/lookahead.py +93 -0
- openinterp/harness/manifold.py +200 -0
- openinterp/harness/memory.py +471 -0
- openinterp/harness/moe.py +134 -0
- openinterp/harness/patcher.py +322 -0
- openinterp/harness/patchscope.py +177 -0
- openinterp/harness/probing.py +383 -0
- openinterp/harness/scan.py +462 -0
- openinterp/harness/scan_backend.py +201 -0
- openinterp/harness/sister_passes.py +463 -0
- openinterp/harness/sisters.py +363 -0
- openinterp/harness/sites.py +101 -0
- openinterp/harness/specificity.py +107 -0
- openinterp/harness/steering.py +159 -0
- openinterp/harness/superpositions.py +116 -0
- openinterp/harness/units.py +203 -0
- openinterp/harness/wiring.py +432 -0
- openinterp/harness/wiring_activations.py +285 -0
- openinterp/langid.py +73 -0
- openinterp/names.py +129 -0
- openinterp/neuronpedia_baseline.py +88 -0
- openinterp/neuronpedia_review.py +148 -0
- openinterp/planner.py +466 -0
- openinterp/planner_effects.py +89 -0
- openinterp/planner_heads.py +92 -0
- openinterp/planner_models.py +174 -0
- openinterp/planner_sisters.py +122 -0
- openinterp/planner_states.py +57 -0
- openinterp/planner_superpositions.py +114 -0
- openinterp/pole_effects.py +102 -0
- openinterp/prior_art.py +143 -0
- openinterp/reproduce.py +450 -0
- openinterp/revision.py +45 -0
- openinterp/runner_cache.py +35 -0
- openinterp/score_controls.py +169 -0
- openinterp/sensitive.py +243 -0
- openinterp/show.py +236 -0
- openinterp/signing.py +209 -0
- openinterp/sisters.py +476 -0
- openinterp/superposition_finders.py +225 -0
- openinterp/sync/__init__.py +5 -0
- openinterp/sync/r2.py +280 -0
- openinterp/syntax.py +399 -0
- openinterp/takedown_checks.py +76 -0
- openinterp/toxicity.py +158 -0
- openinterp/verdict_regression.py +79 -0
- openinterp/wiring_backfill.py +170 -0
- 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)
|