@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.
@@ -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.0",
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.0.0",
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)