runapprentice 0.4.2__tar.gz → 0.4.4__tar.gz
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.
- {runapprentice-0.4.2 → runapprentice-0.4.4}/.gitignore +6 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/PKG-INFO +3 -3
- {runapprentice-0.4.2 → runapprentice-0.4.4}/pyproject.toml +5 -2
- runapprentice-0.4.4/src/runapprentice/_local_ops.py +171 -0
- runapprentice-0.4.4/src/runapprentice/_mlx_eval.py +70 -0
- runapprentice-0.4.4/src/runapprentice/_mlx_lora.py +243 -0
- runapprentice-0.4.4/src/runapprentice/_train_output.py +197 -0
- runapprentice-0.4.4/src/runapprentice/cli.py +1049 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/client.py +48 -11
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/langchain.py +3 -1
- runapprentice-0.4.4/src/runapprentice/local.py +1144 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/models.py +57 -0
- runapprentice-0.4.4/tests/conftest.py +12 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_capture.py +17 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_cli.py +220 -13
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_client.py +137 -0
- runapprentice-0.4.4/tests/test_device_facts.py +60 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_local.py +329 -23
- runapprentice-0.4.4/tests/test_local_ops.py +497 -0
- runapprentice-0.4.4/tests/test_local_progress.py +228 -0
- runapprentice-0.4.4/tests/test_managed_run_readers.py +115 -0
- runapprentice-0.4.4/tests/test_train_output.py +157 -0
- runapprentice-0.4.4/tests/test_training_audit.py +275 -0
- runapprentice-0.4.2/src/runapprentice/cli.py +0 -615
- runapprentice-0.4.2/src/runapprentice/local.py +0 -612
- {runapprentice-0.4.2 → runapprentice-0.4.4}/LICENSE +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/README.md +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/openai.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.4}/tests/test_openai.py +0 -0
|
@@ -52,3 +52,9 @@ docs/personal_learning.md
|
|
|
52
52
|
.uv-cache/
|
|
53
53
|
spike/Capture-*.png
|
|
54
54
|
apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
|
|
55
|
+
supabase-magic-link-template.html
|
|
56
|
+
|
|
57
|
+
# Remotion scratch: frame exports for review, retired captures, render output.
|
|
58
|
+
# 30MB of JPEG/PNG working files — never source.
|
|
59
|
+
apprentice-video/tmp/
|
|
60
|
+
apprentice-video/out/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: runapprentice
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
|
|
5
5
|
Project-URL: Homepage, https://runapprentice.com
|
|
6
6
|
Project-URL: Documentation, https://docs.runapprentice.com
|
|
@@ -22,7 +22,7 @@ Requires-Dist: pydantic>=2.0
|
|
|
22
22
|
Provides-Extra: langchain
|
|
23
23
|
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
24
24
|
Provides-Extra: local
|
|
25
|
-
Requires-Dist: mlx-lm<0.
|
|
25
|
+
Requires-Dist: mlx-lm<0.33,>=0.32.2; extra == 'local'
|
|
26
26
|
Provides-Extra: optimize
|
|
27
27
|
Requires-Dist: dspy<4,>=3.2.1; extra == 'optimize'
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runapprentice"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.4"
|
|
4
4
|
description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -32,7 +32,7 @@ Documentation = "https://docs.runapprentice.com"
|
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
34
|
langchain = ["langchain-core>=0.3"]
|
|
35
|
-
local = ["mlx-lm>=0.
|
|
35
|
+
local = ["mlx-lm>=0.32.2,<0.33"]
|
|
36
36
|
optimize = ["dspy>=3.2.1,<4"]
|
|
37
37
|
|
|
38
38
|
[dependency-groups]
|
|
@@ -57,6 +57,9 @@ only-include = ["src/runapprentice", "tests", "README.md", "LICENSE", "pyproject
|
|
|
57
57
|
line-length = 100
|
|
58
58
|
src = ["src", "tests"]
|
|
59
59
|
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E4", "E7", "E9", "F"]
|
|
62
|
+
|
|
60
63
|
[tool.mypy]
|
|
61
64
|
strict = true
|
|
62
65
|
mypy_path = "src"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Process ownership and durable local training status. No MLX imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
from runapprentice._train_output import status_command, watch_command
|
|
19
|
+
from runapprentice.client import ApprenticeError
|
|
20
|
+
|
|
21
|
+
_owner: dict[str, Any] | None = None
|
|
22
|
+
_thread: int | None = None
|
|
23
|
+
_fd: int | None = None
|
|
24
|
+
json_progress = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lock_fd() -> int | None:
|
|
28
|
+
return _fd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ponytail: the lock lives in this user's home, so it serializes runs per macOS account. Two
|
|
32
|
+
# accounts training at once can still compete for the GPU; a shared lock would need a
|
|
33
|
+
# world-writable path and its permission problems. Docs say "per user account".
|
|
34
|
+
def _root() -> Path:
|
|
35
|
+
return Path.home() / ".apprentice" / "local-runs"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
return value if isinstance(value, dict) else {}
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write(path: Path, value: dict[str, Any]) -> None:
|
|
47
|
+
name: str | None = None
|
|
48
|
+
try:
|
|
49
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
|
50
|
+
delete=False) as out:
|
|
51
|
+
name = out.name
|
|
52
|
+
json.dump(value, out)
|
|
53
|
+
out.write("\n")
|
|
54
|
+
os.replace(name, path)
|
|
55
|
+
finally:
|
|
56
|
+
if name is not None:
|
|
57
|
+
Path(name).unlink(missing_ok=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def update(**values: Any) -> None:
|
|
61
|
+
if _owner is not None:
|
|
62
|
+
if values.get("state") in {"failed", "completed"}:
|
|
63
|
+
values.setdefault("finished", dt.datetime.now(dt.UTC).isoformat())
|
|
64
|
+
_owner.update(values)
|
|
65
|
+
_write(Path(_owner["status_path"]), _owner)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def snapshot() -> dict[str, Any]:
|
|
69
|
+
return dict(_owner) if _owner is not None else {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def status(task: str) -> dict[str, Any]:
|
|
73
|
+
from runapprentice.local import _local_run_dir
|
|
74
|
+
|
|
75
|
+
path = _local_run_dir(task) / "status.json"
|
|
76
|
+
value = _read(path)
|
|
77
|
+
if not value:
|
|
78
|
+
return {"task": task, "state": "none"}
|
|
79
|
+
if value.get("state") == "running":
|
|
80
|
+
try:
|
|
81
|
+
with (_root() / ".lock").open("r+") as lock:
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
84
|
+
value["state"] = "stopped"
|
|
85
|
+
except BlockingIOError:
|
|
86
|
+
holder = _read(_root() / "holder.json")
|
|
87
|
+
if holder.get("run_id") != value.get("run_id"):
|
|
88
|
+
value["state"] = "stopped"
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
value["state"] = "stopped"
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _interrupt(signum: int, frame: Any) -> None:
|
|
95
|
+
reason = ("the terminal closed" if signum == signal.SIGHUP
|
|
96
|
+
else "the process was told to quit, often by a command timeout")
|
|
97
|
+
raise KeyboardInterrupt(f"Stopped because {reason}.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _acquire(lock: Any) -> None:
|
|
101
|
+
# status() probes this lock for an instant, so one refusal may only mean a poll was
|
|
102
|
+
# looking. A real run holds it for much longer than these retries.
|
|
103
|
+
for attempt in range(3):
|
|
104
|
+
try:
|
|
105
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
106
|
+
return
|
|
107
|
+
except BlockingIOError:
|
|
108
|
+
if attempt == 2:
|
|
109
|
+
raise
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def training(task: str) -> Iterator[None]:
|
|
115
|
+
global _owner, _thread, _fd
|
|
116
|
+
from runapprentice.local import _local_run_dir
|
|
117
|
+
|
|
118
|
+
folder = _local_run_dir(task)
|
|
119
|
+
if _owner is not None and _thread == threading.get_ident():
|
|
120
|
+
if _owner["task"] != task:
|
|
121
|
+
raise ApprenticeError("Another local task is already running.", code="busy")
|
|
122
|
+
yield
|
|
123
|
+
return
|
|
124
|
+
_root().mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with (_root() / ".lock").open("a+") as lock:
|
|
126
|
+
try:
|
|
127
|
+
_acquire(lock)
|
|
128
|
+
except BlockingIOError as exc:
|
|
129
|
+
holder = _read(_root() / "holder.json")
|
|
130
|
+
command = status_command(str(holder.get("task", task)))
|
|
131
|
+
raise ApprenticeError(
|
|
132
|
+
f"Local training is busy: task {holder.get('task', 'unknown')}, "
|
|
133
|
+
f"pid {holder.get('pid', 'unknown')}. Check: {command}", code="busy",
|
|
134
|
+
remediation=command,
|
|
135
|
+
) from exc
|
|
136
|
+
previous = {}
|
|
137
|
+
try:
|
|
138
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
_fd = lock.fileno()
|
|
140
|
+
_thread = threading.get_ident()
|
|
141
|
+
_owner = {"task": task, "pid": os.getpid(), "run_id": uuid.uuid4().hex,
|
|
142
|
+
"state": "running", "phase": "preflight", "step": None,
|
|
143
|
+
"total": None, "percent": None, "time_left": None,
|
|
144
|
+
"train_loss": None, "val_loss": None, "val_step": None,
|
|
145
|
+
"train_points": [], "val_points": [], "memory_points": [],
|
|
146
|
+
"speed_points": [], "tokens_per_second": None,
|
|
147
|
+
"peak_memory": None,
|
|
148
|
+
"effort": None, "started": dt.datetime.now(dt.UTC).isoformat(),
|
|
149
|
+
"finished": None, "status_path": str(folder / "status.json")}
|
|
150
|
+
_owner.update(log_path=str(folder / "train.log"),
|
|
151
|
+
status_command=status_command(task), watch_command=watch_command(task))
|
|
152
|
+
_write(_root() / "holder.json", _owner)
|
|
153
|
+
update()
|
|
154
|
+
with (folder / "train.log").open("w", encoding="utf-8") as log:
|
|
155
|
+
log.write(f"Training {task!r}, pid {os.getpid()}, started {_owner['started']}\n")
|
|
156
|
+
if threading.current_thread() is threading.main_thread():
|
|
157
|
+
for sig in (signal.SIGTERM, signal.SIGHUP):
|
|
158
|
+
previous[sig] = signal.signal(sig, _interrupt)
|
|
159
|
+
yield
|
|
160
|
+
except BaseException as exc:
|
|
161
|
+
try:
|
|
162
|
+
update(state="failed", message=str(exc) or "Local training was interrupted.",
|
|
163
|
+
finished=dt.datetime.now(dt.UTC).isoformat())
|
|
164
|
+
except OSError:
|
|
165
|
+
# ponytail: preserve the original failure if the disk cannot save status.
|
|
166
|
+
pass
|
|
167
|
+
raise
|
|
168
|
+
finally:
|
|
169
|
+
for sig, handler in previous.items():
|
|
170
|
+
signal.signal(sig, handler)
|
|
171
|
+
_owner, _thread, _fd = None, None, None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Generate held-out answers with one model load: `python -m runapprentice._mlx_eval`.
|
|
2
|
+
|
|
3
|
+
Reads one JSON string (the prompt) per line from --prompts and writes one JSON
|
|
4
|
+
string (the answer) per line to --out. `mlx_lm generate` per row reloaded the
|
|
5
|
+
model every time. The memory guard from _mlx_lora covers this step too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
import mlx.core as mx
|
|
17
|
+
|
|
18
|
+
from runapprentice._mlx_lora import memory_ceiling_gb
|
|
19
|
+
from runapprentice.local import MEMORY_GUARD_EXIT
|
|
20
|
+
|
|
21
|
+
# Generation stops at the end of the answer; this only caps a model that never stops.
|
|
22
|
+
# mlx_lm generate's default of 100 tokens cut off real JSON answers mid-object.
|
|
23
|
+
MAX_TOKENS = 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
from mlx_lm import generate, load
|
|
28
|
+
|
|
29
|
+
parser = argparse.ArgumentParser()
|
|
30
|
+
parser.add_argument("--model", required=True)
|
|
31
|
+
parser.add_argument("--adapter-path", required=True)
|
|
32
|
+
parser.add_argument("--prompts", type=Path, required=True)
|
|
33
|
+
parser.add_argument("--out", type=Path, required=True)
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
|
|
36
|
+
ceiling = memory_ceiling_gb()
|
|
37
|
+
loaded = load(args.model, adapter_path=args.adapter_path)
|
|
38
|
+
# Any: mlx-lm's tokenizer wrapper forwards apply_chat_template untyped.
|
|
39
|
+
model, tokenizer = loaded[0], cast(Any, loaded[1])
|
|
40
|
+
with args.prompts.open(encoding="utf-8") as src, args.out.open("w", encoding="utf-8") as out:
|
|
41
|
+
for line in src:
|
|
42
|
+
prompt = tokenizer.apply_chat_template(
|
|
43
|
+
[{"role": "user", "content": json.loads(line)}],
|
|
44
|
+
tokenize=False,
|
|
45
|
+
add_generation_prompt=True,
|
|
46
|
+
# Qwen3.5 reasons in free text by default; the scorer needs the answer.
|
|
47
|
+
enable_thinking=False,
|
|
48
|
+
)
|
|
49
|
+
answer = generate(
|
|
50
|
+
model,
|
|
51
|
+
tokenizer,
|
|
52
|
+
tokenizer.encode(prompt, add_special_tokens=False),
|
|
53
|
+
max_tokens=MAX_TOKENS,
|
|
54
|
+
)
|
|
55
|
+
out.write(json.dumps(answer, ensure_ascii=False) + "\n")
|
|
56
|
+
out.flush()
|
|
57
|
+
if os.environ.get("APPRENTICE_MLX_PROGRESS"):
|
|
58
|
+
print('APPRENTICE_PROGRESS {"row": 1}', flush=True)
|
|
59
|
+
peak = mx.get_peak_memory() / 1e9
|
|
60
|
+
if peak > ceiling:
|
|
61
|
+
print(
|
|
62
|
+
f"[apprentice] memory guard: eval peaked at {peak:.1f} GB, over the "
|
|
63
|
+
f"{ceiling:.1f} GB safe limit for this Mac's GPU. Stopping.",
|
|
64
|
+
flush=True,
|
|
65
|
+
)
|
|
66
|
+
raise SystemExit(MEMORY_GUARD_EXIT)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""`python -m mlx_lm lora` with two changes for long-row Qwen3.5 training.
|
|
2
|
+
|
|
3
|
+
1. Speed and memory. In training mode mlx-lm runs Qwen3.5's linear-attention
|
|
4
|
+
recurrence (`gated_delta_ops`) one token at a time, keeping a 2 MB float32
|
|
5
|
+
state per token per layer for backprop. An 800-token row took 26-125 s per
|
|
6
|
+
step and ~23 GB. `gated_delta_chunked` computes the same recurrence 64
|
|
7
|
+
tokens at a time with matmuls (the chunkwise "WY" form of the delta rule),
|
|
8
|
+
matching the reference to float32 rounding. Model quality is unchanged.
|
|
9
|
+
2. A memory guard. MLX's own memory limit is a guideline, not a cap, so a run
|
|
10
|
+
that outgrows the GPU working set pushes the Mac into swap. The guard stops
|
|
11
|
+
training with exit code MEMORY_GUARD_EXIT as soon as a step's peak passes
|
|
12
|
+
the ceiling, before the next, possibly longer, step can go further.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
import mlx.core as mx
|
|
25
|
+
|
|
26
|
+
from runapprentice.local import MEMORY_GUARD_EXIT, _check_token_lengths
|
|
27
|
+
from runapprentice.client import ApprenticeError
|
|
28
|
+
# Fraction of Metal's recommended working set a step may peak at. The rest is
|
|
29
|
+
# headroom for a longer row than the one that set the peak.
|
|
30
|
+
MEMORY_GUARD_FRACTION = 0.85
|
|
31
|
+
CHUNK = 64
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _unit_lower_inverse(n: mx.array) -> mx.array:
|
|
35
|
+
# Forward substitution for (I + N)^-1, N strictly lower triangular. Row i is
|
|
36
|
+
# e_i - N[i, :i] @ rows[:i]. A power series ((I - N)(I + N^2)...) is shorter
|
|
37
|
+
# but cancels catastrophically in float32 when keys are correlated, as in text.
|
|
38
|
+
c = n.shape[-1]
|
|
39
|
+
eye = mx.eye(c, dtype=n.dtype)
|
|
40
|
+
rows = [mx.broadcast_to(eye[0], n.shape[:-2] + (c,))]
|
|
41
|
+
for i in range(1, c):
|
|
42
|
+
prev = mx.stack(rows, axis=-2) # [..., i, C]
|
|
43
|
+
rows.append(eye[i] - (n[..., i : i + 1, :i] @ prev)[..., 0, :])
|
|
44
|
+
return mx.stack(rows, axis=-2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gated_delta_chunked(
|
|
48
|
+
q: mx.array,
|
|
49
|
+
k: mx.array,
|
|
50
|
+
v: mx.array,
|
|
51
|
+
g: mx.array,
|
|
52
|
+
beta: mx.array,
|
|
53
|
+
state: mx.array,
|
|
54
|
+
chunk: int = CHUNK,
|
|
55
|
+
) -> tuple[mx.array, mx.array]:
|
|
56
|
+
"""Same result as mlx_lm `gated_delta_ops` for a scalar gate and no mask.
|
|
57
|
+
|
|
58
|
+
q, k: [B, T, Hk, Dk]; v: [B, T, Hv, Dv]; g, beta: [B, T, Hv]; state: [B, Hv, Dv, Dk].
|
|
59
|
+
Per token the reference does S = g*S; d = beta*(v - S k); S += d k^T; y = S q.
|
|
60
|
+
"""
|
|
61
|
+
B, T, Hk, _ = q.shape
|
|
62
|
+
Hv = v.shape[-2]
|
|
63
|
+
if (repeat := Hv // Hk) > 1:
|
|
64
|
+
q = mx.repeat(q, repeat, -2)
|
|
65
|
+
k = mx.repeat(k, repeat, -2)
|
|
66
|
+
in_dtype = q.dtype
|
|
67
|
+
q, k, v = (x.astype(mx.float32).transpose(0, 2, 1, 3) for x in (q, k, v))
|
|
68
|
+
# Clamp before log: log's backward divides by its input, even with zero cotangent.
|
|
69
|
+
log_g = mx.log(mx.maximum(g.astype(mx.float32), 1e-30)).transpose(0, 2, 1)
|
|
70
|
+
beta = beta.astype(mx.float32).transpose(0, 2, 1)
|
|
71
|
+
# Padding tokens have k = 0, beta = 0 and g = 1, so they leave the state untouched.
|
|
72
|
+
pad = (-T) % chunk
|
|
73
|
+
if pad:
|
|
74
|
+
q, k, v = (mx.pad(x, [(0, 0), (0, 0), (0, pad), (0, 0)]) for x in (q, k, v))
|
|
75
|
+
log_g = mx.pad(log_g, [(0, 0), (0, 0), (0, pad)])
|
|
76
|
+
beta = mx.pad(beta, [(0, 0), (0, 0), (0, pad)])
|
|
77
|
+
n = (T + pad) // chunk
|
|
78
|
+
q, k, v = (x.reshape(B, Hv, n, chunk, x.shape[-1]) for x in (q, k, v))
|
|
79
|
+
log_g = log_g.reshape(B, Hv, n, chunk)
|
|
80
|
+
beta = beta.reshape(B, Hv, n, chunk)
|
|
81
|
+
|
|
82
|
+
cum = mx.cumsum(log_g, axis=-1) # log of the decay from chunk start to token i
|
|
83
|
+
idx = mx.arange(chunk)
|
|
84
|
+
strict = idx[:, None] > idx[None, :]
|
|
85
|
+
inclusive = idx[:, None] >= idx[None, :]
|
|
86
|
+
diff = cum[..., :, None] - cum[..., None, :]
|
|
87
|
+
decay_strict = mx.where(strict, mx.exp(mx.where(strict, diff, 0.0)), 0.0)
|
|
88
|
+
decay_inclusive = mx.where(inclusive, mx.exp(mx.where(inclusive, diff, 0.0)), 0.0)
|
|
89
|
+
# Within a chunk the deltas solve (I + A) D = beta * (V - Gamma K S0^T).
|
|
90
|
+
a = beta[..., None] * decay_strict * (k @ k.swapaxes(-1, -2))
|
|
91
|
+
a_inv = _unit_lower_inverse(a)
|
|
92
|
+
gamma = mx.exp(cum)[..., None]
|
|
93
|
+
w = a_inv @ (beta[..., None] * gamma * k)
|
|
94
|
+
u = a_inv @ (beta[..., None] * v)
|
|
95
|
+
attn = decay_inclusive * (q @ k.swapaxes(-1, -2))
|
|
96
|
+
to_end = mx.exp(cum[..., -1:] - cum)[..., None]
|
|
97
|
+
|
|
98
|
+
ys = []
|
|
99
|
+
for c in range(n):
|
|
100
|
+
state_t = state.swapaxes(-1, -2)
|
|
101
|
+
delta = u[:, :, c] - w[:, :, c] @ state_t
|
|
102
|
+
ys.append(gamma[:, :, c] * (q[:, :, c] @ state_t) + attn[:, :, c] @ delta)
|
|
103
|
+
state = mx.exp(cum[:, :, c, -1])[..., None, None] * state + (
|
|
104
|
+
delta.swapaxes(-1, -2) @ (to_end[:, :, c] * k[:, :, c])
|
|
105
|
+
)
|
|
106
|
+
y = mx.concatenate(ys, axis=2)[:, :, :T].transpose(0, 2, 1, 3)
|
|
107
|
+
return y.astype(in_dtype), state
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _install_chunked_recurrence() -> None:
|
|
111
|
+
from mlx_lm.models import gated_delta
|
|
112
|
+
|
|
113
|
+
reference: Callable[..., tuple[mx.array, mx.array]] = gated_delta.gated_delta_ops
|
|
114
|
+
|
|
115
|
+
def gated_delta_ops(
|
|
116
|
+
q: mx.array,
|
|
117
|
+
k: mx.array,
|
|
118
|
+
v: mx.array,
|
|
119
|
+
g: mx.array,
|
|
120
|
+
beta: mx.array,
|
|
121
|
+
state: mx.array | None = None,
|
|
122
|
+
mask: mx.array | None = None,
|
|
123
|
+
) -> tuple[mx.array, mx.array]:
|
|
124
|
+
# Training passes no mask and a per-head scalar gate; anything else keeps the reference.
|
|
125
|
+
if mask is not None or g.ndim != 3:
|
|
126
|
+
return reference(q, k, v, g, beta, state, mask)
|
|
127
|
+
if state is None:
|
|
128
|
+
B, _, _, Dk = q.shape
|
|
129
|
+
Hv, Dv = v.shape[-2:]
|
|
130
|
+
state = mx.zeros((B, Hv, Dv, Dk), dtype=mx.float32)
|
|
131
|
+
return gated_delta_chunked(q, k, v, g, beta, state)
|
|
132
|
+
|
|
133
|
+
# gated_delta_update looks this name up in its module at call time.
|
|
134
|
+
gated_delta.gated_delta_ops = gated_delta_ops
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def memory_ceiling_gb() -> float:
|
|
138
|
+
return float(mx.device_info()["max_recommended_working_set_size"]) * MEMORY_GUARD_FRACTION / 1e9
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _install_memory_guard() -> None:
|
|
142
|
+
from mlx_lm import lora
|
|
143
|
+
from mlx_lm.tuner.callbacks import get_reporting_callbacks as original
|
|
144
|
+
|
|
145
|
+
ceiling = memory_ceiling_gb()
|
|
146
|
+
# local.py reads the last report to estimate the full run's time.
|
|
147
|
+
stats_path = os.environ.get("APPRENTICE_MLX_STATS")
|
|
148
|
+
|
|
149
|
+
# Duck-typed: the trainer only calls these two methods.
|
|
150
|
+
class MemoryGuard:
|
|
151
|
+
def __init__(self, inner: Any) -> None:
|
|
152
|
+
self.inner = inner
|
|
153
|
+
|
|
154
|
+
def on_train_loss_report(self, train_info: dict[str, Any]) -> None:
|
|
155
|
+
if not math.isfinite(float(train_info["train_loss"])):
|
|
156
|
+
print("Training stopped because the training loss is NaN or infinite.", flush=True)
|
|
157
|
+
raise SystemExit(87)
|
|
158
|
+
if os.environ.get("APPRENTICE_MLX_PROGRESS"):
|
|
159
|
+
progress = {
|
|
160
|
+
key: value for key, value in train_info.items()
|
|
161
|
+
if key == "iteration" or (
|
|
162
|
+
key in {"train_loss", "peak_memory", "tokens_per_second"}
|
|
163
|
+
and math.isfinite(float(value))
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
print(
|
|
167
|
+
"APPRENTICE_PROGRESS "
|
|
168
|
+
+ json.dumps(progress, default=float, allow_nan=False),
|
|
169
|
+
flush=True,
|
|
170
|
+
)
|
|
171
|
+
if self.inner is not None:
|
|
172
|
+
self.inner.on_train_loss_report(train_info)
|
|
173
|
+
if stats_path:
|
|
174
|
+
Path(stats_path).write_text(json.dumps(train_info, default=float), encoding="utf-8")
|
|
175
|
+
# local.py requests a report every step, before another batch runs.
|
|
176
|
+
peak = float(train_info["peak_memory"]) # GB, 1e9 bytes, as mlx-lm reports it
|
|
177
|
+
if peak > ceiling:
|
|
178
|
+
print(
|
|
179
|
+
f"[apprentice] memory guard: training peaked at {peak:.1f} GB, over the "
|
|
180
|
+
f"{ceiling:.1f} GB safe limit for this Mac's GPU. Stopping.",
|
|
181
|
+
flush=True,
|
|
182
|
+
)
|
|
183
|
+
raise SystemExit(MEMORY_GUARD_EXIT)
|
|
184
|
+
|
|
185
|
+
def on_val_loss_report(self, val_info: dict[str, Any]) -> None:
|
|
186
|
+
if (
|
|
187
|
+
os.environ.get("APPRENTICE_MLX_PROGRESS")
|
|
188
|
+
and math.isfinite(float(val_info["val_loss"]))
|
|
189
|
+
):
|
|
190
|
+
print(
|
|
191
|
+
"APPRENTICE_PROGRESS "
|
|
192
|
+
+ json.dumps(
|
|
193
|
+
{key: val_info[key] for key in ("iteration", "val_loss")},
|
|
194
|
+
default=float,
|
|
195
|
+
allow_nan=False,
|
|
196
|
+
),
|
|
197
|
+
flush=True,
|
|
198
|
+
)
|
|
199
|
+
if self.inner is not None:
|
|
200
|
+
self.inner.on_val_loss_report(val_info)
|
|
201
|
+
|
|
202
|
+
def get_reporting_callbacks(*args: Any, **kwargs: Any) -> MemoryGuard:
|
|
203
|
+
return MemoryGuard(original(*args, **kwargs))
|
|
204
|
+
|
|
205
|
+
# lora.run looks this name up in its own module at call time.
|
|
206
|
+
setattr(lora, "get_reporting_callbacks", get_reporting_callbacks)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> None:
|
|
210
|
+
_install_chunked_recurrence()
|
|
211
|
+
_install_memory_guard()
|
|
212
|
+
from mlx_lm import lora
|
|
213
|
+
|
|
214
|
+
from mlx_lm.tuner.datasets import create_dataset
|
|
215
|
+
|
|
216
|
+
original_load = getattr(lora, "load_dataset")
|
|
217
|
+
|
|
218
|
+
def load_largest(args: Any, tokenizer: Any) -> Any:
|
|
219
|
+
train, valid, test = original_load(args, tokenizer)
|
|
220
|
+
for name, dataset in (("training", train), ("held-out", valid)):
|
|
221
|
+
try:
|
|
222
|
+
_check_token_lengths(dataset, args.max_seq_length, name)
|
|
223
|
+
except ApprenticeError as exc:
|
|
224
|
+
print(str(exc), flush=True)
|
|
225
|
+
raise SystemExit(86)
|
|
226
|
+
if not os.environ.get("APPRENTICE_MLX_STATS"):
|
|
227
|
+
return train, valid, test
|
|
228
|
+
# Reuse the training tokenizer and chat template, without loading another model.
|
|
229
|
+
indices = sorted(
|
|
230
|
+
range(len(train)),
|
|
231
|
+
key=lambda i: min(len(train.process(train[i])[0]), args.max_seq_length),
|
|
232
|
+
)[-args.batch_size :]
|
|
233
|
+
largest = create_dataset([train[i] for i in indices], tokenizer, args)
|
|
234
|
+
return largest, largest, test
|
|
235
|
+
|
|
236
|
+
setattr(lora, "load_dataset", load_largest)
|
|
237
|
+
|
|
238
|
+
sys.argv[0] = "mlx_lm.lora"
|
|
239
|
+
getattr(lora, "main")() # untyped in mlx-lm
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|