opsiom-kairo 0.1.0__tar.gz
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.
- opsiom_kairo-0.1.0/PKG-INFO +14 -0
- opsiom_kairo-0.1.0/README.md +37 -0
- opsiom_kairo-0.1.0/kairo/__init__.py +1 -0
- opsiom_kairo-0.1.0/kairo/autotune.py +84 -0
- opsiom_kairo-0.1.0/kairo/benchmark.py +157 -0
- opsiom_kairo-0.1.0/kairo/checkpoint.py +175 -0
- opsiom_kairo-0.1.0/kairo/cli.py +78 -0
- opsiom_kairo-0.1.0/kairo/data.py +152 -0
- opsiom_kairo-0.1.0/kairo/hardware.py +125 -0
- opsiom_kairo-0.1.0/kairo/probes.py +82 -0
- opsiom_kairo-0.1.0/kairo/relay.py +131 -0
- opsiom_kairo-0.1.0/kairo/watchdog.py +92 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/PKG-INFO +14 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/SOURCES.txt +21 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/dependency_links.txt +1 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/entry_points.txt +2 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/requires.txt +11 -0
- opsiom_kairo-0.1.0/opsiom_kairo.egg-info/top_level.txt +1 -0
- opsiom_kairo-0.1.0/pyproject.toml +20 -0
- opsiom_kairo-0.1.0/setup.cfg +4 -0
- opsiom_kairo-0.1.0/tests/test_checkpoint.py +56 -0
- opsiom_kairo-0.1.0/tests/test_data.py +87 -0
- opsiom_kairo-0.1.0/tests/test_watchdog.py +57 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opsiom-kairo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mesure, fiabilité et reprise pour l'entraînement PyTorch (d'abord Opsiom Kaïro).
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: torch>=2.5
|
|
7
|
+
Requires-Dist: numpy
|
|
8
|
+
Requires-Dist: accelerate
|
|
9
|
+
Requires-Dist: pyyaml
|
|
10
|
+
Requires-Dist: huggingface_hub
|
|
11
|
+
Provides-Extra: logging
|
|
12
|
+
Requires-Dist: wandb; extra == "logging"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Kairo Engine
|
|
2
|
+
|
|
3
|
+
Couche de **mesure, fiabilité et reprise** pour l'entraînement PyTorch,
|
|
4
|
+
conçue d'abord pour Opsiom Kaïro (pré-entraînement 500M sur T4 / Colab / Kaggle).
|
|
5
|
+
|
|
6
|
+
Principe : **mesurer avant d'optimiser**. Kairo ne modifie jamais silencieusement
|
|
7
|
+
les choix scientifiques (learning rate, architecture, dataset, budget de tokens).
|
|
8
|
+
|
|
9
|
+
## Structure
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
kairo/
|
|
13
|
+
├── cli.py kairo benchmark | autotune | train | resume | status
|
|
14
|
+
├── hardware.py détection GPU/TPU, VRAM, fp16/bf16
|
|
15
|
+
├── benchmark.py tokens/s, MFU, temps forward/backward/optim, VRAM ← COMMENCER ICI
|
|
16
|
+
├── autotune.py batch size, grad accum, compile, AMP (progressif, jusqu'à OOM)
|
|
17
|
+
├── data.py sampler à permutation (graine fixe), split val représentatif
|
|
18
|
+
├── checkpoint.py sauvegarde/reprise (model, optimizer, tokens_seen, RNG, sampler)
|
|
19
|
+
├── relay.py relais Colab/Kaggle, sauvegarde HF Hub, notifications, arrêt propre
|
|
20
|
+
├── watchdog.py NaN/Inf, loss spike, chute de débit -> checkpoint d'urgence
|
|
21
|
+
└── probes.py prompts fixes générés à intervalles réguliers
|
|
22
|
+
configs/ default.yaml, t4.yaml
|
|
23
|
+
tests/ val sans fuite, reprise après crash, détection d'anomalies
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Ordre de développement
|
|
27
|
+
|
|
28
|
+
1. `hardware.py` + `benchmark.py` (avec MFU) : baseline de référence
|
|
29
|
+
2. Correction du split val + sampler à permutation (`data.py`)
|
|
30
|
+
3. Tests RoPE réel + `torch.compile` via `benchmark.py`
|
|
31
|
+
4. `checkpoint.py` + `relay.py` (cœur de la valeur du projet)
|
|
32
|
+
5. `watchdog.py`, puis `autotune.py`
|
|
33
|
+
6. `probes.py`
|
|
34
|
+
|
|
35
|
+
Volontairement absents tant que le profiling ne les justifie pas : C++/CUDA
|
|
36
|
+
custom, dashboard web, registre d'expériences, réimplémentation d'un Trainer
|
|
37
|
+
(Kairo se branche sur Accelerate).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Kairo Engine."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Recherche progressive de la config d'exécution, jamais de la config
|
|
2
|
+
scientifique.
|
|
3
|
+
|
|
4
|
+
Autotune ne touche jamais : learning rate, architecture, dataset, nombre
|
|
5
|
+
de tokens. Il ne touche que ce qui n'a aucun effet sur le résultat final
|
|
6
|
+
d'entraînement (à grad_accum équivalent) : micro_batch_size et
|
|
7
|
+
grad_accum_steps, pour occuper le GPU sans OOM ni sous-utilisation.
|
|
8
|
+
|
|
9
|
+
Approche volontairement simple (recherche binaire), au-dessus de
|
|
10
|
+
benchmark.py plutôt qu'une réimplémentation : la complexité (compile,
|
|
11
|
+
AMP...) est déjà gérée par PyTorch/Accelerate.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
|
|
17
|
+
from kairo.benchmark import BenchmarkResult, run_benchmark
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class AutotuneResult:
|
|
22
|
+
micro_batch_size: int
|
|
23
|
+
grad_accum_steps: int
|
|
24
|
+
benchmark: BenchmarkResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_max_micro_batch_size(
|
|
28
|
+
model_factory,
|
|
29
|
+
batch_fn_factory,
|
|
30
|
+
target_effective_batch: int,
|
|
31
|
+
flops_per_token: float,
|
|
32
|
+
min_batch: int = 1,
|
|
33
|
+
max_batch: int = 256,
|
|
34
|
+
bench_steps: int = 5,
|
|
35
|
+
) -> AutotuneResult:
|
|
36
|
+
"""Recherche binaire du plus grand micro-batch tenant en VRAM, en
|
|
37
|
+
conservant `target_effective_batch` via grad_accum_steps.
|
|
38
|
+
|
|
39
|
+
- `model_factory()` doit renvoyer un modèle frais sur le bon device
|
|
40
|
+
(nécessaire car un OOM peut laisser des gradients/activations sales).
|
|
41
|
+
- `batch_fn_factory(micro_batch_size)` doit renvoyer un `batch_fn`
|
|
42
|
+
compatible avec `kairo.benchmark.run_benchmark`.
|
|
43
|
+
"""
|
|
44
|
+
import torch
|
|
45
|
+
|
|
46
|
+
lo, hi = min_batch, max_batch
|
|
47
|
+
best: AutotuneResult | None = None
|
|
48
|
+
|
|
49
|
+
while lo <= hi:
|
|
50
|
+
mid = (lo + hi) // 2
|
|
51
|
+
try:
|
|
52
|
+
model = model_factory()
|
|
53
|
+
batch_fn = batch_fn_factory(mid)
|
|
54
|
+
result = run_benchmark(
|
|
55
|
+
model, batch_fn, flops_per_token=flops_per_token,
|
|
56
|
+
num_steps=bench_steps, warmup_steps=2,
|
|
57
|
+
)
|
|
58
|
+
except torch.cuda.OutOfMemoryError:
|
|
59
|
+
torch.cuda.empty_cache()
|
|
60
|
+
hi = mid - 1
|
|
61
|
+
continue
|
|
62
|
+
finally:
|
|
63
|
+
del model
|
|
64
|
+
torch.cuda.empty_cache()
|
|
65
|
+
|
|
66
|
+
if target_effective_batch % mid != 0:
|
|
67
|
+
# On garde la mesure pour info mais on ne la retient comme
|
|
68
|
+
# "best" que si elle divise exactement le batch effectif visé,
|
|
69
|
+
# pour ne jamais changer le batch effectif implicitement.
|
|
70
|
+
lo = mid + 1
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
accum = target_effective_batch // mid
|
|
74
|
+
best = AutotuneResult(mid, accum, result)
|
|
75
|
+
lo = mid + 1 # on essaie plus grand : moins de micro-steps, souvent plus rapide
|
|
76
|
+
|
|
77
|
+
if best is None:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"Aucun micro_batch_size testé ne divise target_effective_batch "
|
|
80
|
+
f"({target_effective_batch}) sans OOM. Essayez un target_effective_batch "
|
|
81
|
+
"qui a plus de diviseurs, ou augmentez max_batch."
|
|
82
|
+
)
|
|
83
|
+
return best
|
|
84
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Benchmark de référence : tokens/s, MFU, forward/backward/optimiseur, VRAM.
|
|
2
|
+
|
|
3
|
+
Objectif : mesurer AVANT d'optimiser, pour éviter de deviner. Rien ici ne
|
|
4
|
+
change la config d'entraînement ; ce module ne fait qu'observer.
|
|
5
|
+
|
|
6
|
+
Usage typique :
|
|
7
|
+
|
|
8
|
+
from kairo.benchmark import estimate_model_flops, run_benchmark
|
|
9
|
+
|
|
10
|
+
flops_per_token = estimate_model_flops(n_params=511_000_000)
|
|
11
|
+
result = run_benchmark(model, batch_fn, flops_per_token=flops_per_token,
|
|
12
|
+
num_steps=20, warmup_steps=5)
|
|
13
|
+
print(result.report())
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import time
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from kairo.hardware import detect_hardware
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def estimate_model_flops(n_params: int, n_layers: int | None = None,
|
|
25
|
+
seq_len: int | None = None) -> float:
|
|
26
|
+
"""FLOPs par token pour une passe forward+backward, approximation
|
|
27
|
+
standard des modèles transformer denses : 6*N (Kaplan et al., 2020).
|
|
28
|
+
|
|
29
|
+
Le terme d'attention (2 * 2 * n_layers * seq_len * d_model) est ignoré
|
|
30
|
+
par défaut car négligeable pour seq_len << d_model * n_layers ; il est
|
|
31
|
+
ajouté si n_layers et seq_len sont fournis (approximation grossière,
|
|
32
|
+
d_model non connu ici -> à affiner si besoin de précision fine)."""
|
|
33
|
+
flops = 6 * n_params
|
|
34
|
+
if n_layers and seq_len:
|
|
35
|
+
# Terme quadratique en séquence, ordre de grandeur seulement.
|
|
36
|
+
flops += 12 * n_layers * seq_len
|
|
37
|
+
return float(flops)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclasses.dataclass
|
|
41
|
+
class BenchmarkResult:
|
|
42
|
+
tokens_per_sec: float
|
|
43
|
+
step_time_s: float
|
|
44
|
+
forward_time_s: float
|
|
45
|
+
backward_time_s: float
|
|
46
|
+
optim_time_s: float
|
|
47
|
+
peak_vram_gb: float | None
|
|
48
|
+
achieved_flops: float # FLOPs/s réellement obtenus
|
|
49
|
+
mfu: float | None # achieved_flops / pic matériel théorique, 0..1
|
|
50
|
+
num_steps: int
|
|
51
|
+
tokens_per_step: int
|
|
52
|
+
|
|
53
|
+
def report(self) -> str:
|
|
54
|
+
lines = [
|
|
55
|
+
f"tokens/s : {self.tokens_per_sec:,.0f}",
|
|
56
|
+
f"temps/step : {self.step_time_s*1000:.1f} ms "
|
|
57
|
+
f"(fwd {self.forward_time_s*1000:.1f} / "
|
|
58
|
+
f"bwd {self.backward_time_s*1000:.1f} / "
|
|
59
|
+
f"optim {self.optim_time_s*1000:.1f})",
|
|
60
|
+
]
|
|
61
|
+
if self.peak_vram_gb is not None:
|
|
62
|
+
lines.append(f"VRAM pic : {self.peak_vram_gb:.2f} Go")
|
|
63
|
+
if self.mfu is not None:
|
|
64
|
+
lines.append(f"MFU : {self.mfu*100:.1f} %")
|
|
65
|
+
else:
|
|
66
|
+
lines.append("MFU : n/a (pic FLOPs matériel inconnu)")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_benchmark(
|
|
71
|
+
model,
|
|
72
|
+
batch_fn: Callable[[], tuple],
|
|
73
|
+
flops_per_token: float,
|
|
74
|
+
step_fn: Callable | None = None,
|
|
75
|
+
num_steps: int = 20,
|
|
76
|
+
warmup_steps: int = 5,
|
|
77
|
+
) -> BenchmarkResult:
|
|
78
|
+
"""Mesure les performances réelles d'un modèle sur `num_steps` pas.
|
|
79
|
+
|
|
80
|
+
- `batch_fn()` doit retourner un batch `(input_ids, targets)` déjà sur
|
|
81
|
+
le bon device.
|
|
82
|
+
- `step_fn(model, batch)` doit exécuter forward + backward + optimizer
|
|
83
|
+
et rendre le contrôle une fois le pas terminé (avant tout .item()
|
|
84
|
+
qui forcerait déjà une synchro : la synchro est faite par ce module).
|
|
85
|
+
Si `step_fn` n'est pas fourni, un pas forward/backward générique est
|
|
86
|
+
utilisé (sans pas d'optimiseur), suffisant pour mesurer fwd/bwd.
|
|
87
|
+
- Import torch différé : ce module reste important même sans torch.
|
|
88
|
+
"""
|
|
89
|
+
import torch
|
|
90
|
+
|
|
91
|
+
device = next(model.parameters()).device
|
|
92
|
+
is_cuda = device.type == "cuda"
|
|
93
|
+
|
|
94
|
+
def sync():
|
|
95
|
+
if is_cuda:
|
|
96
|
+
torch.cuda.synchronize()
|
|
97
|
+
|
|
98
|
+
if step_fn is None:
|
|
99
|
+
def step_fn(model, batch): # type: ignore[no-redef]
|
|
100
|
+
input_ids, targets = batch
|
|
101
|
+
logits = model(input_ids)
|
|
102
|
+
loss = torch.nn.functional.cross_entropy(
|
|
103
|
+
logits.view(-1, logits.size(-1)), targets.view(-1)
|
|
104
|
+
)
|
|
105
|
+
loss.backward()
|
|
106
|
+
model.zero_grad(set_to_none=True)
|
|
107
|
+
|
|
108
|
+
# Warmup : cuDNN autotuning, compilation, allocation de la VRAM.
|
|
109
|
+
for _ in range(warmup_steps):
|
|
110
|
+
batch = batch_fn()
|
|
111
|
+
step_fn(model, batch)
|
|
112
|
+
sync()
|
|
113
|
+
|
|
114
|
+
if is_cuda:
|
|
115
|
+
torch.cuda.reset_peak_memory_stats(device)
|
|
116
|
+
|
|
117
|
+
total_tokens = 0
|
|
118
|
+
step_times = []
|
|
119
|
+
for _ in range(num_steps):
|
|
120
|
+
batch = batch_fn()
|
|
121
|
+
n_tokens = batch[0].numel()
|
|
122
|
+
sync()
|
|
123
|
+
t0 = time.perf_counter()
|
|
124
|
+
step_fn(model, batch)
|
|
125
|
+
sync()
|
|
126
|
+
t1 = time.perf_counter()
|
|
127
|
+
step_times.append(t1 - t0)
|
|
128
|
+
total_tokens += n_tokens
|
|
129
|
+
|
|
130
|
+
total_time = sum(step_times)
|
|
131
|
+
tokens_per_sec = total_tokens / total_time
|
|
132
|
+
step_time_s = total_time / num_steps
|
|
133
|
+
achieved_flops = tokens_per_sec * flops_per_token
|
|
134
|
+
|
|
135
|
+
peak_vram_gb = None
|
|
136
|
+
if is_cuda:
|
|
137
|
+
peak_vram_gb = torch.cuda.max_memory_allocated(device) / (1024**3)
|
|
138
|
+
|
|
139
|
+
hw = detect_hardware()
|
|
140
|
+
mfu = achieved_flops / hw.peak_flops if hw.peak_flops else None
|
|
141
|
+
|
|
142
|
+
# Sans instrumentation fine (torch.profiler), on ne distingue pas
|
|
143
|
+
# fwd/bwd/optim par défaut : ils sont laissés à 0 ici et à remplir par
|
|
144
|
+
# l'appelant via `step_fn` instrumenté si le détail est nécessaire.
|
|
145
|
+
return BenchmarkResult(
|
|
146
|
+
tokens_per_sec=tokens_per_sec,
|
|
147
|
+
step_time_s=step_time_s,
|
|
148
|
+
forward_time_s=0.0,
|
|
149
|
+
backward_time_s=0.0,
|
|
150
|
+
optim_time_s=0.0,
|
|
151
|
+
peak_vram_gb=peak_vram_gb,
|
|
152
|
+
achieved_flops=achieved_flops,
|
|
153
|
+
mfu=mfu,
|
|
154
|
+
num_steps=num_steps,
|
|
155
|
+
tokens_per_step=total_tokens // num_steps,
|
|
156
|
+
)
|
|
157
|
+
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Sauvegarde/reprise complète d'un run.
|
|
2
|
+
|
|
3
|
+
Contrairement à une sauvegarde "poids seuls", un checkpoint Kairo capture
|
|
4
|
+
tout ce qui est nécessaire pour reprendre EXACTEMENT là où le run s'est
|
|
5
|
+
arrêté, y compris entre deux plateformes différentes (Colab -> Kaggle) :
|
|
6
|
+
|
|
7
|
+
- state_dict() du modèle et de l'optimiseur
|
|
8
|
+
- scheduler (si fourni)
|
|
9
|
+
- tokens_seen (source de vérité pour la progression, pas step ni epoch)
|
|
10
|
+
- états RNG (torch CPU, torch CUDA, numpy, random) pour la reproductibilité
|
|
11
|
+
- state_dict() du sampler (kairo.data.PermutationSampler), pour reprendre
|
|
12
|
+
le parcours du corpus exactement où il en était
|
|
13
|
+
- métadonnées libres (config, meilleure loss val, etc.)
|
|
14
|
+
|
|
15
|
+
Le fichier le plus récent et le "best" (meilleure loss val représentative,
|
|
16
|
+
cf. data.py) sont conservés séparément pour ne jamais perdre le meilleur
|
|
17
|
+
modèle à cause d'une reprise ultérieure qui diverge.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import dataclasses
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
import shutil
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass
|
|
32
|
+
class CheckpointMeta:
|
|
33
|
+
tokens_seen: int
|
|
34
|
+
step: int
|
|
35
|
+
val_loss: float | None
|
|
36
|
+
wall_time_s: float
|
|
37
|
+
extra: dict = dataclasses.field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _capture_rng_state() -> dict:
|
|
41
|
+
state = {"python": random.getstate(), "numpy": None, "torch_cpu": None,
|
|
42
|
+
"torch_cuda": None}
|
|
43
|
+
try:
|
|
44
|
+
import numpy as np
|
|
45
|
+
state["numpy"] = np.random.get_state()
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
import torch
|
|
50
|
+
state["torch_cpu"] = torch.get_rng_state()
|
|
51
|
+
if torch.cuda.is_available():
|
|
52
|
+
state["torch_cuda"] = torch.cuda.get_rng_state_all()
|
|
53
|
+
except ImportError:
|
|
54
|
+
pass
|
|
55
|
+
return state
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _restore_rng_state(state: dict) -> None:
|
|
59
|
+
if state.get("python") is not None:
|
|
60
|
+
random.setstate(state["python"])
|
|
61
|
+
if state.get("numpy") is not None:
|
|
62
|
+
try:
|
|
63
|
+
import numpy as np
|
|
64
|
+
np.random.set_state(state["numpy"])
|
|
65
|
+
except ImportError:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
import torch
|
|
69
|
+
if state.get("torch_cpu") is not None:
|
|
70
|
+
torch.set_rng_state(state["torch_cpu"])
|
|
71
|
+
if state.get("torch_cuda") is not None and torch.cuda.is_available():
|
|
72
|
+
torch.cuda.set_rng_state_all(state["torch_cuda"])
|
|
73
|
+
except ImportError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_checkpoint(
|
|
78
|
+
path: str,
|
|
79
|
+
model,
|
|
80
|
+
optimizer=None,
|
|
81
|
+
scheduler=None,
|
|
82
|
+
sampler=None,
|
|
83
|
+
meta: CheckpointMeta | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Écrit un checkpoint de façon atomique (fichier temporaire + rename),
|
|
86
|
+
pour ne jamais laisser un fichier corrompu si le process est tué en
|
|
87
|
+
plein milieu (fréquent sur Colab/Kaggle en fin de session)."""
|
|
88
|
+
import torch
|
|
89
|
+
|
|
90
|
+
payload: dict[str, Any] = {
|
|
91
|
+
"model": model.state_dict(),
|
|
92
|
+
"optimizer": optimizer.state_dict() if optimizer is not None else None,
|
|
93
|
+
"scheduler": scheduler.state_dict() if scheduler is not None else None,
|
|
94
|
+
"sampler": sampler.state_dict() if sampler is not None else None,
|
|
95
|
+
"rng": _capture_rng_state(),
|
|
96
|
+
"meta": dataclasses.asdict(meta) if meta is not None else None,
|
|
97
|
+
"saved_at": time.time(),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
101
|
+
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
|
|
102
|
+
os.close(fd)
|
|
103
|
+
try:
|
|
104
|
+
torch.save(payload, tmp_path)
|
|
105
|
+
os.replace(tmp_path, path) # atomique sur un même filesystem
|
|
106
|
+
except BaseException:
|
|
107
|
+
if os.path.exists(tmp_path):
|
|
108
|
+
os.remove(tmp_path)
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_checkpoint(
|
|
113
|
+
path: str,
|
|
114
|
+
model,
|
|
115
|
+
optimizer=None,
|
|
116
|
+
scheduler=None,
|
|
117
|
+
sampler=None,
|
|
118
|
+
map_location: str | None = None,
|
|
119
|
+
restore_rng: bool = True,
|
|
120
|
+
) -> CheckpointMeta | None:
|
|
121
|
+
import torch
|
|
122
|
+
|
|
123
|
+
payload = torch.load(path, map_location=map_location, weights_only=False)
|
|
124
|
+
|
|
125
|
+
model.load_state_dict(payload["model"])
|
|
126
|
+
if optimizer is not None and payload.get("optimizer") is not None:
|
|
127
|
+
optimizer.load_state_dict(payload["optimizer"])
|
|
128
|
+
if scheduler is not None and payload.get("scheduler") is not None:
|
|
129
|
+
scheduler.load_state_dict(payload["scheduler"])
|
|
130
|
+
if sampler is not None and payload.get("sampler") is not None:
|
|
131
|
+
sampler.load_state_dict(payload["sampler"])
|
|
132
|
+
if restore_rng and payload.get("rng") is not None:
|
|
133
|
+
_restore_rng_state(payload["rng"])
|
|
134
|
+
|
|
135
|
+
meta = payload.get("meta")
|
|
136
|
+
return CheckpointMeta(**meta) if meta else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CheckpointManager:
|
|
140
|
+
"""Gère deux fichiers stables : `latest.pt` (dernier en date, pour
|
|
141
|
+
reprendre après une coupure) et `best.pt` (meilleure val_loss vue,
|
|
142
|
+
jamais écrasé par un run qui repart puis diverge)."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, directory: str):
|
|
145
|
+
self.directory = directory
|
|
146
|
+
os.makedirs(directory, exist_ok=True)
|
|
147
|
+
self._best_val_loss = float("inf")
|
|
148
|
+
state_path = os.path.join(directory, "state.json")
|
|
149
|
+
if os.path.exists(state_path):
|
|
150
|
+
with open(state_path) as f:
|
|
151
|
+
self._best_val_loss = json.load(f).get("best_val_loss", float("inf"))
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def latest_path(self) -> str:
|
|
155
|
+
return os.path.join(self.directory, "latest.pt")
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def best_path(self) -> str:
|
|
159
|
+
return os.path.join(self.directory, "best.pt")
|
|
160
|
+
|
|
161
|
+
def save(self, model, optimizer, scheduler, sampler, meta: CheckpointMeta) -> None:
|
|
162
|
+
save_checkpoint(self.latest_path, model, optimizer, scheduler, sampler, meta)
|
|
163
|
+
if meta.val_loss is not None and meta.val_loss < self._best_val_loss:
|
|
164
|
+
self._best_val_loss = meta.val_loss
|
|
165
|
+
shutil.copy2(self.latest_path, self.best_path)
|
|
166
|
+
with open(os.path.join(self.directory, "state.json"), "w") as f:
|
|
167
|
+
json.dump({"best_val_loss": self._best_val_loss}, f)
|
|
168
|
+
|
|
169
|
+
def resume(self, model, optimizer=None, scheduler=None, sampler=None,
|
|
170
|
+
map_location: str | None = None) -> CheckpointMeta | None:
|
|
171
|
+
if not os.path.exists(self.latest_path):
|
|
172
|
+
return None
|
|
173
|
+
return load_checkpoint(self.latest_path, model, optimizer, scheduler,
|
|
174
|
+
sampler, map_location=map_location)
|
|
175
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Point d'entrée CLI : kairo hardware | benchmark | status.
|
|
2
|
+
|
|
3
|
+
`kairo train`/`resume` ne sont pas des commandes génériques ici : Kairo est
|
|
4
|
+
une bibliothèque qui s'utilise depuis le script d'entraînement existant
|
|
5
|
+
(main-GPU-CPU-TPU.py), pas un Trainer qui remplace ce script. La CLI ne
|
|
6
|
+
couvre donc que ce qui a du sens hors contexte d'un script précis :
|
|
7
|
+
diagnostic matériel, benchmark autonome sur un modèle donné, et lecture
|
|
8
|
+
de l'état d'un run à partir de son dossier de checkpoints.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_hardware(args: argparse.Namespace) -> None:
|
|
17
|
+
from kairo.hardware import detect_hardware, summary_line
|
|
18
|
+
|
|
19
|
+
info = detect_hardware()
|
|
20
|
+
if args.json:
|
|
21
|
+
print(json.dumps(info.as_dict(), indent=2))
|
|
22
|
+
else:
|
|
23
|
+
print(summary_line(info))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
27
|
+
import os
|
|
28
|
+
|
|
29
|
+
from kairo.checkpoint import load_checkpoint
|
|
30
|
+
|
|
31
|
+
latest = os.path.join(args.checkpoint_dir, "latest.pt")
|
|
32
|
+
best = os.path.join(args.checkpoint_dir, "best.pt")
|
|
33
|
+
if not os.path.exists(latest):
|
|
34
|
+
print(f"Aucun checkpoint dans {args.checkpoint_dir}")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Chargement "à blanc" : on ne restaure aucun state_dict, juste les
|
|
38
|
+
# métadonnées, donc pas besoin d'un vrai modèle/optimiseur ici.
|
|
39
|
+
class _Void:
|
|
40
|
+
def state_dict(self):
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def load_state_dict(self, _):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
meta = load_checkpoint(latest, _Void(), restore_rng=False)
|
|
47
|
+
print(f"dernier checkpoint : {latest}")
|
|
48
|
+
if meta:
|
|
49
|
+
print(f" step : {meta.step}")
|
|
50
|
+
print(f" tokens_seen : {meta.tokens_seen:,}")
|
|
51
|
+
print(f" val_loss : {meta.val_loss}")
|
|
52
|
+
print(f"best checkpoint : {best if os.path.exists(best) else '(absent)'}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(prog="kairo")
|
|
57
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
58
|
+
|
|
59
|
+
p_hw = sub.add_parser("hardware", help="Détecte et affiche le matériel disponible.")
|
|
60
|
+
p_hw.add_argument("--json", action="store_true")
|
|
61
|
+
p_hw.set_defaults(func=cmd_hardware)
|
|
62
|
+
|
|
63
|
+
p_status = sub.add_parser("status", help="Affiche l'état d'un dossier de checkpoints.")
|
|
64
|
+
p_status.add_argument("checkpoint_dir")
|
|
65
|
+
p_status.set_defaults(func=cmd_status)
|
|
66
|
+
|
|
67
|
+
return parser
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
parser = build_parser()
|
|
72
|
+
args = parser.parse_args()
|
|
73
|
+
args.func(args)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
78
|
+
|