@tuned-tensor/cli 0.13.1-beta.0 → 0.13.1-beta.2
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 +31 -12
- package/dist/{chunk-YJYCGSHJ.js → chunk-2P2RL23O.js} +144 -6
- package/dist/chunk-2P2RL23O.js.map +1 -0
- package/dist/index.js +548 -43
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +77 -9
- package/dist/local-runtime.js.map +1 -1
- package/docs/local-runtime/architecture.md +24 -0
- package/examples/local-runtime/foundation-spec.json +31 -0
- package/package.json +6 -3
- package/training/foundation/pyproject.toml +24 -0
- package/training/foundation/src/common.py +41 -0
- package/training/foundation/src/data.py +135 -0
- package/training/foundation/src/evaluate.py +115 -0
- package/training/foundation/src/finetune.py +96 -0
- package/training/foundation/src/model.py +157 -0
- package/training/foundation/src/pretrain.py +66 -0
- package/training/foundation/src/rl.py +121 -0
- package/training/foundation/src/train_tokenizer.py +26 -0
- package/training/foundation/uv.lock +922 -0
- package/dist/chunk-YJYCGSHJ.js.map +0 -1
|
@@ -40,6 +40,30 @@ The first version intentionally excludes:
|
|
|
40
40
|
The next model or method should arrive with its own locked dependencies,
|
|
41
41
|
resource defaults, data contract, and real CUDA acceptance test.
|
|
42
42
|
|
|
43
|
+
TT Local currently ships two such methods:
|
|
44
|
+
|
|
45
|
+
- **Adapter** (`training/local-runner`): certified Transformers checkpoints and
|
|
46
|
+
PEFT LoRA SFT. Driven by `tt run` and adapter pipelines.
|
|
47
|
+
- **Foundation** (`training/foundation`): a from-scratch BPE tokenizer and GPT
|
|
48
|
+
with no Transformers/PEFT. Driven only by `tt pipeline` from a foundation
|
|
49
|
+
`tunedtensor.json`. Pretrain, SFT, and RL require CUDA. Tokenizer training
|
|
50
|
+
and the CPU unit tests do not. The current runtime is single-process and
|
|
51
|
+
rejects `nproc_per_node` values above 1 and unsupported steps such as
|
|
52
|
+
`compare` before creating run artifacts.
|
|
53
|
+
|
|
54
|
+
The foundation engine is a readable lifecycle baseline inspired by nanochat,
|
|
55
|
+
not a port of nanochat's data, distributed, optimizer, or compute-optimal
|
|
56
|
+
training stack. Its bounded pretraining corpus repeats the foundation spec's
|
|
57
|
+
prompt and examples, and chat evaluation reuses those examples. RL uses a
|
|
58
|
+
single seeded on-policy sample per step with a sparse last-number exact reward
|
|
59
|
+
and requires every expected output to contain a number. BPB is normalized over
|
|
60
|
+
decoded target bytes from each evaluated window, but the corpus remains
|
|
61
|
+
example-derived. The resulting metrics prove stage wiring and can demonstrate
|
|
62
|
+
deliberate overfitting; they do not measure held-out model capability.
|
|
63
|
+
|
|
64
|
+
Do not mix those lockfiles. Each engine hashes its own `pyproject.toml` and
|
|
65
|
+
`uv.lock` into a distinct cache environment.
|
|
66
|
+
|
|
43
67
|
## Python boundary
|
|
44
68
|
|
|
45
69
|
Node owns input validation, state, process lifecycle, manifests, and reports.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"engine": "foundation",
|
|
3
|
+
"name": "Foundation smoke chat",
|
|
4
|
+
"description": "Tiny from-scratch tokenizer, pretrain, chat SFT, and eval.",
|
|
5
|
+
"system_prompt": "You are a helpful assistant.",
|
|
6
|
+
"guidelines": [
|
|
7
|
+
"Answer the user request directly."
|
|
8
|
+
],
|
|
9
|
+
"constraints": [],
|
|
10
|
+
"examples": [
|
|
11
|
+
{
|
|
12
|
+
"input": "Say hello in one sentence.",
|
|
13
|
+
"output": "Hello — how can I help you today?"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"input": "What is 2 + 2?",
|
|
17
|
+
"output": "2 + 2 = 4."
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"foundation": {
|
|
21
|
+
"vocab_size": 256,
|
|
22
|
+
"max_chars": 20000,
|
|
23
|
+
"depth": 2,
|
|
24
|
+
"pretrain_steps": 2,
|
|
25
|
+
"finetune_steps": 2,
|
|
26
|
+
"rl_steps": 0,
|
|
27
|
+
"batch_size": 2,
|
|
28
|
+
"sequence_length": 64,
|
|
29
|
+
"nproc_per_node": 1
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuned-tensor/cli",
|
|
3
|
-
"version": "0.13.1-beta.
|
|
3
|
+
"version": "0.13.1-beta.2",
|
|
4
4
|
"description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test": "npm run test:cli && npm run test:local-runtime && npm run test:python",
|
|
18
18
|
"test:cli": "vitest run",
|
|
19
19
|
"test:local-runtime": "node --import tsx --test \"test/local-runtime/**/*.test.ts\"",
|
|
20
|
-
"test:python": "python3 -m unittest discover -s training/local-runner/tests -p 'test_*.py'",
|
|
20
|
+
"test:python": "python3 -m unittest discover -s training/local-runner/tests -p 'test_*.py' && uv run --frozen --project training/foundation python -m unittest discover -s training/foundation/tests -p 'test_*.py'",
|
|
21
21
|
"test:watch": "vitest"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"training/local-runner/pyproject.toml",
|
|
26
26
|
"training/local-runner/uv.lock",
|
|
27
27
|
"training/local-runner/src/*.py",
|
|
28
|
+
"training/foundation/pyproject.toml",
|
|
29
|
+
"training/foundation/uv.lock",
|
|
30
|
+
"training/foundation/src/*.py",
|
|
28
31
|
"docs/local-runtime",
|
|
29
32
|
"examples/local-runtime/*.json",
|
|
30
33
|
"examples/local-runtime/*.jsonl"
|
|
@@ -44,7 +47,7 @@
|
|
|
44
47
|
"dependencies": {
|
|
45
48
|
"@earendil-works/pi-agent-core": "0.84.1",
|
|
46
49
|
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
47
|
-
"@tuned-tensor/pipeline-contract": "1.
|
|
50
|
+
"@tuned-tensor/pipeline-contract": "1.1.0",
|
|
48
51
|
"chalk": "^5.6.2",
|
|
49
52
|
"cli-table3": "^0.6.5",
|
|
50
53
|
"commander": "^14.0.3",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tuned-tensor-foundation"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"huggingface-hub>=0.36.0",
|
|
7
|
+
"numpy>=2.0.0",
|
|
8
|
+
"safetensors>=0.4.5",
|
|
9
|
+
"tokenizers>=0.21.0",
|
|
10
|
+
"torch>=2.11.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv.sources]
|
|
14
|
+
torch = [
|
|
15
|
+
{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[tool.uv.index]]
|
|
19
|
+
name = "pytorch-cu130"
|
|
20
|
+
url = "https://download.pytorch.org/whl/cu130"
|
|
21
|
+
explicit = true
|
|
22
|
+
|
|
23
|
+
[tool.uv]
|
|
24
|
+
package = false
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_private_directory(path: str | Path) -> Path:
|
|
10
|
+
destination = Path(path)
|
|
11
|
+
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
12
|
+
destination.chmod(0o700)
|
|
13
|
+
return destination
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_private_file(path: str | Path) -> Path:
|
|
17
|
+
destination = Path(path)
|
|
18
|
+
destination.chmod(0o600)
|
|
19
|
+
return destination
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(argv: list[str] | None = None) -> dict[str, Any]:
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("--config", required=True)
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
return json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_json(path: str | Path, value: Any) -> None:
|
|
30
|
+
destination = Path(path)
|
|
31
|
+
ensure_private_directory(destination.parent)
|
|
32
|
+
with destination.open("w", encoding="utf-8") as output:
|
|
33
|
+
make_private_file(destination)
|
|
34
|
+
output.write(json.dumps(value, indent=2) + "\n")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_cuda(action: str) -> None:
|
|
38
|
+
import torch
|
|
39
|
+
|
|
40
|
+
if not torch.cuda.is_available():
|
|
41
|
+
raise SystemExit(f"Foundation {action} requires CUDA.")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tokenizers import Tokenizer
|
|
8
|
+
from tokenizers import models as tokenizer_models
|
|
9
|
+
from tokenizers import pre_tokenizers, trainers
|
|
10
|
+
|
|
11
|
+
SYSTEM = "<|system|>"
|
|
12
|
+
USER = "<|user|>"
|
|
13
|
+
ASSISTANT = "<|assistant|>"
|
|
14
|
+
END = "<|end|>"
|
|
15
|
+
UNK = "<unk>"
|
|
16
|
+
PAD = "<pad>"
|
|
17
|
+
SPECIAL_TOKENS = [UNK, PAD, SYSTEM, USER, ASSISTANT, END]
|
|
18
|
+
IGNORE_INDEX = -100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def train_tokenizer(corpus: str, vocab_size: int) -> Tokenizer:
|
|
22
|
+
tokenizer = Tokenizer(tokenizer_models.BPE(unk_token=UNK))
|
|
23
|
+
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
|
|
24
|
+
trainer = trainers.BpeTrainer(
|
|
25
|
+
vocab_size=max(int(vocab_size), len(SPECIAL_TOKENS) + 32),
|
|
26
|
+
special_tokens=SPECIAL_TOKENS,
|
|
27
|
+
min_frequency=1,
|
|
28
|
+
)
|
|
29
|
+
tokenizer.train_from_iterator([corpus], trainer=trainer)
|
|
30
|
+
return tokenizer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def smoke_corpus(examples: list[dict[str, str]], max_chars: int, extra: str = "") -> str:
|
|
34
|
+
chunks = [extra, *[example.get("input", "") for example in examples], *[example.get("output", "") for example in examples]]
|
|
35
|
+
seed = "\n".join(part for part in chunks if part).strip() or "the quick brown fox jumps over the lazy dog"
|
|
36
|
+
seed = f"{seed}\n"
|
|
37
|
+
repeats = max(1, (max_chars // max(len(seed), 1)) + 1)
|
|
38
|
+
return (seed * repeats)[:max_chars]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def corpus_hash(text: str) -> str:
|
|
42
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_prompt(system_prompt: str, user: str) -> str:
|
|
46
|
+
system = system_prompt.strip() or "You are a helpful assistant."
|
|
47
|
+
return f"{SYSTEM}{system}{END}{USER}{user}{END}{ASSISTANT}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_chat(system_prompt: str, user: str, assistant: str) -> str:
|
|
51
|
+
return f"{format_prompt(system_prompt, user)}{assistant}{END}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encode_ids(tokenizer: Tokenizer, text: str) -> list[int]:
|
|
55
|
+
return list(tokenizer.encode(text).ids)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_ids(tokenizer: Tokenizer, ids: list[int]) -> str:
|
|
59
|
+
return tokenizer.decode(ids, skip_special_tokens=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decoded_byte_count(tokenizer: Tokenizer, rows: list[list[int]]) -> int:
|
|
63
|
+
"""Count decoded UTF-8 bytes across every target-token row."""
|
|
64
|
+
return sum(len(tokenizer.decode(row).encode("utf-8")) for row in rows)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pad_or_trim(ids: list[int], length: int, pad_id: int) -> list[int]:
|
|
68
|
+
if len(ids) >= length:
|
|
69
|
+
return ids[:length]
|
|
70
|
+
return ids + [pad_id] * (length - len(ids))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def encode_sft_example(
|
|
74
|
+
tokenizer: Tokenizer,
|
|
75
|
+
system_prompt: str,
|
|
76
|
+
user: str,
|
|
77
|
+
assistant: str,
|
|
78
|
+
sequence_length: int,
|
|
79
|
+
) -> tuple[list[int], list[int]]:
|
|
80
|
+
pad_id = tokenizer.token_to_id(PAD) or 0
|
|
81
|
+
prefix_ids = encode_ids(tokenizer, format_prompt(system_prompt, user))
|
|
82
|
+
full_ids = encode_ids(tokenizer, format_chat(system_prompt, user, assistant))
|
|
83
|
+
model_tokens = sequence_length + 1
|
|
84
|
+
if len(full_ids) > model_tokens:
|
|
85
|
+
overflow = len(full_ids) - model_tokens
|
|
86
|
+
prefix_ids = prefix_ids[overflow:] if overflow < len(prefix_ids) else []
|
|
87
|
+
full_ids = full_ids[-model_tokens:]
|
|
88
|
+
input_ids = pad_or_trim(full_ids[:-1], sequence_length, pad_id)
|
|
89
|
+
target_ids = full_ids[1:]
|
|
90
|
+
first_assistant_target = max(0, min(len(prefix_ids) - 1, len(target_ids)))
|
|
91
|
+
labels = [IGNORE_INDEX] * first_assistant_target + target_ids[first_assistant_target:]
|
|
92
|
+
labels = pad_or_trim(labels, sequence_length, IGNORE_INDEX)
|
|
93
|
+
return input_ids, labels
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def window_tokens(ids: list[int], sequence_length: int) -> list[list[int]]:
|
|
97
|
+
if len(ids) < sequence_length + 1:
|
|
98
|
+
pad = [ids[-1] if ids else 0] * (sequence_length + 1 - len(ids))
|
|
99
|
+
ids = ids + pad
|
|
100
|
+
windows: list[list[int]] = []
|
|
101
|
+
stride = max(1, sequence_length // 2)
|
|
102
|
+
for start in range(0, len(ids) - sequence_length, stride):
|
|
103
|
+
windows.append(ids[start:start + sequence_length + 1])
|
|
104
|
+
if len(windows) >= 256:
|
|
105
|
+
break
|
|
106
|
+
if not windows:
|
|
107
|
+
windows.append(ids[:sequence_length + 1])
|
|
108
|
+
return windows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_numeric_answer(text: str) -> float | None:
|
|
112
|
+
matches = re.findall(r"-?\d+(?:\.\d+)?", text.replace(",", ""))
|
|
113
|
+
if not matches:
|
|
114
|
+
return None
|
|
115
|
+
return float(matches[-1])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def numeric_reward(prediction: str, expected: str) -> float:
|
|
119
|
+
predicted = parse_numeric_answer(prediction)
|
|
120
|
+
target = parse_numeric_answer(expected)
|
|
121
|
+
if predicted is None or target is None:
|
|
122
|
+
return 0.0
|
|
123
|
+
return 1.0 if predicted == target else 0.0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def example_pairs(raw: list[Any]) -> list[dict[str, str]]:
|
|
127
|
+
pairs: list[dict[str, str]] = []
|
|
128
|
+
for item in raw:
|
|
129
|
+
if not isinstance(item, dict):
|
|
130
|
+
continue
|
|
131
|
+
user = str(item.get("input") or item.get("user") or "")
|
|
132
|
+
assistant = str(item.get("output") or item.get("assistant") or "")
|
|
133
|
+
if user and assistant:
|
|
134
|
+
pairs.append({"input": user, "output": assistant})
|
|
135
|
+
return pairs
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
from tokenizers import Tokenizer
|
|
8
|
+
from torch.nn import functional as F
|
|
9
|
+
|
|
10
|
+
from common import load_config, write_json
|
|
11
|
+
from data import END, decoded_byte_count, encode_ids, example_pairs, format_prompt, smoke_corpus, window_tokens
|
|
12
|
+
from model import bits_per_byte, generate, load_model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pick_device() -> torch.device:
|
|
16
|
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def evaluate_bpb(model, tokenizer: Tokenizer, corpus: str, sequence_length: int, device: torch.device) -> dict[str, float | int]:
|
|
20
|
+
windows = window_tokens(tokenizer.encode(corpus).ids, sequence_length)
|
|
21
|
+
tensor = torch.tensor(windows[:8], dtype=torch.long, device=device)
|
|
22
|
+
inputs = tensor[:, :-1]
|
|
23
|
+
labels = tensor[:, 1:]
|
|
24
|
+
model.eval()
|
|
25
|
+
with torch.no_grad():
|
|
26
|
+
logits = model(inputs)
|
|
27
|
+
nll = F.cross_entropy(logits.reshape(-1, logits.size(-1)), labels.reshape(-1), reduction="sum")
|
|
28
|
+
byte_count = max(decoded_byte_count(tokenizer, labels.detach().cpu().tolist()), 1)
|
|
29
|
+
return {
|
|
30
|
+
"nll": float(nll.item()),
|
|
31
|
+
"bpb": bits_per_byte(nll, byte_count),
|
|
32
|
+
"tokens": int(inputs.numel()),
|
|
33
|
+
"bytes": int(byte_count),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def evaluate_chat(
|
|
38
|
+
model,
|
|
39
|
+
tokenizer: Tokenizer,
|
|
40
|
+
system_prompt: str,
|
|
41
|
+
examples: list[dict[str, str]],
|
|
42
|
+
device: torch.device,
|
|
43
|
+
) -> dict[str, float | int | list[dict[str, str | bool]]]:
|
|
44
|
+
rows: list[dict[str, str | bool]] = []
|
|
45
|
+
correct = 0
|
|
46
|
+
model.eval()
|
|
47
|
+
for example in examples:
|
|
48
|
+
prompt_ids = encode_ids(tokenizer, format_prompt(system_prompt, example["input"])) or [0]
|
|
49
|
+
tokens = torch.tensor([prompt_ids], dtype=torch.long, device=device)
|
|
50
|
+
generated = generate(
|
|
51
|
+
model,
|
|
52
|
+
tokens,
|
|
53
|
+
max_new_tokens=32,
|
|
54
|
+
stop_token_id=tokenizer.token_to_id(END),
|
|
55
|
+
)[0].tolist()[len(prompt_ids):]
|
|
56
|
+
text = tokenizer.decode(generated).split(END)[0].strip()
|
|
57
|
+
matched = text == example["output"].strip()
|
|
58
|
+
correct += int(matched)
|
|
59
|
+
rows.append({"input": example["input"], "expected": example["output"], "actual": text, "match": matched})
|
|
60
|
+
return {
|
|
61
|
+
"examples": len(examples),
|
|
62
|
+
"correct": correct,
|
|
63
|
+
"accuracy": (correct / len(examples)) if examples else 0.0,
|
|
64
|
+
"predictions": rows,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_inference(model, tokenizer: Tokenizer, prompt: str, device: torch.device) -> dict[str, float | int | str]:
|
|
69
|
+
prompt_ids = encode_ids(tokenizer, prompt) or [0]
|
|
70
|
+
tokens = torch.tensor([prompt_ids[: min(16, len(prompt_ids))]], dtype=torch.long, device=device)
|
|
71
|
+
if device.type == "cuda":
|
|
72
|
+
torch.cuda.reset_peak_memory_stats(device)
|
|
73
|
+
torch.cuda.synchronize()
|
|
74
|
+
started = time.perf_counter()
|
|
75
|
+
generated = generate(model, tokens, max_new_tokens=32)
|
|
76
|
+
if device.type == "cuda":
|
|
77
|
+
torch.cuda.synchronize()
|
|
78
|
+
elapsed = max(time.perf_counter() - started, 1e-6)
|
|
79
|
+
new_tokens = max(int(generated.size(1) - tokens.size(1)), 0)
|
|
80
|
+
vram_mb = float(torch.cuda.max_memory_allocated(device) / (1024 * 1024)) if device.type == "cuda" else 0.0
|
|
81
|
+
return {
|
|
82
|
+
"tokens": new_tokens,
|
|
83
|
+
"seconds": elapsed,
|
|
84
|
+
"tokens_per_second": new_tokens / elapsed,
|
|
85
|
+
"vram_mb": vram_mb,
|
|
86
|
+
"device": str(device),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> None:
|
|
91
|
+
config = load_config(argv)
|
|
92
|
+
evaluator = str(config.get("evaluator") or "bpb")
|
|
93
|
+
device = pick_device()
|
|
94
|
+
tokenizer = Tokenizer.from_file(str(Path(config["tokenizer_dir"]) / "tokenizer.json"))
|
|
95
|
+
model = load_model(config["model_dir"], map_location=device)
|
|
96
|
+
examples = example_pairs(config.get("examples") or [])
|
|
97
|
+
sequence_length = int(config.get("sequence_length") or model.config["sequence_length"])
|
|
98
|
+
output_dir = Path(config["output_dir"])
|
|
99
|
+
metrics: dict = {"ok": True, "evaluator": evaluator, "device": str(device)}
|
|
100
|
+
if evaluator == "bpb":
|
|
101
|
+
corpus = smoke_corpus(examples, int(config.get("max_chars") or 20_000), extra=str(config.get("system_prompt") or ""))
|
|
102
|
+
metrics.update(evaluate_bpb(model, tokenizer, corpus, sequence_length, device))
|
|
103
|
+
elif evaluator == "chat":
|
|
104
|
+
metrics.update(evaluate_chat(model, tokenizer, str(config.get("system_prompt") or ""), examples, device))
|
|
105
|
+
elif evaluator == "inference":
|
|
106
|
+
prompt = examples[0]["input"] if examples else "hello"
|
|
107
|
+
metrics.update(evaluate_inference(model, tokenizer, prompt, device))
|
|
108
|
+
else:
|
|
109
|
+
raise SystemExit(f"Unknown foundation evaluator: {evaluator}")
|
|
110
|
+
write_json(output_dir / "metrics.json", metrics)
|
|
111
|
+
write_json(output_dir / "report.json", metrics)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from tokenizers import Tokenizer
|
|
7
|
+
from torch.nn import functional as F
|
|
8
|
+
|
|
9
|
+
from common import load_config, require_cuda, write_json
|
|
10
|
+
from data import IGNORE_INDEX, encode_sft_example, example_pairs
|
|
11
|
+
from model import load_model, param_count, save_model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sft_tensors(
|
|
15
|
+
tokenizer: Tokenizer,
|
|
16
|
+
system_prompt: str,
|
|
17
|
+
examples: list[dict[str, str]],
|
|
18
|
+
sequence_length: int,
|
|
19
|
+
device: torch.device,
|
|
20
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
21
|
+
inputs: list[list[int]] = []
|
|
22
|
+
labels: list[list[int]] = []
|
|
23
|
+
for example in examples:
|
|
24
|
+
input_ids, label_ids = encode_sft_example(
|
|
25
|
+
tokenizer,
|
|
26
|
+
system_prompt,
|
|
27
|
+
example["input"],
|
|
28
|
+
example["output"],
|
|
29
|
+
sequence_length,
|
|
30
|
+
)
|
|
31
|
+
inputs.append(input_ids)
|
|
32
|
+
labels.append(label_ids)
|
|
33
|
+
return (
|
|
34
|
+
torch.tensor(inputs, dtype=torch.long, device=device),
|
|
35
|
+
torch.tensor(labels, dtype=torch.long, device=device),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> None:
|
|
40
|
+
config = load_config(argv)
|
|
41
|
+
require_cuda("finetune")
|
|
42
|
+
device = torch.device("cuda")
|
|
43
|
+
tokenizer = Tokenizer.from_file(str(Path(config["tokenizer_dir"]) / "tokenizer.json"))
|
|
44
|
+
model = load_model(config["model_dir"], map_location=device)
|
|
45
|
+
examples = example_pairs(config.get("examples") or [])
|
|
46
|
+
if len(examples) < 1:
|
|
47
|
+
raise SystemExit("Foundation finetune needs at least one chat example.")
|
|
48
|
+
sequence_length = int(config.get("sequence_length") or model.config["sequence_length"])
|
|
49
|
+
inputs, labels = sft_tensors(
|
|
50
|
+
tokenizer,
|
|
51
|
+
str(config.get("system_prompt") or ""),
|
|
52
|
+
examples,
|
|
53
|
+
sequence_length,
|
|
54
|
+
device,
|
|
55
|
+
)
|
|
56
|
+
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
|
|
57
|
+
steps = max(1, int(config.get("steps") or 2))
|
|
58
|
+
batch_size = max(1, int(config.get("batch_size") or 2))
|
|
59
|
+
last_loss = 0.0
|
|
60
|
+
model.train()
|
|
61
|
+
for step in range(steps):
|
|
62
|
+
start = (step * batch_size) % inputs.size(0)
|
|
63
|
+
end = start + batch_size
|
|
64
|
+
if end <= inputs.size(0):
|
|
65
|
+
batch_inputs = inputs[start:end]
|
|
66
|
+
batch_labels = labels[start:end]
|
|
67
|
+
else:
|
|
68
|
+
indices = [(start + offset) % inputs.size(0) for offset in range(batch_size)]
|
|
69
|
+
batch_inputs = inputs[indices]
|
|
70
|
+
batch_labels = labels[indices]
|
|
71
|
+
logits = model(batch_inputs)
|
|
72
|
+
loss = F.cross_entropy(
|
|
73
|
+
logits.reshape(-1, logits.size(-1)),
|
|
74
|
+
batch_labels.reshape(-1),
|
|
75
|
+
ignore_index=IGNORE_INDEX,
|
|
76
|
+
)
|
|
77
|
+
optimizer.zero_grad(set_to_none=True)
|
|
78
|
+
loss.backward()
|
|
79
|
+
optimizer.step()
|
|
80
|
+
last_loss = float(loss.item())
|
|
81
|
+
|
|
82
|
+
output_dir = Path(config["output_dir"])
|
|
83
|
+
save_model(model, output_dir)
|
|
84
|
+
write_json(output_dir / "metrics.json", {
|
|
85
|
+
"ok": True,
|
|
86
|
+
"steps": steps,
|
|
87
|
+
"loss": last_loss,
|
|
88
|
+
"examples": inputs.size(0),
|
|
89
|
+
"parameters": param_count(model),
|
|
90
|
+
"ignore_index": IGNORE_INDEX,
|
|
91
|
+
"device": str(device),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from torch import nn
|
|
10
|
+
from safetensors.torch import load_file, save_file
|
|
11
|
+
|
|
12
|
+
from common import ensure_private_directory, make_private_file, write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def derived_width(depth: int) -> int:
|
|
16
|
+
return 32 * int(depth)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def derived_heads(depth: int) -> int:
|
|
20
|
+
width = derived_width(depth)
|
|
21
|
+
for heads in (8, 4, 2, 1):
|
|
22
|
+
if width % heads == 0:
|
|
23
|
+
return heads
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def model_config_from_depth(depth: int, vocab_size: int, sequence_length: int) -> dict[str, int]:
|
|
28
|
+
return {
|
|
29
|
+
"depth": int(depth),
|
|
30
|
+
"width": derived_width(depth),
|
|
31
|
+
"heads": derived_heads(depth),
|
|
32
|
+
"vocab_size": int(vocab_size),
|
|
33
|
+
"sequence_length": int(sequence_length),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CausalSelfAttention(nn.Module):
|
|
38
|
+
def __init__(self, width: int, heads: int) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
if width % heads != 0:
|
|
41
|
+
raise ValueError("width must be divisible by heads")
|
|
42
|
+
self.heads = heads
|
|
43
|
+
self.head_dim = width // heads
|
|
44
|
+
self.qkv = nn.Linear(width, 3 * width, bias=False)
|
|
45
|
+
self.proj = nn.Linear(width, width, bias=False)
|
|
46
|
+
|
|
47
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
48
|
+
batch, steps, width = x.shape
|
|
49
|
+
qkv = self.qkv(x).view(batch, steps, 3, self.heads, self.head_dim)
|
|
50
|
+
query, key, value = qkv.unbind(dim=2)
|
|
51
|
+
query = query.transpose(1, 2)
|
|
52
|
+
key = key.transpose(1, 2)
|
|
53
|
+
value = value.transpose(1, 2)
|
|
54
|
+
context = torch.nn.functional.scaled_dot_product_attention(
|
|
55
|
+
query, key, value, is_causal=True,
|
|
56
|
+
)
|
|
57
|
+
context = context.transpose(1, 2).contiguous().view(batch, steps, width)
|
|
58
|
+
return self.proj(context)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TransformerBlock(nn.Module):
|
|
62
|
+
def __init__(self, width: int, heads: int) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.ln1 = nn.LayerNorm(width)
|
|
65
|
+
self.attn = CausalSelfAttention(width, heads)
|
|
66
|
+
self.ln2 = nn.LayerNorm(width)
|
|
67
|
+
self.mlp = nn.Sequential(
|
|
68
|
+
nn.Linear(width, 4 * width),
|
|
69
|
+
nn.GELU(),
|
|
70
|
+
nn.Linear(4 * width, width),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
74
|
+
x = x + self.attn(self.ln1(x))
|
|
75
|
+
x = x + self.mlp(self.ln2(x))
|
|
76
|
+
return x
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FoundationGPT(nn.Module):
|
|
80
|
+
def __init__(self, config: dict[str, int]) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self.config = config
|
|
83
|
+
width = config["width"]
|
|
84
|
+
self.tok_emb = nn.Embedding(config["vocab_size"], width)
|
|
85
|
+
self.pos_emb = nn.Embedding(config["sequence_length"], width)
|
|
86
|
+
self.blocks = nn.ModuleList(
|
|
87
|
+
[TransformerBlock(width, config["heads"]) for _ in range(config["depth"])],
|
|
88
|
+
)
|
|
89
|
+
self.ln_f = nn.LayerNorm(width)
|
|
90
|
+
self.lm_head = nn.Linear(width, config["vocab_size"], bias=False)
|
|
91
|
+
self.lm_head.weight = self.tok_emb.weight
|
|
92
|
+
|
|
93
|
+
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
|
|
94
|
+
_batch, steps = input_ids.shape
|
|
95
|
+
if steps > self.config["sequence_length"]:
|
|
96
|
+
raise ValueError("sequence exceeds model sequence_length")
|
|
97
|
+
positions = torch.arange(steps, device=input_ids.device)
|
|
98
|
+
x = self.tok_emb(input_ids) + self.pos_emb(positions)
|
|
99
|
+
for block in self.blocks:
|
|
100
|
+
x = block(x)
|
|
101
|
+
return self.lm_head(self.ln_f(x))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@torch.no_grad()
|
|
105
|
+
def generate(
|
|
106
|
+
model: FoundationGPT,
|
|
107
|
+
input_ids: torch.Tensor,
|
|
108
|
+
max_new_tokens: int,
|
|
109
|
+
stop_token_id: int | None = None,
|
|
110
|
+
) -> torch.Tensor:
|
|
111
|
+
model.eval()
|
|
112
|
+
tokens = input_ids
|
|
113
|
+
limit = model.config["sequence_length"]
|
|
114
|
+
finished = torch.zeros(tokens.size(0), dtype=torch.bool, device=tokens.device)
|
|
115
|
+
for _ in range(max_new_tokens):
|
|
116
|
+
logits = model(tokens[:, -limit:])
|
|
117
|
+
next_token = logits[:, -1, :].argmax(dim=-1, keepdim=True)
|
|
118
|
+
if stop_token_id is not None:
|
|
119
|
+
next_token = torch.where(
|
|
120
|
+
finished.unsqueeze(1),
|
|
121
|
+
torch.full_like(next_token, stop_token_id),
|
|
122
|
+
next_token,
|
|
123
|
+
)
|
|
124
|
+
tokens = torch.cat([tokens, next_token], dim=1)
|
|
125
|
+
if stop_token_id is not None:
|
|
126
|
+
finished |= next_token.squeeze(1).eq(stop_token_id)
|
|
127
|
+
if bool(finished.all()):
|
|
128
|
+
break
|
|
129
|
+
return tokens
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_model(model: FoundationGPT, directory: str | Path) -> None:
|
|
133
|
+
destination = ensure_private_directory(directory)
|
|
134
|
+
tensors = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()}
|
|
135
|
+
model_path = destination / "model.safetensors"
|
|
136
|
+
save_file(tensors, str(model_path))
|
|
137
|
+
make_private_file(model_path)
|
|
138
|
+
write_json(destination / "config.json", model.config)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_model(directory: str | Path, map_location: str | torch.device = "cpu") -> FoundationGPT:
|
|
142
|
+
destination = Path(directory)
|
|
143
|
+
config: dict[str, Any] = json.loads((destination / "config.json").read_text(encoding="utf-8"))
|
|
144
|
+
model = FoundationGPT({key: int(value) for key, value in config.items()})
|
|
145
|
+
state = load_file(str(destination / "model.safetensors"))
|
|
146
|
+
model.load_state_dict(state)
|
|
147
|
+
return model.to(map_location)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def param_count(model: nn.Module) -> int:
|
|
151
|
+
return sum(parameter.numel() for parameter in model.parameters())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def bits_per_byte(nll_sum: torch.Tensor, byte_count: int) -> float:
|
|
155
|
+
if byte_count <= 0:
|
|
156
|
+
return 0.0
|
|
157
|
+
return float(nll_sum.item() / math.log(2) / byte_count)
|