@tuned-tensor/cli 0.9.0 → 0.9.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 +37 -0
- package/dist/chunk-DAP2Z7EZ.js +4400 -0
- package/dist/chunk-DAP2Z7EZ.js.map +1 -0
- package/dist/index.js +356 -128
- 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 +16 -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,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tarfile
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from model_contract import (
|
|
13
|
+
CERTIFIED_BASE_MODEL,
|
|
14
|
+
assert_certified_model_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
MAX_ARCHIVE_MEMBERS = 20_000
|
|
18
|
+
MAX_ARCHIVE_EXPANDED_BYTES = 20 * 1024 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
22
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
23
|
+
if not isinstance(value, dict):
|
|
24
|
+
raise ValueError(f"Expected a JSON object in {path}")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure_hugging_face_cache(cache_home: str | None) -> None:
|
|
29
|
+
"""Treat model_cache as HF_HOME and keep legacy overrides consistent."""
|
|
30
|
+
if not cache_home:
|
|
31
|
+
return
|
|
32
|
+
home = Path(cache_home).expanduser().absolute()
|
|
33
|
+
hub = home / "hub"
|
|
34
|
+
os.environ.update({
|
|
35
|
+
"HF_HOME": str(home),
|
|
36
|
+
"HF_HUB_CACHE": str(hub),
|
|
37
|
+
"HUGGINGFACE_HUB_CACHE": str(hub),
|
|
38
|
+
})
|
|
39
|
+
for deprecated in (
|
|
40
|
+
"TRANSFORMERS_CACHE",
|
|
41
|
+
"PYTORCH_TRANSFORMERS_CACHE",
|
|
42
|
+
"PYTORCH_PRETRAINED_BERT_CACHE",
|
|
43
|
+
):
|
|
44
|
+
os.environ.pop(deprecated, None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def import_runtime_dependencies() -> None:
|
|
48
|
+
"""Import libraries only after Hugging Face cache settings are final."""
|
|
49
|
+
global torch, PeftModel, AutoModelForCausalLM, AutoTokenizer
|
|
50
|
+
|
|
51
|
+
import torch as torch_module
|
|
52
|
+
from peft import PeftModel as peft_model
|
|
53
|
+
from transformers import (
|
|
54
|
+
AutoModelForCausalLM as auto_model_for_causal_lm,
|
|
55
|
+
AutoTokenizer as auto_tokenizer,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
torch = torch_module
|
|
59
|
+
PeftModel = peft_model
|
|
60
|
+
AutoModelForCausalLM = auto_model_for_causal_lm
|
|
61
|
+
AutoTokenizer = auto_tokenizer
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def strip_file_uri(value: str | None) -> str | None:
|
|
65
|
+
if not value:
|
|
66
|
+
return value
|
|
67
|
+
if value.startswith("file://"):
|
|
68
|
+
return value[7:]
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_adapter_archive(path: Path, destination: Path) -> None:
|
|
73
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
destination_root = destination.resolve()
|
|
75
|
+
with tarfile.open(path, "r:gz") as archive:
|
|
76
|
+
members = archive.getmembers()
|
|
77
|
+
if len(members) > MAX_ARCHIVE_MEMBERS:
|
|
78
|
+
raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
|
|
79
|
+
expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
|
|
80
|
+
if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
|
|
81
|
+
raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
|
|
82
|
+
for member in members:
|
|
83
|
+
member_path = (destination / member.name).resolve()
|
|
84
|
+
try:
|
|
85
|
+
member_path.relative_to(destination_root)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise ValueError(f"Unsafe archive member: {member.name}") from exc
|
|
88
|
+
if member.issym() or member.islnk() or member.isdev():
|
|
89
|
+
raise ValueError(f"Unsafe archive member type: {member.name}")
|
|
90
|
+
archive.extractall(destination)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
|
|
94
|
+
path_value = strip_file_uri(value)
|
|
95
|
+
if not path_value:
|
|
96
|
+
return None
|
|
97
|
+
path = Path(path_value)
|
|
98
|
+
if path.is_file() and path.name.endswith(".tar.gz"):
|
|
99
|
+
extracted = tmp / "adapter"
|
|
100
|
+
_extract_adapter_archive(path, extracted)
|
|
101
|
+
candidates = sorted(extracted.rglob("adapter_config.json"))
|
|
102
|
+
if candidates:
|
|
103
|
+
return str(candidates[0].parent)
|
|
104
|
+
raise ValueError(f"Adapter archive contains no adapter_config.json: {path}")
|
|
105
|
+
if not path.is_dir():
|
|
106
|
+
raise ValueError(f"Adapter path is not a directory or .tar.gz archive: {path}")
|
|
107
|
+
return str(path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_prompt(
|
|
111
|
+
tokenizer: Any,
|
|
112
|
+
system: str,
|
|
113
|
+
prompt: str,
|
|
114
|
+
) -> str:
|
|
115
|
+
messages = [
|
|
116
|
+
{"role": "system", "content": system},
|
|
117
|
+
{"role": "user", "content": prompt},
|
|
118
|
+
]
|
|
119
|
+
try:
|
|
120
|
+
return tokenizer.apply_chat_template(
|
|
121
|
+
messages,
|
|
122
|
+
tokenize=False,
|
|
123
|
+
add_generation_prompt=True,
|
|
124
|
+
)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_device(device: str) -> str:
|
|
130
|
+
if device not in {"cuda", "cpu"}:
|
|
131
|
+
raise ValueError(f"device must be cuda or cpu; got {device!r}")
|
|
132
|
+
if device == "cuda" and not torch.cuda.is_available():
|
|
133
|
+
raise RuntimeError("CUDA evaluation was requested, but torch.cuda.is_available() is false")
|
|
134
|
+
return device
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _assert_certified_model_source(base_model: str) -> None:
|
|
138
|
+
if not Path(base_model).exists() and base_model != CERTIFIED_BASE_MODEL:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"The bundled evaluator currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_text_model(payload: dict[str, Any], adapter_path: str | None):
|
|
145
|
+
base_model = str(payload["base_model"])
|
|
146
|
+
_assert_certified_model_source(base_model)
|
|
147
|
+
device = resolve_device(str(payload.get("device", "cuda")))
|
|
148
|
+
token = os.getenv("HF_TOKEN")
|
|
149
|
+
revision = payload.get("base_model_revision")
|
|
150
|
+
source_kwargs: dict[str, Any] = {
|
|
151
|
+
"trust_remote_code": False,
|
|
152
|
+
"token": token,
|
|
153
|
+
}
|
|
154
|
+
if not Path(base_model).exists():
|
|
155
|
+
source_kwargs["local_files_only"] = True
|
|
156
|
+
if revision and not Path(base_model).exists():
|
|
157
|
+
source_kwargs["revision"] = revision
|
|
158
|
+
|
|
159
|
+
tokenizer = AutoTokenizer.from_pretrained(base_model, **source_kwargs)
|
|
160
|
+
if tokenizer.eos_token_id is None:
|
|
161
|
+
raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
|
|
162
|
+
if tokenizer.pad_token_id is None:
|
|
163
|
+
tokenizer.pad_token = tokenizer.eos_token
|
|
164
|
+
|
|
165
|
+
dtype = None
|
|
166
|
+
if device == "cuda":
|
|
167
|
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
168
|
+
|
|
169
|
+
model_kwargs: dict[str, Any] = dict(source_kwargs)
|
|
170
|
+
if dtype is not None:
|
|
171
|
+
model_kwargs["dtype"] = dtype
|
|
172
|
+
if device == "cuda":
|
|
173
|
+
model_kwargs["device_map"] = {"": torch.cuda.current_device()}
|
|
174
|
+
|
|
175
|
+
model = AutoModelForCausalLM.from_pretrained(base_model, **model_kwargs)
|
|
176
|
+
assert_certified_model_config(model.config)
|
|
177
|
+
if adapter_path:
|
|
178
|
+
model = PeftModel.from_pretrained(model, adapter_path)
|
|
179
|
+
if device != "cuda":
|
|
180
|
+
model = model.to(device)
|
|
181
|
+
model.eval()
|
|
182
|
+
return model, tokenizer, device
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def sampling_kwargs(generation: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
temperature = float(generation.get("temperature", 0))
|
|
187
|
+
if temperature <= 0:
|
|
188
|
+
return {"do_sample": False}
|
|
189
|
+
return {
|
|
190
|
+
"do_sample": True,
|
|
191
|
+
"temperature": temperature,
|
|
192
|
+
"top_p": float(generation.get("top_p", 1)),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def generate_text_one(
|
|
197
|
+
model: Any,
|
|
198
|
+
tokenizer: Any,
|
|
199
|
+
system: str,
|
|
200
|
+
example: dict[str, Any],
|
|
201
|
+
generation: dict[str, Any],
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
if example.get("input_assets"):
|
|
204
|
+
raise ValueError("The bundled Qwen SFT evaluator is text-only and does not accept input_assets")
|
|
205
|
+
prompt = str(example["input"])
|
|
206
|
+
formatted = format_prompt(tokenizer, system, prompt)
|
|
207
|
+
inputs = tokenizer(formatted, return_tensors="pt")
|
|
208
|
+
target_device = next(model.parameters()).device
|
|
209
|
+
inputs = {key: value.to(target_device) for key, value in inputs.items()}
|
|
210
|
+
started = time.perf_counter()
|
|
211
|
+
with torch.inference_mode():
|
|
212
|
+
generated = model.generate(
|
|
213
|
+
**inputs,
|
|
214
|
+
max_new_tokens=int(generation.get("max_new_tokens", 256)),
|
|
215
|
+
pad_token_id=tokenizer.eos_token_id,
|
|
216
|
+
**sampling_kwargs(generation),
|
|
217
|
+
)
|
|
218
|
+
latency_ms = max(0, round((time.perf_counter() - started) * 1000))
|
|
219
|
+
input_length = int(inputs["input_ids"].shape[-1])
|
|
220
|
+
output_tokens = generated[0][input_length:]
|
|
221
|
+
actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
|
|
222
|
+
return {
|
|
223
|
+
"id": str(example["id"]),
|
|
224
|
+
"actual": actual,
|
|
225
|
+
"latency_ms": latency_ms,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main() -> None:
|
|
230
|
+
parser = argparse.ArgumentParser()
|
|
231
|
+
parser.add_argument("--input", required=True)
|
|
232
|
+
parser.add_argument("--output", required=True)
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
payload = load_json(Path(args.input))
|
|
236
|
+
if payload.get("protocol_version") != 2:
|
|
237
|
+
raise ValueError("Unsupported inference protocol; expected protocol_version 2")
|
|
238
|
+
if payload.get("model_loader") not in (None, "causal_lm"):
|
|
239
|
+
raise ValueError("The bundled evaluator is text-only and requires model_loader=causal_lm")
|
|
240
|
+
configure_hugging_face_cache(payload.get("model_cache"))
|
|
241
|
+
import_runtime_dependencies()
|
|
242
|
+
|
|
243
|
+
with TemporaryDirectory(prefix="tt-local-evaluate-") as tmp_dir:
|
|
244
|
+
adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
|
|
245
|
+
generation = payload.get("generation", {})
|
|
246
|
+
if not isinstance(generation, dict):
|
|
247
|
+
raise ValueError("generation must be a JSON object")
|
|
248
|
+
model, tokenizer, _device = load_text_model(payload, adapter_path)
|
|
249
|
+
results = [
|
|
250
|
+
generate_text_one(
|
|
251
|
+
model,
|
|
252
|
+
tokenizer,
|
|
253
|
+
str(payload.get("system", "")),
|
|
254
|
+
example,
|
|
255
|
+
generation,
|
|
256
|
+
)
|
|
257
|
+
for example in payload.get("examples", [])
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
output = {
|
|
261
|
+
"provider": "transformers",
|
|
262
|
+
"model_id": payload.get("model_id"),
|
|
263
|
+
"base_model": payload.get("base_model"),
|
|
264
|
+
"adapter_path": payload.get("adapter_path"),
|
|
265
|
+
"generation_config": generation,
|
|
266
|
+
"results": results,
|
|
267
|
+
}
|
|
268
|
+
output_path = Path(args.output)
|
|
269
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
output_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
CERTIFIED_BASE_MODEL = "Qwen/Qwen3.5-2B"
|
|
7
|
+
CERTIFIED_TEXT_CONFIG = {
|
|
8
|
+
"model_type": "qwen3_5_text",
|
|
9
|
+
"hidden_size": 2048,
|
|
10
|
+
"num_hidden_layers": 24,
|
|
11
|
+
"num_attention_heads": 8,
|
|
12
|
+
"num_key_value_heads": 2,
|
|
13
|
+
"intermediate_size": 6144,
|
|
14
|
+
"vocab_size": 248320,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _field(value: Any, name: str) -> Any:
|
|
19
|
+
if isinstance(value, dict):
|
|
20
|
+
return value.get(name)
|
|
21
|
+
return getattr(value, name, None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def assert_certified_model_config(value: Any, label: str = "base-model config") -> None:
|
|
25
|
+
model_type = _field(value, "model_type")
|
|
26
|
+
architectures = _field(value, "architectures")
|
|
27
|
+
if model_type == "qwen3_5":
|
|
28
|
+
text_config = _field(value, "text_config")
|
|
29
|
+
if (
|
|
30
|
+
not isinstance(architectures, (list, tuple))
|
|
31
|
+
or "Qwen3_5ForConditionalGeneration" not in architectures
|
|
32
|
+
or text_config is None
|
|
33
|
+
):
|
|
34
|
+
raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
|
|
35
|
+
elif model_type == "qwen3_5_text":
|
|
36
|
+
# AutoModelForCausalLM exposes the selected text sub-config after it
|
|
37
|
+
# dispatches the verified repository-level Qwen3.5 config.
|
|
38
|
+
text_config = value
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
|
|
41
|
+
for name, expected in CERTIFIED_TEXT_CONFIG.items():
|
|
42
|
+
actual = _field(text_config, name)
|
|
43
|
+
if actual != expected:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture: "
|
|
46
|
+
f"text_config.{name} must be {expected!r}, got {actual!r}"
|
|
47
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from model_contract import (
|
|
10
|
+
CERTIFIED_BASE_MODEL,
|
|
11
|
+
assert_certified_model_config,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
ALLOW_PATTERNS = [
|
|
15
|
+
"*.json",
|
|
16
|
+
"*.model",
|
|
17
|
+
"*.safetensors",
|
|
18
|
+
"*.safetensors.index.json",
|
|
19
|
+
"*.tiktoken",
|
|
20
|
+
"*.txt",
|
|
21
|
+
"chat_template.jinja",
|
|
22
|
+
"merges.txt",
|
|
23
|
+
"pytorch_model*.bin",
|
|
24
|
+
"sentencepiece.bpe.model",
|
|
25
|
+
"spiece.model",
|
|
26
|
+
"tokenizer.*",
|
|
27
|
+
"vocab.*",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
IGNORE_PATTERNS = [
|
|
31
|
+
"*.ckpt",
|
|
32
|
+
"*.gguf",
|
|
33
|
+
"*.h5",
|
|
34
|
+
"*.msgpack",
|
|
35
|
+
"*.onnx",
|
|
36
|
+
"*.ot",
|
|
37
|
+
"optimizer.pt",
|
|
38
|
+
"rng_state*.pth",
|
|
39
|
+
"scheduler.pt",
|
|
40
|
+
"trainer_state.json",
|
|
41
|
+
"training_args.bin",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_json(path: str, value: dict[str, Any]) -> None:
|
|
46
|
+
destination = Path(path)
|
|
47
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
destination.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure_hugging_face_cache(cache_home: str | None) -> None:
|
|
52
|
+
"""Treat model_cache as HF_HOME and keep legacy overrides consistent."""
|
|
53
|
+
if not cache_home:
|
|
54
|
+
return
|
|
55
|
+
# Keep the lexical absolute path supplied by the Node runner. Path.resolve
|
|
56
|
+
# canonicalizes macOS /var to /private/var, making metadata disagree with
|
|
57
|
+
# training even though both locations identify the same directory.
|
|
58
|
+
home = Path(cache_home).expanduser().absolute()
|
|
59
|
+
hub = home / "hub"
|
|
60
|
+
os.environ.update({
|
|
61
|
+
"HF_HOME": str(home),
|
|
62
|
+
"HF_HUB_CACHE": str(hub),
|
|
63
|
+
"HUGGINGFACE_HUB_CACHE": str(hub),
|
|
64
|
+
})
|
|
65
|
+
for deprecated in (
|
|
66
|
+
"TRANSFORMERS_CACHE",
|
|
67
|
+
"PYTORCH_TRANSFORMERS_CACHE",
|
|
68
|
+
"PYTORCH_PRETRAINED_BERT_CACHE",
|
|
69
|
+
):
|
|
70
|
+
os.environ.pop(deprecated, None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_file(path: Path, algorithm: str, prefix: bytes = b"") -> str:
|
|
74
|
+
digest = hashlib.new(algorithm)
|
|
75
|
+
digest.update(prefix)
|
|
76
|
+
with path.open("rb") as source:
|
|
77
|
+
for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""):
|
|
78
|
+
digest.update(chunk)
|
|
79
|
+
return digest.hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify_snapshot(snapshot: Path) -> tuple[list[Path], int]:
|
|
83
|
+
config = snapshot / "config.json"
|
|
84
|
+
if not config.is_file() or config.stat().st_size == 0:
|
|
85
|
+
raise ValueError(f"Cached snapshot is missing a non-empty config.json: {snapshot}")
|
|
86
|
+
try:
|
|
87
|
+
parsed_config = json.loads(config.read_text(encoding="utf-8"))
|
|
88
|
+
assert_certified_model_config(parsed_config, f"Cached config {config}")
|
|
89
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
90
|
+
raise ValueError(f"Cached snapshot has an invalid config.json: {snapshot}") from exc
|
|
91
|
+
|
|
92
|
+
weight_files = sorted(snapshot.glob("*.safetensors")) + sorted(snapshot.glob("pytorch_model*.bin"))
|
|
93
|
+
index_files = sorted(snapshot.glob("*.safetensors.index.json")) + sorted(snapshot.glob("pytorch_model*.bin.index.json"))
|
|
94
|
+
for index in index_files:
|
|
95
|
+
try:
|
|
96
|
+
weight_map = json.loads(index.read_text(encoding="utf-8")).get("weight_map", {})
|
|
97
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
98
|
+
raise ValueError(f"Cached snapshot has an invalid weight index: {index}") from exc
|
|
99
|
+
if not isinstance(weight_map, dict) or not weight_map:
|
|
100
|
+
raise ValueError(f"Cached snapshot has an empty weight index: {index}")
|
|
101
|
+
for name in set(weight_map.values()):
|
|
102
|
+
if not isinstance(name, str) or not (snapshot / name).is_file():
|
|
103
|
+
raise ValueError(f"Cached snapshot is missing an indexed weight shard: {name}")
|
|
104
|
+
if not weight_files:
|
|
105
|
+
raise ValueError(f"Cached snapshot contains no Transformers model weights: {snapshot}")
|
|
106
|
+
for weight in weight_files:
|
|
107
|
+
if weight.stat().st_size == 0:
|
|
108
|
+
raise ValueError(f"Cached snapshot contains an empty model weight: {weight}")
|
|
109
|
+
|
|
110
|
+
tokenizer_config = snapshot / "tokenizer_config.json"
|
|
111
|
+
vocabulary = [
|
|
112
|
+
snapshot / "tokenizer.json",
|
|
113
|
+
snapshot / "tokenizer.model",
|
|
114
|
+
snapshot / "sentencepiece.bpe.model",
|
|
115
|
+
snapshot / "spiece.model",
|
|
116
|
+
snapshot / "vocab.json",
|
|
117
|
+
snapshot / "tokenizer.tiktoken",
|
|
118
|
+
]
|
|
119
|
+
if not tokenizer_config.is_file() or not any(path.is_file() and path.stat().st_size > 0 for path in vocabulary):
|
|
120
|
+
raise ValueError(f"Cached snapshot is missing tokenizer metadata or vocabulary files: {snapshot}")
|
|
121
|
+
|
|
122
|
+
files = [path for path in snapshot.rglob("*") if path.is_file()]
|
|
123
|
+
verified_blobs = 0
|
|
124
|
+
for path in files:
|
|
125
|
+
blob = path.resolve()
|
|
126
|
+
expected = blob.name.lower()
|
|
127
|
+
if len(expected) == 64 and all(character in "0123456789abcdef" for character in expected):
|
|
128
|
+
actual = hash_file(blob, "sha256")
|
|
129
|
+
elif len(expected) == 40 and all(character in "0123456789abcdef" for character in expected):
|
|
130
|
+
actual = hash_file(blob, "sha1", f"blob {blob.stat().st_size}\0".encode())
|
|
131
|
+
else:
|
|
132
|
+
continue
|
|
133
|
+
if actual != expected:
|
|
134
|
+
raise ValueError(f"Cached Hugging Face blob checksum mismatch: {path}")
|
|
135
|
+
verified_blobs += 1
|
|
136
|
+
return files, verified_blobs
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> None:
|
|
140
|
+
parser = argparse.ArgumentParser(description="Prefetch a TT Local Hugging Face base model.")
|
|
141
|
+
parser.add_argument("--input", required=True)
|
|
142
|
+
parser.add_argument("--output", required=True)
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
146
|
+
base_model = str(payload["base_model"])
|
|
147
|
+
if base_model != CERTIFIED_BASE_MODEL:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
|
|
150
|
+
)
|
|
151
|
+
cache_dir = payload.get("model_cache")
|
|
152
|
+
configure_hugging_face_cache(cache_dir)
|
|
153
|
+
|
|
154
|
+
# Import only after the cache environment is final. huggingface_hub reads
|
|
155
|
+
# these variables while importing its constants module.
|
|
156
|
+
from huggingface_hub import constants, snapshot_download
|
|
157
|
+
|
|
158
|
+
action = "Verifying cached" if payload.get("local_files_only") else "Prefetching"
|
|
159
|
+
print(f"{action} {base_model} in {constants.HF_HUB_CACHE}...", flush=True)
|
|
160
|
+
snapshot_path = snapshot_download(
|
|
161
|
+
repo_id=base_model,
|
|
162
|
+
revision=payload.get("revision"),
|
|
163
|
+
token=os.getenv("HF_TOKEN"),
|
|
164
|
+
allow_patterns=ALLOW_PATTERNS,
|
|
165
|
+
ignore_patterns=IGNORE_PATTERNS,
|
|
166
|
+
local_files_only=bool(payload.get("local_files_only", False)),
|
|
167
|
+
)
|
|
168
|
+
snapshot = Path(snapshot_path)
|
|
169
|
+
snapshot_revision = snapshot.name
|
|
170
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}", snapshot_revision):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"Hugging Face snapshot did not resolve to a 40-character immutable commit SHA"
|
|
173
|
+
)
|
|
174
|
+
print(f"Verifying snapshot files under {snapshot}...", flush=True)
|
|
175
|
+
snapshot_files, verified_blob_count = verify_snapshot(snapshot)
|
|
176
|
+
|
|
177
|
+
write_json(args.output, {
|
|
178
|
+
"ok": True,
|
|
179
|
+
"base_model": base_model,
|
|
180
|
+
"model_cache": str(constants.HF_HOME),
|
|
181
|
+
"hf_home": str(constants.HF_HOME),
|
|
182
|
+
"hub_cache": str(constants.HF_HUB_CACHE),
|
|
183
|
+
"snapshot_path": snapshot_path,
|
|
184
|
+
"snapshot_revision": snapshot_revision.lower(),
|
|
185
|
+
"file_count": len(snapshot_files),
|
|
186
|
+
"size_bytes": sum(path.stat().st_size for path in snapshot_files),
|
|
187
|
+
"verified_blob_count": verified_blob_count,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|