jul 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.
- jul/__init__.py +26 -0
- jul/assets/minicpm5-2b.one_word.center.npy +0 -0
- jul/assets/presets/minicpm5-2b.torch.one_word.center.npy +0 -0
- jul/assets/presets/minicpm5-2b@torch.json +67 -0
- jul/assets/qwen3.5-9b.one_word.center.npy +0 -0
- jul/backbone.py +215 -0
- jul/backends/__init__.py +1 -0
- jul/backends/mlx.py +194 -0
- jul/backends/torch.py +191 -0
- jul/calibrate.py +438 -0
- jul/calibration.py +78 -0
- jul/client.py +306 -0
- jul/context.py +179 -0
- jul/decision.py +234 -0
- jul/engine.py +252 -0
- jul/lab/__init__.py +4 -0
- jul/lab/cli.py +197 -0
- jul/lab/decider.py +91 -0
- jul/lab/evaluate.py +133 -0
- jul/lab/features.py +72 -0
- jul/lab/generic_head.py +120 -0
- jul/lab/heads.py +135 -0
- jul/lab/primitives.py +243 -0
- jul/lab/task.py +117 -0
- jul/presets.py +230 -0
- jul/py.typed +0 -0
- jul/synth.py +135 -0
- jul/tuning.py +185 -0
- jul/types.py +187 -0
- jul-0.1.0.dist-info/METADATA +783 -0
- jul-0.1.0.dist-info/RECORD +40 -0
- jul-0.1.0.dist-info/WHEEL +5 -0
- jul-0.1.0.dist-info/entry_points.txt +2 -0
- jul-0.1.0.dist-info/licenses/LICENSE +202 -0
- jul-0.1.0.dist-info/licenses/NOTICE +18 -0
- jul-0.1.0.dist-info/top_level.txt +2 -0
- jul_cli/__init__.py +5 -0
- jul_cli/__main__.py +3 -0
- jul_cli/main.py +442 -0
- jul_cli/setup.py +209 -0
jul/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""jul — Juste Un LLM: local typed decisions, with the interface of the TypeSafe (Jev) Python SDK.
|
|
2
|
+
|
|
3
|
+
Swap the import and existing code keeps working:
|
|
4
|
+
|
|
5
|
+
# from typesafe_sdk import TypeSafeClient, Choice, Noul, Score
|
|
6
|
+
from jul import TypeSafeClient, Choice, Noul, Score
|
|
7
|
+
|
|
8
|
+
Two extras Jev does not have: a `Context` describing the data to sort, and `client.autotune(...)`, a small
|
|
9
|
+
head trained in seconds on labeled examples while the model itself stays untouched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import AsyncTypeSafeClient, TypeSafeClient
|
|
13
|
+
from .context import Context
|
|
14
|
+
from .presets import PRESETS, Preset
|
|
15
|
+
from .tuning import TuningReport
|
|
16
|
+
from .types import (Choice, ChoiceAnswer, Noul, NoulAnswer, NoulCriteria, Score, ScoreAnswer,
|
|
17
|
+
SystemOneResponse, Usage)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"TypeSafeClient", "AsyncTypeSafeClient",
|
|
23
|
+
"Choice", "Noul", "NoulCriteria", "Score",
|
|
24
|
+
"ChoiceAnswer", "NoulAnswer", "ScoreAnswer", "SystemOneResponse", "Usage",
|
|
25
|
+
"Context", "TuningReport", "Preset", "PRESETS", "__version__",
|
|
26
|
+
]
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "minicpm5-2b",
|
|
3
|
+
"repos": {
|
|
4
|
+
"torch": "openbmb/MiniCPM5-2B"
|
|
5
|
+
},
|
|
6
|
+
"backend": "torch",
|
|
7
|
+
"formulations": [
|
|
8
|
+
{
|
|
9
|
+
"name": "one_word",
|
|
10
|
+
"template": "This text: \"{state}\" means in one word: \"",
|
|
11
|
+
"layer": 41
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "question_options",
|
|
15
|
+
"template": "{instructions}\nPossible answers: {options}.\nText: \"{state}\"\nIn one word, the answer is: \"",
|
|
16
|
+
"layer": 37
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"tau": 0.04381,
|
|
20
|
+
"center": "generic",
|
|
21
|
+
"one_word": [
|
|
22
|
+
37,
|
|
23
|
+
0.03975
|
|
24
|
+
],
|
|
25
|
+
"latency_ms": "~155",
|
|
26
|
+
"quality": "dev accuracy 0.540 ± 0.035 (n=200); not measured on the Jev bench",
|
|
27
|
+
"notes": "Fitted by `jul models add` on 2026-09-22.",
|
|
28
|
+
"calibration": {
|
|
29
|
+
"layers": {
|
|
30
|
+
"one_word": 41,
|
|
31
|
+
"question_options": 37,
|
|
32
|
+
"one_word_only": 37
|
|
33
|
+
},
|
|
34
|
+
"center": "generic",
|
|
35
|
+
"tau": 0.043805317199998264,
|
|
36
|
+
"tau_one_word": 0.03975009964560149,
|
|
37
|
+
"tau_task_center": 0.04132507636642313,
|
|
38
|
+
"dev_accuracy": 0.54,
|
|
39
|
+
"dev_accuracy_stderr": 0.035242020373412196,
|
|
40
|
+
"dev_accuracy_task_center": 0.595,
|
|
41
|
+
"dev_ece": 0.15921275988221167,
|
|
42
|
+
"dev_accuracy_by_center": {
|
|
43
|
+
"options": 0.5361,
|
|
44
|
+
"generic": 0.55,
|
|
45
|
+
"none": 0.5506
|
|
46
|
+
},
|
|
47
|
+
"dev_accuracy_by_set": {
|
|
48
|
+
"yahootopics": 0.42,
|
|
49
|
+
"empathetic": 0.42,
|
|
50
|
+
"massive": 0.6,
|
|
51
|
+
"financialphrasebank": 0.72
|
|
52
|
+
},
|
|
53
|
+
"n_dev": 200,
|
|
54
|
+
"backend": "torch",
|
|
55
|
+
"repo": "openbmb/MiniCPM5-2B",
|
|
56
|
+
"n_layers": 42,
|
|
57
|
+
"candidate_layers": [
|
|
58
|
+
20,
|
|
59
|
+
41
|
|
60
|
+
],
|
|
61
|
+
"n_generic": 195,
|
|
62
|
+
"latency_ms_p50": 154.81362499849638,
|
|
63
|
+
"warnings": [],
|
|
64
|
+
"date": "2026-09-22",
|
|
65
|
+
"seconds": 141
|
|
66
|
+
}
|
|
67
|
+
}
|
|
Binary file
|
jul/backbone.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Backbone: one forward pass, tap hidden states at chosen layers, reuse a cached prompt prefix.
|
|
2
|
+
|
|
3
|
+
The instruction part of every prompt is identical across calls, so its KV cache is computed once
|
|
4
|
+
and each query only pays for its own tokens. When only intermediate layers are needed, the forward
|
|
5
|
+
stops right after the deepest one (the remaining layers are never computed).
|
|
6
|
+
|
|
7
|
+
The framework lives behind `Backbone`: `backends/mlx.py` (Apple Silicon) and `backends/torch.py`
|
|
8
|
+
(transformers: CUDA, CPU, MPS). Everything above this module only sees numpy arrays.
|
|
9
|
+
`Backbone(name)` picks the backend from `JUL_BACKEND`, else MLX when available, else torch.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
BACKENDS = ("mlx", "torch")
|
|
24
|
+
|
|
25
|
+
#: Preset name -> repo per backend. A name missing here is used as the repo itself.
|
|
26
|
+
MODELS: dict[str, dict[str, str]] = {
|
|
27
|
+
"minicpm5-2b": {"mlx": "openbmb/MiniCPM5-2B-MLX", "torch": "openbmb/MiniCPM5-2B"},
|
|
28
|
+
"qwen3-0.6b": {"mlx": "mlx-community/Qwen3-0.6B-4bit", "torch": "Qwen/Qwen3-0.6B"},
|
|
29
|
+
"qwen3-1.7b": {"mlx": "mlx-community/Qwen3-1.7B-4bit", "torch": "Qwen/Qwen3-1.7B"},
|
|
30
|
+
"qwen3.5-9b": {"mlx": "mlx-community/Qwen3.5-9B-4bit", "torch": "Qwen/Qwen3.5-9B"},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#: Size of a group in `PromptTemplate.run_batch`: rows x longest prompt (cached prefix included).
|
|
34
|
+
#: `JUL_BATCH_TOKENS` / `JUL_BATCH_SIZE` override them, read at each call like the other JUL_* variables.
|
|
35
|
+
BATCH_TOKENS = 16384
|
|
36
|
+
BATCH_SIZE = 64
|
|
37
|
+
|
|
38
|
+
# Layers tapped during extraction, as fractions of the model depth.
|
|
39
|
+
DEFAULT_LAYER_FRACTIONS = (0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mlx_available() -> bool:
|
|
43
|
+
return (platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
44
|
+
and importlib.util.find_spec("mlx") is not None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_backend(backend: str | None = None) -> str:
|
|
48
|
+
backend = backend or os.environ.get("JUL_BACKEND")
|
|
49
|
+
if backend:
|
|
50
|
+
if backend not in BACKENDS:
|
|
51
|
+
raise ValueError(f"Unknown backend {backend!r}. Available: {', '.join(BACKENDS)}")
|
|
52
|
+
return backend
|
|
53
|
+
if _mlx_available():
|
|
54
|
+
return "mlx"
|
|
55
|
+
if importlib.util.find_spec("torch") is not None:
|
|
56
|
+
return "torch"
|
|
57
|
+
raise ImportError("No backend installed: pip install 'jul[mlx]' (Apple Silicon) or 'jul[torch]'")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def model_key(name: str, backend: str) -> str:
|
|
61
|
+
"""Identifies vectors computed by a model on a backend (saved centers, heads, calibrations).
|
|
62
|
+
|
|
63
|
+
MLX keeps the bare name so that contexts saved before backends existed stay valid.
|
|
64
|
+
"""
|
|
65
|
+
return name if backend == "mlx" else f"{name}@{backend}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def repo_for(name: str, backend: str) -> str:
|
|
69
|
+
repos = MODELS.get(name)
|
|
70
|
+
if repos is None:
|
|
71
|
+
return name
|
|
72
|
+
if backend not in repos:
|
|
73
|
+
raise ValueError(f"{name!r} has no {backend} repo")
|
|
74
|
+
return repos[backend]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Backbone:
|
|
78
|
+
"""A causal LM read by the vector method. `Backbone(name)` returns the resolved backend's subclass.
|
|
79
|
+
|
|
80
|
+
Subclasses set `tokenizer` (a Hugging Face tokenizer, for `encode` and the chat template) and
|
|
81
|
+
`n_layers`, and implement `forward` and `cache_prefix`.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
backend: str = ""
|
|
85
|
+
|
|
86
|
+
def __new__(cls, name: str, backend: str | None = None, **kwargs):
|
|
87
|
+
if cls is Backbone:
|
|
88
|
+
backend = resolve_backend(backend)
|
|
89
|
+
if backend == "mlx":
|
|
90
|
+
from .backends.mlx import MLXBackbone as cls
|
|
91
|
+
else:
|
|
92
|
+
from .backends.torch import TorchBackbone as cls
|
|
93
|
+
return super().__new__(cls)
|
|
94
|
+
|
|
95
|
+
def __init__(self, name: str, backend: str | None = None):
|
|
96
|
+
self.name = name
|
|
97
|
+
self.repo = repo_for(name, self.backend)
|
|
98
|
+
self.key = model_key(name, self.backend)
|
|
99
|
+
|
|
100
|
+
def layer_indices(self, fractions=DEFAULT_LAYER_FRACTIONS) -> list[int]:
|
|
101
|
+
return sorted({max(0, min(self.n_layers - 1, round(f * self.n_layers) - 1)) for f in fractions})
|
|
102
|
+
|
|
103
|
+
def encode(self, text: str) -> list[int]:
|
|
104
|
+
return self.tokenizer.encode(text, add_special_tokens=False)
|
|
105
|
+
|
|
106
|
+
def forward(self, tokens: list[int], layers=(), logits=False, pool: tuple[int, int] | None = None,
|
|
107
|
+
prefix=None) -> tuple[dict[int, np.ndarray], np.ndarray | None]:
|
|
108
|
+
"""Run tokens through the model, after `prefix` (from `cache_prefix`) when given.
|
|
109
|
+
|
|
110
|
+
Returns ({layer: (2d,) float32 features}, float32 last-token logits or None). Features are the
|
|
111
|
+
last-token hidden state concatenated with the mean hidden state over positions
|
|
112
|
+
pool=(start, end) of `tokens`. The prefix is left as it was, ready for the next query.
|
|
113
|
+
"""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
def forward_batch(self, queries: list[list[int]], layers=(), pools: list | None = None,
|
|
117
|
+
prefix=None) -> list[dict[int, np.ndarray]]:
|
|
118
|
+
"""The features of `forward` for each query, all after the same `prefix`. A backend may run
|
|
119
|
+
them in one forward; this default runs them one by one."""
|
|
120
|
+
pools = pools or [None] * len(queries)
|
|
121
|
+
return [self.forward(q, layers=layers, pool=p, prefix=prefix)[0] for q, p in zip(queries, pools)]
|
|
122
|
+
|
|
123
|
+
def cache_prefix(self, tokens: list[int]):
|
|
124
|
+
"""Run `tokens` once and keep the model state after them, for `forward(prefix=...)`."""
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
|
|
127
|
+
def last_hidden(self, tokens: list[int], prefix=None) -> np.ndarray:
|
|
128
|
+
"""(len(tokens), d) float32: the last layer's hidden states after the final norm, for every token
|
|
129
|
+
of `tokens` (run after `prefix` when given). The prefix is left as it was. Used by the pointer
|
|
130
|
+
method (jul/decision.py)."""
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def model_dir(self) -> Path:
|
|
135
|
+
"""Local directory of the weights (downloaded on first access for a Hub repo)."""
|
|
136
|
+
if Path(self.repo).is_dir():
|
|
137
|
+
return Path(self.repo)
|
|
138
|
+
from huggingface_hub import snapshot_download
|
|
139
|
+
return Path(snapshot_download(self.repo))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class PromptTemplate:
|
|
144
|
+
"""A chat prompt split around the user input: a cached prefix and a per-query suffix."""
|
|
145
|
+
|
|
146
|
+
backbone: Backbone
|
|
147
|
+
prefix_text: str
|
|
148
|
+
suffix_text: str
|
|
149
|
+
use_prefix_cache: bool = True
|
|
150
|
+
|
|
151
|
+
SENTINEL = "QF_INPUT"
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_user_message(cls, backbone: Backbone, message: str, **kw) -> "PromptTemplate":
|
|
155
|
+
"""`message` must contain {input}; it is rendered with the model's chat template."""
|
|
156
|
+
rendered = backbone.tokenizer.apply_chat_template(
|
|
157
|
+
[{"role": "user", "content": message.replace("{input}", cls.SENTINEL)}],
|
|
158
|
+
tokenize=False,
|
|
159
|
+
add_generation_prompt=True,
|
|
160
|
+
enable_thinking=False,
|
|
161
|
+
)
|
|
162
|
+
prefix, suffix = rendered.split(cls.SENTINEL)
|
|
163
|
+
return cls(backbone, prefix, suffix, **kw)
|
|
164
|
+
|
|
165
|
+
def __post_init__(self):
|
|
166
|
+
self.prefix_tokens = self.backbone.encode(self.prefix_text)
|
|
167
|
+
self._n_suffix = len(self.backbone.encode(self.suffix_text))
|
|
168
|
+
self._prefix = None
|
|
169
|
+
if self.use_prefix_cache and self.prefix_tokens:
|
|
170
|
+
self._prefix = self.backbone.cache_prefix(self.prefix_tokens)
|
|
171
|
+
|
|
172
|
+
def run(self, text: str, layers=(), logits=False):
|
|
173
|
+
query = self.backbone.encode(text + self.suffix_text)
|
|
174
|
+
n_input = max(1, len(query) - self._n_suffix)
|
|
175
|
+
if self._prefix is None:
|
|
176
|
+
p = len(self.prefix_tokens)
|
|
177
|
+
return self.backbone.forward(self.prefix_tokens + query, layers=layers, logits=logits, pool=(p, p + n_input))
|
|
178
|
+
return self.backbone.forward(query, layers=layers, logits=logits, pool=(0, n_input), prefix=self._prefix)
|
|
179
|
+
|
|
180
|
+
def run_batch(self, texts: list[str], layers=()) -> list[dict[int, np.ndarray]]:
|
|
181
|
+
"""The features of `run` for each text. Texts are sorted by length and grouped so that a group
|
|
182
|
+
holds at most BATCH_TOKENS tokens (rows x longest prompt) and BATCH_SIZE rows."""
|
|
183
|
+
queries = [self.backbone.encode(t + self.suffix_text) for t in texts]
|
|
184
|
+
n_inputs = [max(1, len(q) - self._n_suffix) for q in queries]
|
|
185
|
+
p = len(self.prefix_tokens)
|
|
186
|
+
if self._prefix is None:
|
|
187
|
+
seqs, pools = [self.prefix_tokens + q for q in queries], [(p, p + n) for n in n_inputs]
|
|
188
|
+
else:
|
|
189
|
+
seqs, pools = queries, [(0, n) for n in n_inputs]
|
|
190
|
+
max_tokens = int(os.environ.get("JUL_BATCH_TOKENS") or BATCH_TOKENS)
|
|
191
|
+
max_rows = int(os.environ.get("JUL_BATCH_SIZE") or BATCH_SIZE)
|
|
192
|
+
out: list = [None] * len(seqs)
|
|
193
|
+
group: list[int] = []
|
|
194
|
+
|
|
195
|
+
def flush():
|
|
196
|
+
got = self.backbone.forward_batch([seqs[i] for i in group], layers=layers,
|
|
197
|
+
pools=[pools[i] for i in group], prefix=self._prefix)
|
|
198
|
+
for i, features in zip(group, got):
|
|
199
|
+
out[i] = features
|
|
200
|
+
|
|
201
|
+
for i in sorted(range(len(seqs)), key=lambda i: len(seqs[i])):
|
|
202
|
+
longest = len(seqs[i]) + (p if self._prefix is not None else 0)
|
|
203
|
+
if group and ((len(group) + 1) * longest > max_tokens or len(group) >= max_rows):
|
|
204
|
+
flush()
|
|
205
|
+
group = []
|
|
206
|
+
group.append(i)
|
|
207
|
+
if group:
|
|
208
|
+
flush()
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def timed(fn, *args, **kwargs):
|
|
213
|
+
t = time.perf_counter()
|
|
214
|
+
out = fn(*args, **kwargs)
|
|
215
|
+
return out, time.perf_counter() - t
|
jul/backends/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework-specific implementations of `jul.backbone.Backbone`. Imported lazily by `Backbone(name)`."""
|
jul/backends/mlx.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""MLX backend (Apple Silicon), through mlx-lm.
|
|
2
|
+
|
|
3
|
+
`forward_batch` runs several queries in one forward, as the torch backend does: padded on the right,
|
|
4
|
+
under the causal mask alone, since a real token never sees the padding after it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import mlx.core as mx
|
|
14
|
+
import numpy as np
|
|
15
|
+
from mlx_lm import load
|
|
16
|
+
from mlx_lm.models.cache import KVCache, can_trim_prompt_cache, make_prompt_cache
|
|
17
|
+
|
|
18
|
+
from ..backbone import Backbone
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _StopForward(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rope_fix(repo: str) -> dict | None:
|
|
26
|
+
"""transformers 5 writes the RoPE base under `rope_parameters`; mlx-lm reads `rope_theta` and
|
|
27
|
+
silently falls back to 10000 when it is missing, which gives wrong answers without any error
|
|
28
|
+
(JOURNAL §9 duovicies). Pass the right value when a converted config has only the new key."""
|
|
29
|
+
path = Path(repo) / "config.json"
|
|
30
|
+
if not path.exists():
|
|
31
|
+
# a Hub repo: the cached config, else fetch that one file (1 KB, before the weights)
|
|
32
|
+
from huggingface_hub import hf_hub_download, try_to_load_from_cache
|
|
33
|
+
cached = try_to_load_from_cache(repo, "config.json")
|
|
34
|
+
try:
|
|
35
|
+
path = Path(cached if isinstance(cached, str) else hf_hub_download(repo, "config.json"))
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
config = json.loads(path.read_text())
|
|
39
|
+
theta = (config.get("rope_parameters") or {}).get("rope_theta")
|
|
40
|
+
return {"rope_theta": theta} if theta and "rope_theta" not in config else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Tap:
|
|
44
|
+
"""Wraps a transformer block to record its features and optionally halt the forward."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, block, idx: int, backbone: "MLXBackbone"):
|
|
47
|
+
self.block = block
|
|
48
|
+
self.idx = idx
|
|
49
|
+
self.bb = backbone
|
|
50
|
+
|
|
51
|
+
def __call__(self, *args, **kwargs):
|
|
52
|
+
h = self.block(*args, **kwargs)
|
|
53
|
+
if self.idx in self.bb._want:
|
|
54
|
+
pool = self.bb._pool
|
|
55
|
+
last = h[mx.arange(h.shape[0]), self.bb._last]
|
|
56
|
+
# mean in float32, rounded back to the model dtype like `h[:, start:end].mean(1)` was
|
|
57
|
+
mean = (mx.where(pool, h, 0).astype(mx.float32).sum(1) / pool.sum(1)).astype(h.dtype)
|
|
58
|
+
# [last token ; mean over the input tokens]
|
|
59
|
+
self.bb._captured[self.idx] = mx.concatenate([last, mean], axis=-1)
|
|
60
|
+
if self.bb._stop_at == self.idx:
|
|
61
|
+
raise _StopForward
|
|
62
|
+
return h
|
|
63
|
+
|
|
64
|
+
def __getattr__(self, name):
|
|
65
|
+
return getattr(self.block, name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class _Prefix:
|
|
70
|
+
n: int
|
|
71
|
+
cache: list | None = None # attention KV cache: trimmed back to the prefix after each query
|
|
72
|
+
snapshot: list | None = None # recurrent state: copied, each query starts from the copy
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MLXBackbone(Backbone):
|
|
76
|
+
backend = "mlx"
|
|
77
|
+
|
|
78
|
+
def __init__(self, name: str, backend: str | None = None):
|
|
79
|
+
super().__init__(name)
|
|
80
|
+
self.model, self.tokenizer = load(self.repo, model_config=_rope_fix(self.repo))
|
|
81
|
+
layers = self.model.layers
|
|
82
|
+
for i, block in enumerate(layers):
|
|
83
|
+
layers[i] = _Tap(block, i, self)
|
|
84
|
+
self.n_layers = len(layers)
|
|
85
|
+
self._want: set[int] = set()
|
|
86
|
+
self._stop_at: int | None = None
|
|
87
|
+
self._captured: dict[int, mx.array] = {}
|
|
88
|
+
self._last: mx.array | None = None # (B,) index of each row's last real token
|
|
89
|
+
self._pool: mx.array | None = None # (B, T, 1) bool mask of each row's pooled positions
|
|
90
|
+
pad = getattr(self.tokenizer, "pad_token_id", None)
|
|
91
|
+
self._pad = pad if pad is not None else (self.tokenizer.eos_token_id or 0)
|
|
92
|
+
lm = getattr(self.model, "language_model", self.model) # multimodal wrappers (e.g. Qwen3.5) nest the text model
|
|
93
|
+
self._inner = lm.model
|
|
94
|
+
self._lm_head = lm.lm_head if hasattr(lm, "lm_head") else self._inner.embed_tokens.as_linear
|
|
95
|
+
|
|
96
|
+
def forward(self, tokens, layers=(), logits=False, pool=None, prefix: _Prefix | None = None, cache=None):
|
|
97
|
+
"""`cache` (an mlx-lm prompt cache, advanced in place) is kept for the dev scripts."""
|
|
98
|
+
if prefix is None:
|
|
99
|
+
return self._first(self._run([tokens], cache, layers, logits, [pool]))
|
|
100
|
+
if prefix.snapshot is not None:
|
|
101
|
+
return self._first(self._run([tokens], self._restored(prefix.snapshot), layers, logits, [pool]))
|
|
102
|
+
try:
|
|
103
|
+
return self._first(self._run([tokens], prefix.cache, layers, logits, [pool]))
|
|
104
|
+
finally:
|
|
105
|
+
for c in prefix.cache:
|
|
106
|
+
if c.offset > prefix.n:
|
|
107
|
+
c.trim(c.offset - prefix.n)
|
|
108
|
+
|
|
109
|
+
def forward_batch(self, queries, layers=(), pools=None, prefix: _Prefix | None = None):
|
|
110
|
+
pools = pools or [None] * len(queries)
|
|
111
|
+
if len(queries) == 1 or (prefix is not None and not _repeatable(prefix)):
|
|
112
|
+
# a recurrent state (or a rotating window) is not repeated over a batch: one query at a time
|
|
113
|
+
return super().forward_batch(queries, layers, pools, prefix)
|
|
114
|
+
cache = None
|
|
115
|
+
if prefix is not None:
|
|
116
|
+
# a fresh cache holding the prefix once per row; the template's own cache is not touched
|
|
117
|
+
cache = make_prompt_cache(self.model)
|
|
118
|
+
for new, c in zip(cache, prefix.cache):
|
|
119
|
+
keys, values = c.state
|
|
120
|
+
new.state = (mx.repeat(keys, len(queries), axis=0), mx.repeat(values, len(queries), axis=0))
|
|
121
|
+
captured, _ = self._run(queries, cache, layers, False, pools)
|
|
122
|
+
# every group has its own shape (rows x width): MLX would keep the freed buffers of each one
|
|
123
|
+
mx.clear_cache()
|
|
124
|
+
return [{k: v[i] for k, v in captured.items()} for i in range(len(queries))]
|
|
125
|
+
|
|
126
|
+
def cache_prefix(self, tokens) -> _Prefix:
|
|
127
|
+
cache = make_prompt_cache(self.model)
|
|
128
|
+
self._run([tokens], cache, logits=True)
|
|
129
|
+
mx.eval([c.state for c in cache])
|
|
130
|
+
if can_trim_prompt_cache(cache):
|
|
131
|
+
return _Prefix(len(tokens), cache=cache)
|
|
132
|
+
# Recurrent state (e.g. Gated DeltaNet in Qwen3.5) cannot be trimmed: keep a copy of the state
|
|
133
|
+
# after the prefix. Layers replace their state arrays rather than writing into them, so the
|
|
134
|
+
# copy is never modified.
|
|
135
|
+
return _Prefix(len(tokens), snapshot=[tuple(c.state) for c in cache])
|
|
136
|
+
|
|
137
|
+
def last_hidden(self, tokens, prefix: _Prefix | None = None) -> np.ndarray:
|
|
138
|
+
self._want, self._stop_at = set(), None
|
|
139
|
+
cache = None
|
|
140
|
+
if prefix is not None:
|
|
141
|
+
cache = self._restored(prefix.snapshot) if prefix.snapshot is not None else prefix.cache
|
|
142
|
+
try:
|
|
143
|
+
h = self._inner(mx.array(tokens)[None], cache=cache)[0].astype(mx.float32)
|
|
144
|
+
mx.eval(h)
|
|
145
|
+
return np.array(h)
|
|
146
|
+
finally:
|
|
147
|
+
if prefix is not None and prefix.cache is not None:
|
|
148
|
+
for c in prefix.cache:
|
|
149
|
+
if c.offset > prefix.n:
|
|
150
|
+
c.trim(c.offset - prefix.n)
|
|
151
|
+
|
|
152
|
+
def _restored(self, snapshot):
|
|
153
|
+
cache = make_prompt_cache(self.model)
|
|
154
|
+
for c, state in zip(cache, snapshot):
|
|
155
|
+
c.state = list(state)
|
|
156
|
+
return cache
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _first(result):
|
|
160
|
+
captured, logits = result
|
|
161
|
+
return {k: v[0] for k, v in captured.items()}, (logits[0] if logits is not None else None)
|
|
162
|
+
|
|
163
|
+
def _run(self, seqs, cache=None, layers=(), logits=False, pools=None):
|
|
164
|
+
"""Right-padded batch of token lists. Returns ({layer: (B, 2d)}, (B, vocab) or None)."""
|
|
165
|
+
lengths = [len(s) for s in seqs]
|
|
166
|
+
width = max(lengths)
|
|
167
|
+
ids = np.full((len(seqs), width), self._pad, dtype=np.int32)
|
|
168
|
+
pool = np.zeros((len(seqs), width, 1), dtype=bool)
|
|
169
|
+
for i, (s, p) in enumerate(zip(seqs, pools or [None] * len(seqs))):
|
|
170
|
+
ids[i, : len(s)] = s
|
|
171
|
+
start, end = p or (0, len(s))
|
|
172
|
+
pool[i, start:end] = True
|
|
173
|
+
self._want = set(layers)
|
|
174
|
+
self._last = mx.array([n - 1 for n in lengths])
|
|
175
|
+
self._pool = mx.array(pool)
|
|
176
|
+
self._stop_at = None if logits else (max(layers) if layers else None)
|
|
177
|
+
self._captured = {}
|
|
178
|
+
out = None
|
|
179
|
+
try:
|
|
180
|
+
h = self._inner(mx.array(ids), cache=cache)
|
|
181
|
+
if logits:
|
|
182
|
+
out = self._lm_head(h[mx.arange(len(seqs)), self._last]).astype(mx.float32)
|
|
183
|
+
except _StopForward:
|
|
184
|
+
pass
|
|
185
|
+
captured = {k: v.astype(mx.float32) for k, v in self._captured.items()}
|
|
186
|
+
mx.eval(list(captured.values()) + ([out] if out is not None else []))
|
|
187
|
+
return ({k: np.array(v) for k, v in captured.items()},
|
|
188
|
+
np.array(out) if out is not None else None)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _repeatable(prefix: _Prefix) -> bool:
|
|
192
|
+
"""A plain KV cache can be copied once per row of a batch; a recurrent state or a rotating window
|
|
193
|
+
goes through `forward`, one query at a time."""
|
|
194
|
+
return prefix.cache is not None and all(type(c) is KVCache for c in prefix.cache)
|