@tuned-tensor/cli 0.13.1-beta.5 → 0.13.1-beta.7
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.
- package/README.md +24 -6
- package/dist/{chunk-AVMRHDFY.js → chunk-AKJ5WZ2S.js} +75 -52
- package/dist/chunk-AKJ5WZ2S.js.map +1 -0
- package/dist/index.js +142 -22
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +71 -6
- package/dist/local-runtime.js.map +1 -1
- package/docs/local-runtime/architecture.md +24 -6
- package/docs/local-runtime/foundation-long-runs.md +108 -0
- package/package.json +1 -1
- package/training/foundation/src/checkpoint.py +235 -0
- package/training/foundation/src/common.py +30 -2
- package/training/foundation/src/data.py +154 -6
- package/training/foundation/src/evaluate.py +43 -9
- package/training/foundation/src/pretrain.py +439 -27
- package/training/foundation/src/rl.py +11 -3
- package/training/foundation/src/train_tokenizer.py +38 -5
- package/dist/chunk-AVMRHDFY.js.map +0 -1
|
@@ -51,19 +51,37 @@ TT Local currently ships two such methods:
|
|
|
51
51
|
rejects `nproc_per_node` values above 1 and unsupported steps such as
|
|
52
52
|
`compare` before creating run artifacts.
|
|
53
53
|
|
|
54
|
-
The foundation engine is a readable
|
|
55
|
-
not a port of nanochat's
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
The foundation engine is a readable single-GPU trainer inspired by nanochat,
|
|
55
|
+
not a port of nanochat's distributed or compute-optimal training stack. It can
|
|
56
|
+
stream configured text/JSONL shards into a deterministic token cache, use a
|
|
57
|
+
distinct held-out BPB corpus, and atomically recover model, optimizer,
|
|
58
|
+
scheduler, RNG, counters, and token position. Without a configured corpus it
|
|
59
|
+
retains the bounded prompt/example smoke path. Chat evaluation reuses the spec
|
|
60
|
+
examples. RL uses a
|
|
58
61
|
single seeded on-policy sample per step with a sparse last-number exact reward
|
|
59
62
|
and requires every expected output to contain a number. BPB is normalized over
|
|
60
63
|
decoded target bytes from each evaluated window, but the corpus remains
|
|
61
|
-
example-derived
|
|
62
|
-
deliberate overfitting; they do not measure
|
|
64
|
+
example-derived unless `validation_path` is configured. Smoke metrics prove
|
|
65
|
+
stage wiring and can demonstrate deliberate overfitting; they do not measure
|
|
66
|
+
held-out model capability.
|
|
63
67
|
|
|
64
68
|
Do not mix those lockfiles. Each engine hashes its own `pyproject.toml` and
|
|
65
69
|
`uv.lock` into a distinct cache environment.
|
|
66
70
|
|
|
71
|
+
## Spec-driven instruction contract
|
|
72
|
+
|
|
73
|
+
Adapter and foundation specs extend one engine-neutral behavior schema:
|
|
74
|
+
`name`, `description`, `system_prompt`, `guidelines`, `constraints`, and
|
|
75
|
+
`examples`. Node compiles the three instruction fields into one canonical
|
|
76
|
+
system message. The adapter dataset compiler and every foundation stage use
|
|
77
|
+
that same compiler, so tokenizer input, pretraining smoke data, chat SFT, RL,
|
|
78
|
+
and evaluation all receive guidelines and constraints consistently.
|
|
79
|
+
|
|
80
|
+
Engine-specific fields control execution only. Adapter specs add `base_model`,
|
|
81
|
+
LoRA hyperparameters, and optional prebuilt datasets. Foundation specs add
|
|
82
|
+
`engine: "foundation"` and the `foundation` training configuration. The
|
|
83
|
+
behavioral instruction remains the shared source of truth.
|
|
84
|
+
|
|
67
85
|
## Python boundary
|
|
68
86
|
|
|
69
87
|
Node owns input validation, state, process lifecycle, manifests, and reports.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Long foundation runs
|
|
2
|
+
|
|
3
|
+
The foundation engine can run recoverable, single-GPU pretraining from `.txt`
|
|
4
|
+
files or JSONL shards whose rows contain a string `text` field. Keep training
|
|
5
|
+
and validation data in different files or directories.
|
|
6
|
+
|
|
7
|
+
## Spec
|
|
8
|
+
|
|
9
|
+
The long-run fields live under `foundation` in `tunedtensor.json`:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"corpus_path": "./data/train",
|
|
14
|
+
"validation_path": "./data/validation.jsonl",
|
|
15
|
+
"checkpoint_backup_dir": "/mnt/checkpoint-backup/my-run",
|
|
16
|
+
"depth": 20,
|
|
17
|
+
"vocab_size": 32000,
|
|
18
|
+
"tokenizer_max_chars": 100000000,
|
|
19
|
+
"max_chars": 50000000000,
|
|
20
|
+
"pretrain_steps": 200000,
|
|
21
|
+
"sequence_length": 1024,
|
|
22
|
+
"batch_size": 2,
|
|
23
|
+
"gradient_accumulation_steps": 16,
|
|
24
|
+
"learning_rate": 0.0003,
|
|
25
|
+
"weight_decay": 0.1,
|
|
26
|
+
"warmup_steps": 2000,
|
|
27
|
+
"min_lr_ratio": 0.1,
|
|
28
|
+
"gradient_clip": 1.0,
|
|
29
|
+
"bf16": true,
|
|
30
|
+
"checkpoint_interval_steps": 500,
|
|
31
|
+
"checkpoint_interval_seconds": 1800,
|
|
32
|
+
"keep_checkpoints": 3,
|
|
33
|
+
"log_interval_steps": 10,
|
|
34
|
+
"seed": 1337,
|
|
35
|
+
"finetune_steps": 100,
|
|
36
|
+
"rl_steps": 0,
|
|
37
|
+
"nproc_per_node": 1
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Relative paths are resolved beside the spec. `max_chars` is a deterministic
|
|
42
|
+
upper bound across the sorted corpus shards. Omit `corpus_path` only for the
|
|
43
|
+
small prompt/example smoke corpus.
|
|
44
|
+
|
|
45
|
+
Run the preflight, then use one stable, absolute run directory from the first
|
|
46
|
+
launch onward:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
tt doctor tunedtensor.json
|
|
50
|
+
tt pipeline run \
|
|
51
|
+
--spec /absolute/project/tunedtensor.json \
|
|
52
|
+
--resume /absolute/project/.tuned-tensor/foundation-runs/experiment-1
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--resume` also starts a missing directory. On restart, verified completed
|
|
56
|
+
stages are skipped. An interrupted pretrain stage restores the newest complete
|
|
57
|
+
checkpoint with matching configuration. Changing model, optimizer, tokenizer,
|
|
58
|
+
data manifest, or schedule settings requires a new run directory.
|
|
59
|
+
|
|
60
|
+
Recovery data is stored under `pretrain/recovery/`:
|
|
61
|
+
|
|
62
|
+
- `pretrain-tokens.bin` and its source/tokenizer manifest;
|
|
63
|
+
- `training-metrics.jsonl` heartbeats;
|
|
64
|
+
- `checkpoints/step-*` atomic rolling checkpoints.
|
|
65
|
+
|
|
66
|
+
Each checkpoint contains model weights, AdamW and scheduler state, CPU and CUDA
|
|
67
|
+
RNG state, completed optimizer step, samples consumed, token count, and loss.
|
|
68
|
+
SIGINT or SIGTERM requests a step-boundary checkpoint. The CLI allows up to two
|
|
69
|
+
minutes for that checkpoint before forcibly stopping the process. SIGKILL or a
|
|
70
|
+
power loss falls back to the most recent periodic checkpoint.
|
|
71
|
+
|
|
72
|
+
## User service
|
|
73
|
+
|
|
74
|
+
For unattended operation, run the resumable command under a user systemd
|
|
75
|
+
service. Replace every path with an absolute path and use the installed `tt`
|
|
76
|
+
path reported by `command -v tt`:
|
|
77
|
+
|
|
78
|
+
```ini
|
|
79
|
+
[Unit]
|
|
80
|
+
Description=Tuned Tensor foundation pretraining
|
|
81
|
+
After=network-online.target
|
|
82
|
+
|
|
83
|
+
[Service]
|
|
84
|
+
Type=simple
|
|
85
|
+
WorkingDirectory=/absolute/project
|
|
86
|
+
ExecStart=/absolute/path/to/tt pipeline run --spec /absolute/project/tunedtensor.json --resume /absolute/project/.tuned-tensor/foundation-runs/experiment-1
|
|
87
|
+
Restart=on-failure
|
|
88
|
+
RestartSec=30
|
|
89
|
+
TimeoutStopSec=130
|
|
90
|
+
KillSignal=SIGTERM
|
|
91
|
+
|
|
92
|
+
[Install]
|
|
93
|
+
WantedBy=default.target
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Save it as `~/.config/systemd/user/tt-foundation.service`, then run:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
systemctl --user daemon-reload
|
|
100
|
+
systemctl --user enable --now tt-foundation.service
|
|
101
|
+
journalctl --user -fu tt-foundation.service
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Enable user lingering if the job must survive logout. Use a UPS for power-loss
|
|
105
|
+
protection, and put `checkpoint_backup_dir` on a different physical disk or
|
|
106
|
+
remote mounted filesystem. Before a multi-day run, complete a full-load soak,
|
|
107
|
+
stop the service during pretraining, and verify the journal reports a resumed
|
|
108
|
+
checkpoint after restart.
|
package/package.json
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
from safetensors.torch import load_file, save_file
|
|
14
|
+
|
|
15
|
+
from common import ensure_private_directory, write_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sha256(path: Path) -> str:
|
|
19
|
+
digest = hashlib.sha256()
|
|
20
|
+
with path.open("rb") as source:
|
|
21
|
+
while chunk := source.read(1_048_576):
|
|
22
|
+
digest.update(chunk)
|
|
23
|
+
return digest.hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ResumeState:
|
|
28
|
+
step: int
|
|
29
|
+
samples_consumed: int
|
|
30
|
+
tokens_seen: int
|
|
31
|
+
last_loss: float
|
|
32
|
+
path: Path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CheckpointManager:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
output_dir: str | Path,
|
|
39
|
+
config_fingerprint: str,
|
|
40
|
+
*,
|
|
41
|
+
keep: int = 3,
|
|
42
|
+
backup_dir: str | Path | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.root = ensure_private_directory(Path(output_dir) / "checkpoints").resolve()
|
|
45
|
+
self.config_fingerprint = config_fingerprint
|
|
46
|
+
self.keep = max(1, int(keep))
|
|
47
|
+
self.backup_dir = Path(backup_dir).expanduser().resolve() if backup_dir else None
|
|
48
|
+
if self.backup_dir is not None and (
|
|
49
|
+
self.backup_dir == self.root or self.root in self.backup_dir.parents
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("checkpoint_backup_dir must be outside the primary checkpoints directory.")
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _name(step: int) -> str:
|
|
55
|
+
return f"step-{step:09d}"
|
|
56
|
+
|
|
57
|
+
def _candidates(self, root: Path | None = None) -> list[Path]:
|
|
58
|
+
candidate_root = root or self.root
|
|
59
|
+
return sorted(
|
|
60
|
+
(path for path in candidate_root.glob("step-*") if path.is_dir()),
|
|
61
|
+
key=lambda path: path.name,
|
|
62
|
+
reverse=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _resume_candidates(self) -> list[Path]:
|
|
66
|
+
ranked = [(path, 1) for path in self._candidates()]
|
|
67
|
+
if self.backup_dir is not None and self.backup_dir.is_dir():
|
|
68
|
+
ranked.extend((path, 0) for path in self._candidates(self.backup_dir))
|
|
69
|
+
return [
|
|
70
|
+
path
|
|
71
|
+
for path, _priority in sorted(
|
|
72
|
+
ranked,
|
|
73
|
+
key=lambda candidate: (candidate[0].name, candidate[1]),
|
|
74
|
+
reverse=True,
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def latest(self) -> Path | None:
|
|
79
|
+
mismatched = False
|
|
80
|
+
corrupt = False
|
|
81
|
+
for path in self._resume_candidates():
|
|
82
|
+
try:
|
|
83
|
+
metadata = json.loads((path / "metadata.json").read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, json.JSONDecodeError):
|
|
85
|
+
corrupt = True
|
|
86
|
+
continue
|
|
87
|
+
if not isinstance(metadata, dict) or metadata.get("complete") is not True:
|
|
88
|
+
corrupt = True
|
|
89
|
+
continue
|
|
90
|
+
if metadata.get("config_fingerprint") != self.config_fingerprint:
|
|
91
|
+
mismatched = True
|
|
92
|
+
continue
|
|
93
|
+
model_path = path / "model.safetensors"
|
|
94
|
+
state_path = path / "training-state.pt"
|
|
95
|
+
if not model_path.is_file() or not state_path.is_file():
|
|
96
|
+
corrupt = True
|
|
97
|
+
continue
|
|
98
|
+
files = metadata.get("files")
|
|
99
|
+
if not isinstance(files, dict):
|
|
100
|
+
corrupt = True
|
|
101
|
+
continue
|
|
102
|
+
valid = True
|
|
103
|
+
for name, candidate in [
|
|
104
|
+
("model.safetensors", model_path),
|
|
105
|
+
("training-state.pt", state_path),
|
|
106
|
+
]:
|
|
107
|
+
expected = files.get(name)
|
|
108
|
+
if (
|
|
109
|
+
not isinstance(expected, dict)
|
|
110
|
+
or candidate.stat().st_size != expected.get("size")
|
|
111
|
+
or _sha256(candidate) != expected.get("sha256")
|
|
112
|
+
):
|
|
113
|
+
valid = False
|
|
114
|
+
break
|
|
115
|
+
if valid:
|
|
116
|
+
return path
|
|
117
|
+
corrupt = True
|
|
118
|
+
if mismatched:
|
|
119
|
+
raise ValueError("Existing foundation checkpoints do not match the current training configuration.")
|
|
120
|
+
if corrupt:
|
|
121
|
+
raise ValueError("No intact foundation checkpoint remains for the current training configuration.")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def load(
|
|
125
|
+
self,
|
|
126
|
+
model: torch.nn.Module,
|
|
127
|
+
optimizer: torch.optim.Optimizer,
|
|
128
|
+
scheduler: torch.optim.lr_scheduler.LRScheduler,
|
|
129
|
+
device: torch.device,
|
|
130
|
+
) -> ResumeState | None:
|
|
131
|
+
path = self.latest()
|
|
132
|
+
if path is None:
|
|
133
|
+
return None
|
|
134
|
+
model.load_state_dict(load_file(str(path / "model.safetensors"), device=str(device)))
|
|
135
|
+
state = torch.load(path / "training-state.pt", map_location=device, weights_only=True)
|
|
136
|
+
optimizer.load_state_dict(state["optimizer"])
|
|
137
|
+
scheduler.load_state_dict(state["scheduler"])
|
|
138
|
+
torch.set_rng_state(state["cpu_rng"].cpu())
|
|
139
|
+
if device.type == "cuda" and state.get("cuda_rng"):
|
|
140
|
+
torch.cuda.set_rng_state_all([value.cpu() for value in state["cuda_rng"]])
|
|
141
|
+
return ResumeState(
|
|
142
|
+
step=int(state["step"]),
|
|
143
|
+
samples_consumed=int(state["samples_consumed"]),
|
|
144
|
+
tokens_seen=int(state["tokens_seen"]),
|
|
145
|
+
last_loss=float(state.get("last_loss", 0.0)),
|
|
146
|
+
path=path,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def save(
|
|
150
|
+
self,
|
|
151
|
+
model: torch.nn.Module,
|
|
152
|
+
optimizer: torch.optim.Optimizer,
|
|
153
|
+
scheduler: torch.optim.lr_scheduler.LRScheduler,
|
|
154
|
+
*,
|
|
155
|
+
step: int,
|
|
156
|
+
samples_consumed: int,
|
|
157
|
+
tokens_seen: int,
|
|
158
|
+
last_loss: float,
|
|
159
|
+
reason: str,
|
|
160
|
+
) -> Path:
|
|
161
|
+
name = self._name(step)
|
|
162
|
+
final = self.root / name
|
|
163
|
+
if final.exists() and self.latest() == final:
|
|
164
|
+
self._backup(final)
|
|
165
|
+
return final
|
|
166
|
+
temporary = Path(tempfile.mkdtemp(prefix=f".{name}.", dir=self.root))
|
|
167
|
+
temporary.chmod(0o700)
|
|
168
|
+
try:
|
|
169
|
+
tensors = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()}
|
|
170
|
+
model_path = temporary / "model.safetensors"
|
|
171
|
+
save_file(tensors, str(model_path))
|
|
172
|
+
model_path.chmod(0o600)
|
|
173
|
+
state_path = temporary / "training-state.pt"
|
|
174
|
+
torch.save({
|
|
175
|
+
"step": int(step),
|
|
176
|
+
"samples_consumed": int(samples_consumed),
|
|
177
|
+
"tokens_seen": int(tokens_seen),
|
|
178
|
+
"last_loss": float(last_loss),
|
|
179
|
+
"optimizer": optimizer.state_dict(),
|
|
180
|
+
"scheduler": scheduler.state_dict(),
|
|
181
|
+
"cpu_rng": torch.get_rng_state(),
|
|
182
|
+
"cuda_rng": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
|
|
183
|
+
}, state_path)
|
|
184
|
+
state_path.chmod(0o600)
|
|
185
|
+
write_json(temporary / "metadata.json", {
|
|
186
|
+
"complete": True,
|
|
187
|
+
"config_fingerprint": self.config_fingerprint,
|
|
188
|
+
"step": int(step),
|
|
189
|
+
"samples_consumed": int(samples_consumed),
|
|
190
|
+
"tokens_seen": int(tokens_seen),
|
|
191
|
+
"reason": reason,
|
|
192
|
+
"files": {
|
|
193
|
+
"model.safetensors": {
|
|
194
|
+
"size": model_path.stat().st_size,
|
|
195
|
+
"sha256": _sha256(model_path),
|
|
196
|
+
},
|
|
197
|
+
"training-state.pt": {
|
|
198
|
+
"size": state_path.stat().st_size,
|
|
199
|
+
"sha256": _sha256(state_path),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
if final.exists():
|
|
204
|
+
shutil.rmtree(final)
|
|
205
|
+
os.replace(temporary, final)
|
|
206
|
+
self._prune()
|
|
207
|
+
self._backup(final)
|
|
208
|
+
return final
|
|
209
|
+
finally:
|
|
210
|
+
if temporary.exists():
|
|
211
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
212
|
+
|
|
213
|
+
def _prune(self) -> None:
|
|
214
|
+
for stale in self._candidates()[self.keep:]:
|
|
215
|
+
shutil.rmtree(stale)
|
|
216
|
+
|
|
217
|
+
def _backup(self, checkpoint: Path) -> None:
|
|
218
|
+
if self.backup_dir is None:
|
|
219
|
+
return
|
|
220
|
+
ensure_private_directory(self.backup_dir)
|
|
221
|
+
destination = self.backup_dir / checkpoint.name
|
|
222
|
+
temporary = self.backup_dir / f".{checkpoint.name}.{os.getpid()}.tmp"
|
|
223
|
+
if temporary.exists():
|
|
224
|
+
shutil.rmtree(temporary)
|
|
225
|
+
shutil.copytree(checkpoint, temporary)
|
|
226
|
+
if destination.exists():
|
|
227
|
+
shutil.rmtree(destination)
|
|
228
|
+
os.replace(temporary, destination)
|
|
229
|
+
retained = sorted(
|
|
230
|
+
(path for path in self.backup_dir.glob("step-*") if path.is_dir()),
|
|
231
|
+
key=lambda path: path.name,
|
|
232
|
+
reverse=True,
|
|
233
|
+
)
|
|
234
|
+
for stale in retained[self.keep:]:
|
|
235
|
+
shutil.rmtree(stale)
|
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
|
|
@@ -29,9 +31,35 @@ def load_config(argv: list[str] | None = None) -> dict[str, Any]:
|
|
|
29
31
|
def write_json(path: str | Path, value: Any) -> None:
|
|
30
32
|
destination = Path(path)
|
|
31
33
|
ensure_private_directory(destination.parent)
|
|
32
|
-
|
|
34
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
35
|
+
dir=destination.parent,
|
|
36
|
+
prefix=f".{destination.name}.",
|
|
37
|
+
suffix=".tmp",
|
|
38
|
+
)
|
|
39
|
+
temporary = Path(temporary_name)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
42
|
+
os.fchmod(output.fileno(), 0o600)
|
|
43
|
+
output.write(json.dumps(value, indent=2) + "\n")
|
|
44
|
+
output.flush()
|
|
45
|
+
os.fsync(output.fileno())
|
|
46
|
+
os.replace(temporary, destination)
|
|
33
47
|
make_private_file(destination)
|
|
34
|
-
|
|
48
|
+
finally:
|
|
49
|
+
temporary.unlink(missing_ok=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def append_jsonl(path: str | Path, value: Any) -> None:
|
|
53
|
+
destination = Path(path)
|
|
54
|
+
ensure_private_directory(destination.parent)
|
|
55
|
+
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
56
|
+
try:
|
|
57
|
+
payload = (json.dumps(value, separators=(",", ":")) + "\n").encode("utf-8")
|
|
58
|
+
os.write(descriptor, payload)
|
|
59
|
+
os.fsync(descriptor)
|
|
60
|
+
finally:
|
|
61
|
+
os.close(descriptor)
|
|
62
|
+
make_private_file(destination)
|
|
35
63
|
|
|
36
64
|
|
|
37
65
|
def require_cuda(action: str) -> None:
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
4
7
|
import re
|
|
5
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Iterable, Iterator
|
|
6
9
|
|
|
7
10
|
from tokenizers import Tokenizer
|
|
8
11
|
from tokenizers import models as tokenizer_models
|
|
@@ -18,7 +21,7 @@ SPECIAL_TOKENS = [UNK, PAD, SYSTEM, USER, ASSISTANT, END]
|
|
|
18
21
|
IGNORE_INDEX = -100
|
|
19
22
|
|
|
20
23
|
|
|
21
|
-
def train_tokenizer(corpus: str, vocab_size: int) -> Tokenizer:
|
|
24
|
+
def train_tokenizer(corpus: str | Iterable[str], vocab_size: int) -> Tokenizer:
|
|
22
25
|
tokenizer = Tokenizer(tokenizer_models.BPE(unk_token=UNK))
|
|
23
26
|
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
|
|
24
27
|
trainer = trainers.BpeTrainer(
|
|
@@ -26,10 +29,100 @@ def train_tokenizer(corpus: str, vocab_size: int) -> Tokenizer:
|
|
|
26
29
|
special_tokens=SPECIAL_TOKENS,
|
|
27
30
|
min_frequency=1,
|
|
28
31
|
)
|
|
29
|
-
tokenizer.train_from_iterator([corpus], trainer=trainer)
|
|
32
|
+
tokenizer.train_from_iterator([corpus] if isinstance(corpus, str) else corpus, trainer=trainer)
|
|
30
33
|
return tokenizer
|
|
31
34
|
|
|
32
35
|
|
|
36
|
+
def corpus_files(path: str | Path) -> list[Path]:
|
|
37
|
+
unresolved = Path(path).expanduser()
|
|
38
|
+
if unresolved.is_symlink():
|
|
39
|
+
raise ValueError(f"Foundation corpus must not be a symbolic link: {unresolved}")
|
|
40
|
+
source = unresolved.resolve()
|
|
41
|
+
if source.is_file():
|
|
42
|
+
files = [source]
|
|
43
|
+
elif source.is_dir():
|
|
44
|
+
files = sorted(
|
|
45
|
+
candidate for candidate in source.rglob("*")
|
|
46
|
+
if candidate.is_file() and candidate.suffix.lower() in {".txt", ".jsonl"}
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"Foundation corpus path does not exist: {source}")
|
|
50
|
+
if not files:
|
|
51
|
+
raise ValueError(f"Foundation corpus has no .txt or .jsonl files: {source}")
|
|
52
|
+
for candidate in files:
|
|
53
|
+
if candidate.is_symlink():
|
|
54
|
+
raise ValueError(f"Foundation corpus must not contain symbolic links: {candidate}")
|
|
55
|
+
return files
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def corpus_manifest(path: str | Path) -> list[dict[str, int | str]]:
|
|
59
|
+
manifest: list[dict[str, int | str]] = []
|
|
60
|
+
for candidate in corpus_files(path):
|
|
61
|
+
info = candidate.stat()
|
|
62
|
+
manifest.append({
|
|
63
|
+
"path": str(candidate),
|
|
64
|
+
"size": int(info.st_size),
|
|
65
|
+
"mtime_ns": int(info.st_mtime_ns),
|
|
66
|
+
})
|
|
67
|
+
return manifest
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def iter_corpus_documents(path: str | Path, max_chars: int | None = None) -> Iterator[str]:
|
|
71
|
+
remaining = max_chars if max_chars and max_chars > 0 else None
|
|
72
|
+
for candidate in corpus_files(path):
|
|
73
|
+
if remaining is not None and remaining <= 0:
|
|
74
|
+
return
|
|
75
|
+
if candidate.suffix.lower() == ".jsonl":
|
|
76
|
+
with candidate.open("r", encoding="utf-8") as source:
|
|
77
|
+
for line_number, line in enumerate(source, start=1):
|
|
78
|
+
if remaining is not None and remaining <= 0:
|
|
79
|
+
return
|
|
80
|
+
if not line.strip():
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
row = json.loads(line)
|
|
84
|
+
except json.JSONDecodeError as error:
|
|
85
|
+
raise ValueError(f"Invalid JSONL at {candidate}:{line_number}: {error.msg}") from error
|
|
86
|
+
if not isinstance(row, dict) or not isinstance(row.get("text"), str):
|
|
87
|
+
raise ValueError(f'Foundation JSONL rows require a string "text" field: {candidate}:{line_number}')
|
|
88
|
+
text = row["text"]
|
|
89
|
+
if remaining is not None:
|
|
90
|
+
text = text[:remaining]
|
|
91
|
+
remaining -= len(text)
|
|
92
|
+
if text:
|
|
93
|
+
yield text
|
|
94
|
+
else:
|
|
95
|
+
with candidate.open("r", encoding="utf-8") as source:
|
|
96
|
+
while True:
|
|
97
|
+
requested = min(1_048_576, remaining) if remaining is not None else 1_048_576
|
|
98
|
+
if requested <= 0:
|
|
99
|
+
return
|
|
100
|
+
text = source.read(requested)
|
|
101
|
+
if not text:
|
|
102
|
+
break
|
|
103
|
+
if remaining is not None:
|
|
104
|
+
remaining -= len(text)
|
|
105
|
+
yield text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def instruction_corpus_documents(system_prompt: str, documents: Iterable[str]) -> Iterator[str]:
|
|
109
|
+
instruction = system_prompt.strip()
|
|
110
|
+
if instruction:
|
|
111
|
+
yield instruction
|
|
112
|
+
yield from documents
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def corpus_documents_hash(documents: Iterable[str]) -> tuple[str, int]:
|
|
116
|
+
digest = hashlib.sha256()
|
|
117
|
+
chars = 0
|
|
118
|
+
for document in documents:
|
|
119
|
+
encoded = document.encode("utf-8")
|
|
120
|
+
digest.update(len(encoded).to_bytes(8, "little"))
|
|
121
|
+
digest.update(encoded)
|
|
122
|
+
chars += len(document)
|
|
123
|
+
return digest.hexdigest(), chars
|
|
124
|
+
|
|
125
|
+
|
|
33
126
|
def smoke_corpus(examples: list[dict[str, str]], max_chars: int, extra: str = "") -> str:
|
|
34
127
|
chunks = [extra, *[example.get("input", "") for example in examples], *[example.get("output", "") for example in examples]]
|
|
35
128
|
seed = "\n".join(part for part in chunks if part).strip() or "the quick brown fox jumps over the lazy dog"
|
|
@@ -55,6 +148,52 @@ def encode_ids(tokenizer: Tokenizer, text: str) -> list[int]:
|
|
|
55
148
|
return list(tokenizer.encode(text).ids)
|
|
56
149
|
|
|
57
150
|
|
|
151
|
+
def encode_prompt_ids(
|
|
152
|
+
tokenizer: Tokenizer,
|
|
153
|
+
system_prompt: str,
|
|
154
|
+
user: str,
|
|
155
|
+
max_tokens: int,
|
|
156
|
+
) -> list[int]:
|
|
157
|
+
"""Keep the complete spec instruction and trim only user content."""
|
|
158
|
+
full_ids = encode_ids(tokenizer, format_prompt(system_prompt, user))
|
|
159
|
+
if len(full_ids) <= max_tokens:
|
|
160
|
+
return full_ids
|
|
161
|
+
system = system_prompt.strip() or "You are a helpful assistant."
|
|
162
|
+
prefix_ids = encode_ids(tokenizer, f"{SYSTEM}{system}{END}{USER}")
|
|
163
|
+
suffix_ids = encode_ids(tokenizer, f"{END}{ASSISTANT}")
|
|
164
|
+
if len(prefix_ids) + len(suffix_ids) > max_tokens:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"Compiled spec instruction exceeds the foundation model context; "
|
|
167
|
+
"increase foundation.sequence_length or shorten the spec instruction."
|
|
168
|
+
)
|
|
169
|
+
user_ids = encode_ids(tokenizer, user)
|
|
170
|
+
user_budget = max_tokens - len(prefix_ids) - len(suffix_ids)
|
|
171
|
+
retained_user_ids = user_ids[-user_budget:] if user_budget > 0 else []
|
|
172
|
+
return prefix_ids + retained_user_ids + suffix_ids
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def encode_generation_prompt(
|
|
176
|
+
tokenizer: Tokenizer,
|
|
177
|
+
system_prompt: str,
|
|
178
|
+
user: str,
|
|
179
|
+
context_length: int,
|
|
180
|
+
requested_completion_tokens: int,
|
|
181
|
+
) -> tuple[list[int], int]:
|
|
182
|
+
"""Reserve completion space without rejecting an instruction that can fit."""
|
|
183
|
+
completion_tokens = min(requested_completion_tokens, max(1, context_length // 2))
|
|
184
|
+
try:
|
|
185
|
+
prompt_ids = encode_prompt_ids(
|
|
186
|
+
tokenizer,
|
|
187
|
+
system_prompt,
|
|
188
|
+
user,
|
|
189
|
+
context_length - completion_tokens,
|
|
190
|
+
)
|
|
191
|
+
except ValueError:
|
|
192
|
+
prompt_ids = encode_prompt_ids(tokenizer, system_prompt, user, context_length - 1)
|
|
193
|
+
completion_tokens = context_length - len(prompt_ids)
|
|
194
|
+
return prompt_ids, completion_tokens
|
|
195
|
+
|
|
196
|
+
|
|
58
197
|
def decode_ids(tokenizer: Tokenizer, ids: list[int]) -> str:
|
|
59
198
|
return tokenizer.decode(ids, skip_special_tokens=False)
|
|
60
199
|
|
|
@@ -82,9 +221,18 @@ def encode_sft_example(
|
|
|
82
221
|
full_ids = encode_ids(tokenizer, format_chat(system_prompt, user, assistant))
|
|
83
222
|
model_tokens = sequence_length + 1
|
|
84
223
|
if len(full_ids) > model_tokens:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
224
|
+
assistant_ids = encode_ids(tokenizer, f"{assistant}{END}")
|
|
225
|
+
reserved_assistant_tokens = min(2, len(assistant_ids))
|
|
226
|
+
prefix_ids = encode_prompt_ids(
|
|
227
|
+
tokenizer,
|
|
228
|
+
system_prompt,
|
|
229
|
+
user,
|
|
230
|
+
model_tokens - reserved_assistant_tokens,
|
|
231
|
+
)
|
|
232
|
+
assistant_budget = model_tokens - len(prefix_ids)
|
|
233
|
+
if len(assistant_ids) > assistant_budget:
|
|
234
|
+
assistant_ids = assistant_ids[:assistant_budget - 1] + assistant_ids[-1:]
|
|
235
|
+
full_ids = prefix_ids + assistant_ids
|
|
88
236
|
input_ids = pad_or_trim(full_ids[:-1], sequence_length, pad_id)
|
|
89
237
|
target_ids = full_ids[1:]
|
|
90
238
|
first_assistant_target = max(0, min(len(prefix_ids) - 1, len(target_ids)))
|