@tuned-tensor/cli 0.9.0 → 0.9.1
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 +34 -0
- package/dist/chunk-DAP2Z7EZ.js +4400 -0
- package/dist/chunk-DAP2Z7EZ.js.map +1 -0
- package/dist/index.js +324 -70
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +1630 -0
- package/dist/local-runtime.js.map +1 -0
- package/docs/local-runtime/architecture.md +91 -0
- package/docs/local-runtime/spark.md +107 -0
- package/examples/local-runtime/dry-runner.json +14 -0
- package/examples/local-runtime/general-regression.jsonl +4 -0
- package/examples/local-runtime/local-runner.json +25 -0
- package/examples/local-runtime/smoke-spec.json +41 -0
- package/package.json +15 -5
- package/training/local-runner/pyproject.toml +25 -0
- package/training/local-runner/src/evaluate.py +274 -0
- package/training/local-runner/src/model_contract.py +47 -0
- package/training/local-runner/src/prefetch.py +192 -0
- package/training/local-runner/src/serve.py +272 -0
- package/training/local-runner/src/sft_data.py +97 -0
- package/training/local-runner/src/train.py +289 -0
- package/training/local-runner/uv.lock +1204 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from tempfile import TemporaryDirectory
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from evaluate import (
|
|
17
|
+
configure_hugging_face_cache,
|
|
18
|
+
import_runtime_dependencies,
|
|
19
|
+
load_text_model,
|
|
20
|
+
resolve_adapter_path,
|
|
21
|
+
sampling_kwargs,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MODEL_ARTIFACT = os.environ.get("TT_MODEL_ARTIFACT")
|
|
26
|
+
BASE_MODEL = os.environ["TT_BASE_MODEL"]
|
|
27
|
+
BASE_MODEL_REVISION = os.environ.get("TT_BASE_MODEL_REVISION")
|
|
28
|
+
MODEL_NAME = os.environ.get("TT_MODEL_NAME", "tuned-tensor-local")
|
|
29
|
+
MODEL_LOADER = os.environ.get("TT_MODEL_LOADER", "causal_lm")
|
|
30
|
+
SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
|
|
31
|
+
HOST = os.environ.get("TT_HOST", "127.0.0.1")
|
|
32
|
+
PORT = int(os.environ.get("TT_PORT", "8000"))
|
|
33
|
+
DEVICE_REQUEST = os.environ.get("TT_DEVICE", "cuda")
|
|
34
|
+
DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
|
|
35
|
+
DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0"))
|
|
36
|
+
DEFAULT_TOP_P = float(os.environ.get("TT_TOP_P", "1"))
|
|
37
|
+
MAX_REQUEST_BYTES = 2 * 1024 * 1024
|
|
38
|
+
MAX_PROMPT_CHARS = 100_000
|
|
39
|
+
MAX_PROMPT_TOKENS = 16_384
|
|
40
|
+
MAX_MESSAGES = 128
|
|
41
|
+
API_KEY = os.environ.get("TT_API_KEY", "")
|
|
42
|
+
MAX_CONCURRENT_REQUESTS = int(os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if MODEL_LOADER != "causal_lm":
|
|
46
|
+
raise ValueError("The bundled model server is text-only and requires TT_MODEL_LOADER=causal_lm")
|
|
47
|
+
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
48
|
+
import_runtime_dependencies()
|
|
49
|
+
TEMP_DIR = TemporaryDirectory(prefix="tt-local-serve-")
|
|
50
|
+
ADAPTER_PATH = (
|
|
51
|
+
resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
|
|
52
|
+
if MODEL_ARTIFACT
|
|
53
|
+
else None
|
|
54
|
+
)
|
|
55
|
+
MODEL_PAYLOAD = {
|
|
56
|
+
"base_model": BASE_MODEL,
|
|
57
|
+
"base_model_revision": BASE_MODEL_REVISION,
|
|
58
|
+
"device": DEVICE_REQUEST,
|
|
59
|
+
"model_loader": MODEL_LOADER,
|
|
60
|
+
}
|
|
61
|
+
MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
|
|
62
|
+
GENERATION_LOCK = threading.Lock()
|
|
63
|
+
REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def text_content(content: Any) -> str:
|
|
67
|
+
if isinstance(content, str):
|
|
68
|
+
return content
|
|
69
|
+
if content is None:
|
|
70
|
+
return ""
|
|
71
|
+
if not isinstance(content, list):
|
|
72
|
+
raise ValueError("Message content must be text or an array of text parts.")
|
|
73
|
+
parts: list[str] = []
|
|
74
|
+
for part in content:
|
|
75
|
+
if not isinstance(part, dict) or part.get("type") != "text":
|
|
76
|
+
raise ValueError("The bundled Qwen model server accepts text content only.")
|
|
77
|
+
text = part.get("text")
|
|
78
|
+
if not isinstance(text, str):
|
|
79
|
+
raise ValueError("Every text content part must contain a string text field.")
|
|
80
|
+
parts.append(text)
|
|
81
|
+
return "\n".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_messages(raw_messages: Any) -> list[dict[str, str]]:
|
|
85
|
+
if not isinstance(raw_messages, list) or not raw_messages:
|
|
86
|
+
raise ValueError("Request must include a non-empty messages array.")
|
|
87
|
+
if len(raw_messages) > MAX_MESSAGES:
|
|
88
|
+
raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
|
|
89
|
+
|
|
90
|
+
system_parts = [SYSTEM_PROMPT] if SYSTEM_PROMPT else []
|
|
91
|
+
conversation: list[dict[str, str]] = []
|
|
92
|
+
for raw in raw_messages:
|
|
93
|
+
if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
|
|
94
|
+
raise ValueError("Each message must have a supported role and text content.")
|
|
95
|
+
role = str(raw["role"])
|
|
96
|
+
content = text_content(raw.get("content", ""))
|
|
97
|
+
if role == "system":
|
|
98
|
+
if content.strip():
|
|
99
|
+
system_parts.append(content.strip())
|
|
100
|
+
else:
|
|
101
|
+
conversation.append({"role": role, "content": content})
|
|
102
|
+
|
|
103
|
+
if not conversation:
|
|
104
|
+
raise ValueError("Request must contain at least one non-system message.")
|
|
105
|
+
messages: list[dict[str, str]] = []
|
|
106
|
+
if system_parts:
|
|
107
|
+
# Qwen permits one leading system message. Merge the invariant owner
|
|
108
|
+
# prompt and any client context instead of triggering an implicit
|
|
109
|
+
# template fallback with duplicate system turns.
|
|
110
|
+
messages.append({"role": "system", "content": "\n\n".join(system_parts)})
|
|
111
|
+
messages.extend(conversation)
|
|
112
|
+
if sum(len(message["content"]) for message in messages) > MAX_PROMPT_CHARS:
|
|
113
|
+
raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
|
|
114
|
+
return messages
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate_text(messages: list[dict[str, str]], generation: dict[str, Any]) -> tuple[str, int, int]:
|
|
118
|
+
try:
|
|
119
|
+
prompt = TOKENIZER.apply_chat_template(
|
|
120
|
+
messages,
|
|
121
|
+
tokenize=False,
|
|
122
|
+
add_generation_prompt=True,
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
|
|
126
|
+
inputs = TOKENIZER(prompt, return_tensors="pt")
|
|
127
|
+
if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
|
|
128
|
+
raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
|
|
129
|
+
target_device = next(MODEL.parameters()).device
|
|
130
|
+
inputs = {key: value.to(target_device) for key, value in inputs.items()}
|
|
131
|
+
with GENERATION_LOCK:
|
|
132
|
+
import torch
|
|
133
|
+
|
|
134
|
+
with torch.inference_mode():
|
|
135
|
+
generated = MODEL.generate(
|
|
136
|
+
**inputs,
|
|
137
|
+
max_new_tokens=int(generation["max_new_tokens"]),
|
|
138
|
+
pad_token_id=TOKENIZER.eos_token_id,
|
|
139
|
+
**sampling_kwargs(generation),
|
|
140
|
+
)
|
|
141
|
+
prompt_tokens = int(inputs["input_ids"].shape[-1])
|
|
142
|
+
completion_tokens = generated[0][prompt_tokens:]
|
|
143
|
+
content = TOKENIZER.decode(completion_tokens, skip_special_tokens=True).strip()
|
|
144
|
+
return content, prompt_tokens, int(completion_tokens.shape[-1])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
|
|
148
|
+
number = float(default if value is None else value)
|
|
149
|
+
if not math.isfinite(number) or number < minimum or number > maximum:
|
|
150
|
+
raise ValueError(f"Generation value must be between {minimum} and {maximum}.")
|
|
151
|
+
return number
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def bounded_integer(value: Any, default: int, minimum: int, maximum: int) -> int:
|
|
155
|
+
number = bounded_number(value, default, minimum, maximum)
|
|
156
|
+
if not number.is_integer():
|
|
157
|
+
raise ValueError(f"Generation value must be an integer between {minimum} and {maximum}.")
|
|
158
|
+
return int(number)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Handler(BaseHTTPRequestHandler):
|
|
162
|
+
server_version = "TunedTensorLocalServer/0.1"
|
|
163
|
+
|
|
164
|
+
def setup(self) -> None:
|
|
165
|
+
super().setup()
|
|
166
|
+
self.connection.settimeout(30)
|
|
167
|
+
|
|
168
|
+
def authorized(self) -> bool:
|
|
169
|
+
if not API_KEY:
|
|
170
|
+
return True
|
|
171
|
+
supplied = self.headers.get("authorization", "")
|
|
172
|
+
return hmac.compare_digest(supplied, "Bearer " + API_KEY)
|
|
173
|
+
|
|
174
|
+
def send_json(self, status: int, payload: Any) -> None:
|
|
175
|
+
body = json.dumps(payload).encode("utf-8")
|
|
176
|
+
self.send_response(status)
|
|
177
|
+
self.send_header("Content-Type", "application/json")
|
|
178
|
+
self.send_header("Content-Length", str(len(body)))
|
|
179
|
+
self.end_headers()
|
|
180
|
+
self.wfile.write(body)
|
|
181
|
+
|
|
182
|
+
def do_GET(self) -> None: # noqa: N802
|
|
183
|
+
if not self.authorized():
|
|
184
|
+
self.send_json(401, {"error": {"message": "Unauthorized"}})
|
|
185
|
+
return
|
|
186
|
+
if self.path == "/health":
|
|
187
|
+
self.send_json(200, {"status": "ok", "model": MODEL_NAME, "device": DEVICE})
|
|
188
|
+
return
|
|
189
|
+
if self.path == "/v1/models":
|
|
190
|
+
self.send_json(200, {
|
|
191
|
+
"object": "list",
|
|
192
|
+
"data": [{"id": MODEL_NAME, "object": "model", "owned_by": "tuned-tensor-local"}],
|
|
193
|
+
})
|
|
194
|
+
return
|
|
195
|
+
self.send_json(404, {"error": {"message": "Not found"}})
|
|
196
|
+
|
|
197
|
+
def do_POST(self) -> None: # noqa: N802
|
|
198
|
+
if not self.authorized():
|
|
199
|
+
self.send_json(401, {"error": {"message": "Unauthorized"}})
|
|
200
|
+
return
|
|
201
|
+
if self.path not in {"/v1/chat/completions", "/chat/completions"}:
|
|
202
|
+
self.send_json(404, {"error": {"message": "Not found"}})
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
length = int(self.headers.get("content-length", "0"))
|
|
206
|
+
if length <= 0 or length > MAX_REQUEST_BYTES:
|
|
207
|
+
raise ValueError("Request body is empty or too large.")
|
|
208
|
+
body = json.loads(self.rfile.read(length))
|
|
209
|
+
if not isinstance(body, dict):
|
|
210
|
+
raise ValueError("Request body must be a JSON object.")
|
|
211
|
+
if body.get("stream"):
|
|
212
|
+
self.send_json(400, {"error": {"message": "Streaming is not supported yet."}})
|
|
213
|
+
return
|
|
214
|
+
messages = normalize_messages(body.get("messages"))
|
|
215
|
+
generation = {
|
|
216
|
+
"max_new_tokens": bounded_integer(body.get("max_tokens"), DEFAULT_MAX_TOKENS, 1, 8192),
|
|
217
|
+
"temperature": bounded_number(body.get("temperature"), DEFAULT_TEMPERATURE, 0, 5),
|
|
218
|
+
"top_p": bounded_number(body.get("top_p"), DEFAULT_TOP_P, 0, 1),
|
|
219
|
+
}
|
|
220
|
+
if not REQUEST_SLOTS.acquire(blocking=False):
|
|
221
|
+
self.send_json(429, {"error": {"message": "The local model is busy; retry shortly."}})
|
|
222
|
+
return
|
|
223
|
+
started = time.perf_counter()
|
|
224
|
+
try:
|
|
225
|
+
content, prompt_tokens, completion_tokens = generate_text(messages, generation)
|
|
226
|
+
finally:
|
|
227
|
+
REQUEST_SLOTS.release()
|
|
228
|
+
latency_ms = round((time.perf_counter() - started) * 1000)
|
|
229
|
+
self.send_json(200, {
|
|
230
|
+
"id": "chatcmpl-" + uuid.uuid4().hex,
|
|
231
|
+
"object": "chat.completion",
|
|
232
|
+
"created": int(time.time()),
|
|
233
|
+
"model": MODEL_NAME,
|
|
234
|
+
"choices": [{
|
|
235
|
+
"index": 0,
|
|
236
|
+
"message": {"role": "assistant", "content": content},
|
|
237
|
+
"finish_reason": "stop",
|
|
238
|
+
}],
|
|
239
|
+
"usage": {
|
|
240
|
+
"prompt_tokens": prompt_tokens,
|
|
241
|
+
"completion_tokens": completion_tokens,
|
|
242
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
243
|
+
},
|
|
244
|
+
"tt_local": {"latency_ms": latency_ms, "device": DEVICE},
|
|
245
|
+
})
|
|
246
|
+
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
247
|
+
self.send_json(400, {"error": {"message": str(exc)}})
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
print(f"Model server error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
|
250
|
+
self.send_json(500, {"error": {"message": "Internal model server error."}})
|
|
251
|
+
|
|
252
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
253
|
+
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> None:
|
|
257
|
+
print(f"Serving {MODEL_NAME} on http://{HOST}:{PORT}", flush=True)
|
|
258
|
+
print(f"OpenAI-compatible endpoint: http://{HOST}:{PORT}/v1/chat/completions", flush=True)
|
|
259
|
+
print(f"Device: {DEVICE}", flush=True)
|
|
260
|
+
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
261
|
+
server.daemon_threads = True
|
|
262
|
+
server.request_queue_size = max(2, MAX_CONCURRENT_REQUESTS * 2)
|
|
263
|
+
try:
|
|
264
|
+
server.serve_forever()
|
|
265
|
+
except KeyboardInterrupt:
|
|
266
|
+
print("Model server stopped.", flush=True)
|
|
267
|
+
finally:
|
|
268
|
+
server.server_close()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
IGNORE_INDEX = -100
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _token_ids(value: Any, description: str) -> list[int]:
|
|
11
|
+
if isinstance(value, Mapping):
|
|
12
|
+
value = value.get("input_ids")
|
|
13
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
14
|
+
raise TypeError(f"{description} did not return a token-id sequence")
|
|
15
|
+
token_ids = list(value)
|
|
16
|
+
if not token_ids or any(not isinstance(token_id, int) for token_id in token_ids):
|
|
17
|
+
raise ValueError(f"{description} returned empty or invalid token ids")
|
|
18
|
+
return token_ids
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_assistant_only_example(
|
|
22
|
+
tokenizer: Any,
|
|
23
|
+
messages: Any,
|
|
24
|
+
*,
|
|
25
|
+
max_length: int,
|
|
26
|
+
) -> dict[str, list[int]]:
|
|
27
|
+
"""Tokenize one chat row while computing loss only on the final assistant turn.
|
|
28
|
+
|
|
29
|
+
The entire assistant completion is reserved before the prompt is truncated.
|
|
30
|
+
Prompt truncation therefore removes the oldest prompt tokens and can never
|
|
31
|
+
silently discard the expected answer.
|
|
32
|
+
"""
|
|
33
|
+
if max_length < 2:
|
|
34
|
+
raise ValueError("max_length must be at least 2")
|
|
35
|
+
if not isinstance(messages, list) or len(messages) < 2:
|
|
36
|
+
raise ValueError("messages must contain prompt context and a final assistant turn")
|
|
37
|
+
if any(not isinstance(message, dict) for message in messages):
|
|
38
|
+
raise ValueError("every message must be an object")
|
|
39
|
+
allowed_roles = {"system", "user", "assistant", "tool"}
|
|
40
|
+
for message in messages:
|
|
41
|
+
role = message.get("role")
|
|
42
|
+
if role not in allowed_roles:
|
|
43
|
+
raise ValueError(f"unsupported message role: {role!r}")
|
|
44
|
+
if not isinstance(message.get("content"), str):
|
|
45
|
+
raise ValueError("the bundled trainer accepts text-only message content")
|
|
46
|
+
|
|
47
|
+
assistant = messages[-1]
|
|
48
|
+
if assistant.get("role") != "assistant":
|
|
49
|
+
raise ValueError("the final message must have role=assistant")
|
|
50
|
+
content = assistant.get("content")
|
|
51
|
+
if not isinstance(content, str) or not content.strip():
|
|
52
|
+
raise ValueError("the final assistant message must contain non-empty text")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
prompt_ids = _token_ids(
|
|
56
|
+
tokenizer.apply_chat_template(
|
|
57
|
+
messages[:-1],
|
|
58
|
+
tokenize=True,
|
|
59
|
+
add_generation_prompt=True,
|
|
60
|
+
return_dict=False,
|
|
61
|
+
),
|
|
62
|
+
"prompt chat template",
|
|
63
|
+
)
|
|
64
|
+
full_ids = _token_ids(
|
|
65
|
+
tokenizer.apply_chat_template(
|
|
66
|
+
messages,
|
|
67
|
+
tokenize=True,
|
|
68
|
+
add_generation_prompt=False,
|
|
69
|
+
return_dict=False,
|
|
70
|
+
),
|
|
71
|
+
"full chat template",
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
if full_ids[: len(prompt_ids)] != prompt_ids:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Qwen chat-template output is not prefix-aligned; refusing to guess the assistant loss boundary"
|
|
79
|
+
)
|
|
80
|
+
completion_ids = full_ids[len(prompt_ids) :]
|
|
81
|
+
if not completion_ids:
|
|
82
|
+
raise ValueError("Qwen chat template produced no assistant completion tokens")
|
|
83
|
+
if len(completion_ids) >= max_length:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"assistant completion requires {len(completion_ids)} tokens, but max_length={max_length}; "
|
|
86
|
+
"increase max_seq_length so the answer is not truncated"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
prompt_budget = max_length - len(completion_ids)
|
|
90
|
+
retained_prompt_ids = prompt_ids[-prompt_budget:]
|
|
91
|
+
input_ids = [*retained_prompt_ids, *completion_ids]
|
|
92
|
+
labels = [IGNORE_INDEX] * len(retained_prompt_ids) + completion_ids.copy()
|
|
93
|
+
return {
|
|
94
|
+
"input_ids": input_ids,
|
|
95
|
+
"attention_mask": [1] * len(input_ids),
|
|
96
|
+
"labels": labels,
|
|
97
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tarfile
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import TemporaryDirectory
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
from peft import LoraConfig, get_peft_model
|
|
13
|
+
from torch.nn.utils.rnn import pad_sequence
|
|
14
|
+
from torch.utils.data import Dataset
|
|
15
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
|
|
16
|
+
|
|
17
|
+
from model_contract import (
|
|
18
|
+
CERTIFIED_BASE_MODEL,
|
|
19
|
+
assert_certified_model_config,
|
|
20
|
+
)
|
|
21
|
+
from sft_data import IGNORE_INDEX, build_assistant_only_example
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
|
|
25
|
+
BASE_MODEL_DIR = Path(os.environ.get("SM_CHANNEL_BASE_MODEL", "/opt/ml/input/data/base_model"))
|
|
26
|
+
HYPERPARAMETERS_PATH = Path(
|
|
27
|
+
os.environ.get("TT_HYPERPARAMETERS_PATH", "/opt/ml/input/config/hyperparameters.json")
|
|
28
|
+
)
|
|
29
|
+
MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
|
|
30
|
+
OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_hyperparameters() -> dict[str, str]:
|
|
34
|
+
if not HYPERPARAMETERS_PATH.is_file():
|
|
35
|
+
return {}
|
|
36
|
+
raw = json.loads(HYPERPARAMETERS_PATH.read_text(encoding="utf-8"))
|
|
37
|
+
if not isinstance(raw, dict):
|
|
38
|
+
raise ValueError("Hyperparameters must be a JSON object")
|
|
39
|
+
return {str(key): str(value) for key, value in raw.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
HP = load_hyperparameters()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hp(name: str, default: str | None = None) -> str | None:
|
|
46
|
+
value = os.getenv(f"SM_HP_{name.upper()}", HP.get(name, default))
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
value = str(value).strip()
|
|
50
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
51
|
+
return value[1:-1]
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hp_int(name: str, default: int) -> int:
|
|
56
|
+
return int(hp(name, str(default)) or default)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hp_float(name: str, default: float) -> float:
|
|
60
|
+
return float(hp(name, str(default)) or default)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def model_revision_kwargs(model_source: str) -> dict[str, str]:
|
|
64
|
+
revision = hp("base_model_revision")
|
|
65
|
+
return {"revision": revision} if revision and not Path(model_source).exists() else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def assert_certified_request() -> None:
|
|
69
|
+
requested_model = hp("base_model", CERTIFIED_BASE_MODEL)
|
|
70
|
+
if requested_model != CERTIFIED_BASE_MODEL:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {requested_model!r}"
|
|
73
|
+
)
|
|
74
|
+
loader = hp("model_loader", "causal_lm")
|
|
75
|
+
if loader != "causal_lm":
|
|
76
|
+
raise ValueError(f"The bundled trainer is text-only and requires model_loader=causal_lm; got {loader!r}")
|
|
77
|
+
def require_cuda() -> dict[str, Any]:
|
|
78
|
+
if not torch.cuda.is_available():
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"TT Local bundled training requires an NVIDIA CUDA GPU. "
|
|
81
|
+
"Run this workflow on DGX Spark and verify it first with tt-local doctor."
|
|
82
|
+
)
|
|
83
|
+
device_index = torch.cuda.current_device()
|
|
84
|
+
properties = torch.cuda.get_device_properties(device_index)
|
|
85
|
+
return {
|
|
86
|
+
"device_index": device_index,
|
|
87
|
+
"device_name": properties.name,
|
|
88
|
+
"compute_capability": f"{properties.major}.{properties.minor}",
|
|
89
|
+
"bf16_supported": torch.cuda.is_bf16_supported(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_rows() -> list[dict[str, Any]]:
|
|
94
|
+
rows: list[dict[str, Any]] = []
|
|
95
|
+
for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
|
|
96
|
+
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
97
|
+
if not line.strip():
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
row = json.loads(line)
|
|
101
|
+
except json.JSONDecodeError as exc:
|
|
102
|
+
raise ValueError(f"{path}:{line_number} contains invalid JSON") from exc
|
|
103
|
+
if not isinstance(row, dict) or "messages" not in row:
|
|
104
|
+
raise ValueError(f"{path}:{line_number} must contain a messages array")
|
|
105
|
+
rows.append(row)
|
|
106
|
+
if not rows:
|
|
107
|
+
raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
|
|
108
|
+
return rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_model_source() -> str:
|
|
112
|
+
if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()):
|
|
113
|
+
return str(BASE_MODEL_DIR)
|
|
114
|
+
return CERTIFIED_BASE_MODEL
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AssistantOnlyDataset(Dataset[dict[str, torch.Tensor]]):
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
rows: list[dict[str, Any]],
|
|
121
|
+
tokenizer: Any,
|
|
122
|
+
*,
|
|
123
|
+
max_length: int,
|
|
124
|
+
):
|
|
125
|
+
self.examples: list[dict[str, torch.Tensor]] = []
|
|
126
|
+
for row_index, row in enumerate(rows, start=1):
|
|
127
|
+
try:
|
|
128
|
+
tokenized = build_assistant_only_example(
|
|
129
|
+
tokenizer,
|
|
130
|
+
row["messages"],
|
|
131
|
+
max_length=max_length,
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise ValueError(f"Training row {row_index} is not valid Qwen SFT data: {exc}") from exc
|
|
135
|
+
self.examples.append({
|
|
136
|
+
key: torch.tensor(value, dtype=torch.long)
|
|
137
|
+
for key, value in tokenized.items()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
def __len__(self) -> int:
|
|
141
|
+
return len(self.examples)
|
|
142
|
+
|
|
143
|
+
def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
|
|
144
|
+
return self.examples[index]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AssistantOnlyCollator:
|
|
148
|
+
def __init__(self, pad_token_id: int):
|
|
149
|
+
self.pad_token_id = pad_token_id
|
|
150
|
+
|
|
151
|
+
def __call__(self, features: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
|
|
152
|
+
return {
|
|
153
|
+
"input_ids": pad_sequence(
|
|
154
|
+
[feature["input_ids"] for feature in features],
|
|
155
|
+
batch_first=True,
|
|
156
|
+
padding_value=self.pad_token_id,
|
|
157
|
+
),
|
|
158
|
+
"attention_mask": pad_sequence(
|
|
159
|
+
[feature["attention_mask"] for feature in features],
|
|
160
|
+
batch_first=True,
|
|
161
|
+
padding_value=0,
|
|
162
|
+
),
|
|
163
|
+
"labels": pad_sequence(
|
|
164
|
+
[feature["labels"] for feature in features],
|
|
165
|
+
batch_first=True,
|
|
166
|
+
padding_value=IGNORE_INDEX,
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype]:
|
|
172
|
+
token = os.getenv("HF_TOKEN")
|
|
173
|
+
source_kwargs: dict[str, Any] = {
|
|
174
|
+
**model_revision_kwargs(model_source),
|
|
175
|
+
"trust_remote_code": False,
|
|
176
|
+
"token": token,
|
|
177
|
+
}
|
|
178
|
+
if not Path(model_source).exists():
|
|
179
|
+
source_kwargs["local_files_only"] = True
|
|
180
|
+
tokenizer = AutoTokenizer.from_pretrained(model_source, **source_kwargs)
|
|
181
|
+
if tokenizer.eos_token_id is None:
|
|
182
|
+
raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
|
|
183
|
+
if tokenizer.pad_token_id is None:
|
|
184
|
+
tokenizer.pad_token = tokenizer.eos_token
|
|
185
|
+
|
|
186
|
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
187
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
188
|
+
model_source,
|
|
189
|
+
**source_kwargs,
|
|
190
|
+
dtype=dtype,
|
|
191
|
+
device_map={"": torch.cuda.current_device()},
|
|
192
|
+
)
|
|
193
|
+
assert_certified_model_config(model.config)
|
|
194
|
+
model.config.use_cache = False
|
|
195
|
+
return model, tokenizer, dtype
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def apply_lora(model: Any) -> Any:
|
|
199
|
+
config = LoraConfig(
|
|
200
|
+
r=hp_int("lora_rank", 16),
|
|
201
|
+
lora_alpha=hp_int("lora_alpha", 32),
|
|
202
|
+
lora_dropout=hp_float("lora_dropout", 0.05),
|
|
203
|
+
bias="none",
|
|
204
|
+
task_type="CAUSAL_LM",
|
|
205
|
+
target_modules="all-linear",
|
|
206
|
+
)
|
|
207
|
+
return get_peft_model(model, config)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_model_archive() -> Path:
|
|
211
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
archive_path = OUTPUT_DIR / "model.tar.gz"
|
|
213
|
+
with tarfile.open(archive_path, "w:gz") as archive:
|
|
214
|
+
archive.add(MODEL_DIR, arcname="model")
|
|
215
|
+
return archive_path
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_training(rows: list[dict[str, Any]], model_source: str) -> tuple[dict[str, Any], torch.dtype]:
|
|
219
|
+
model, tokenizer, dtype = create_model_and_tokenizer(model_source)
|
|
220
|
+
model = apply_lora(model)
|
|
221
|
+
dataset = AssistantOnlyDataset(
|
|
222
|
+
rows,
|
|
223
|
+
tokenizer,
|
|
224
|
+
max_length=hp_int("max_seq_length", 2048),
|
|
225
|
+
)
|
|
226
|
+
collator = AssistantOnlyCollator(tokenizer.pad_token_id)
|
|
227
|
+
|
|
228
|
+
with TemporaryDirectory(prefix="tt-local-sft-") as output_dir:
|
|
229
|
+
args = TrainingArguments(
|
|
230
|
+
output_dir=output_dir,
|
|
231
|
+
num_train_epochs=hp_int("n_epochs", 3),
|
|
232
|
+
learning_rate=hp_float("learning_rate", 0.00001),
|
|
233
|
+
per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
|
|
234
|
+
gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
|
|
235
|
+
logging_steps=1,
|
|
236
|
+
save_strategy="no",
|
|
237
|
+
report_to=[],
|
|
238
|
+
remove_unused_columns=False,
|
|
239
|
+
bf16=dtype == torch.bfloat16,
|
|
240
|
+
fp16=dtype == torch.float16,
|
|
241
|
+
optim="adamw_torch",
|
|
242
|
+
)
|
|
243
|
+
trainer = Trainer(
|
|
244
|
+
model=model,
|
|
245
|
+
args=args,
|
|
246
|
+
train_dataset=dataset,
|
|
247
|
+
data_collator=collator,
|
|
248
|
+
)
|
|
249
|
+
result = trainer.train()
|
|
250
|
+
|
|
251
|
+
model.save_pretrained(MODEL_DIR)
|
|
252
|
+
tokenizer.save_pretrained(MODEL_DIR)
|
|
253
|
+
return result.metrics, dtype
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> None:
|
|
257
|
+
started = time.time()
|
|
258
|
+
assert_certified_request()
|
|
259
|
+
cuda = require_cuda()
|
|
260
|
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
rows = load_rows()
|
|
264
|
+
model_source = resolve_model_source()
|
|
265
|
+
metrics, dtype = run_training(rows, model_source)
|
|
266
|
+
archive_path = create_model_archive()
|
|
267
|
+
output_metrics = {
|
|
268
|
+
"training_rows": len(rows),
|
|
269
|
+
"base_model": CERTIFIED_BASE_MODEL,
|
|
270
|
+
"base_model_revision": hp("base_model_revision"),
|
|
271
|
+
"model_source": model_source,
|
|
272
|
+
"model_loader": "causal_lm",
|
|
273
|
+
"loss_mask": "final_assistant_only",
|
|
274
|
+
"device": "cuda",
|
|
275
|
+
"cuda_device": cuda["device_name"],
|
|
276
|
+
"cuda_compute_capability": cuda["compute_capability"],
|
|
277
|
+
"dtype": str(dtype).removeprefix("torch."),
|
|
278
|
+
"train_runtime": round(time.time() - started, 3),
|
|
279
|
+
**{key: float(value) for key, value in metrics.items() if isinstance(value, (int, float))},
|
|
280
|
+
"model_archive": str(archive_path),
|
|
281
|
+
}
|
|
282
|
+
metrics_json = json.dumps(output_metrics, indent=2)
|
|
283
|
+
(MODEL_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
|
|
284
|
+
(OUTPUT_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
|
|
285
|
+
print(metrics_json)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|