canifinetune 0.1.0__py3-none-any.whl
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.
- canifinetune/__init__.py +6 -0
- canifinetune/bench/__init__.py +5 -0
- canifinetune/bench/memory_trace.py +69 -0
- canifinetune/bench/oom.py +34 -0
- canifinetune/bench/runner.py +450 -0
- canifinetune/bench/synthetic_data.py +75 -0
- canifinetune/cli.py +512 -0
- canifinetune/doctor.py +165 -0
- canifinetune/estimator/__init__.py +29 -0
- canifinetune/estimator/calibration.py +191 -0
- canifinetune/estimator/formulas.py +315 -0
- canifinetune/estimator/memory.py +250 -0
- canifinetune/estimator/model_metadata.py +342 -0
- canifinetune/estimator/recommender.py +228 -0
- canifinetune/recipes/__init__.py +5 -0
- canifinetune/recipes/generator.py +147 -0
- canifinetune/recipes/templates/README.md.j2 +56 -0
- canifinetune/recipes/templates/config.yaml.j2 +38 -0
- canifinetune/recipes/templates/dataset_format.md.j2 +35 -0
- canifinetune/recipes/templates/eval_smoke.py.j2 +56 -0
- canifinetune/recipes/templates/expected_vram.md.j2 +51 -0
- canifinetune/recipes/templates/requirements.txt.j2 +11 -0
- canifinetune/recipes/templates/run.sh.j2 +9 -0
- canifinetune/recipes/templates/sample_dataset.jsonl.j2 +8 -0
- canifinetune/recipes/templates/train.py.j2 +300 -0
- canifinetune/recipes/templates/troubleshooting.md.j2 +69 -0
- canifinetune/reports/__init__.py +11 -0
- canifinetune/reports/html.py +97 -0
- canifinetune/reports/markdown.py +167 -0
- canifinetune/utils/__init__.py +1 -0
- canifinetune/utils/gpu.py +145 -0
- canifinetune/utils/hf.py +60 -0
- canifinetune/utils/logging.py +48 -0
- canifinetune/utils/subprocess.py +30 -0
- canifinetune/utils/units.py +96 -0
- canifinetune-0.1.0.dist-info/METADATA +251 -0
- canifinetune-0.1.0.dist-info/RECORD +40 -0
- canifinetune-0.1.0.dist-info/WHEEL +4 -0
- canifinetune-0.1.0.dist-info/entry_points.txt +2 -0
- canifinetune-0.1.0.dist-info/licenses/LICENSE +21 -0
canifinetune/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Thin wrappers around ``torch.cuda`` memory stats.
|
|
2
|
+
|
|
3
|
+
Importing this module does not import torch; ``snapshot`` and friends import
|
|
4
|
+
torch lazily, so the rest of the package stays usable on CPU-only installs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MemorySnapshot:
|
|
15
|
+
stage: str
|
|
16
|
+
allocated_gb: float
|
|
17
|
+
reserved_gb: float
|
|
18
|
+
max_allocated_gb: float
|
|
19
|
+
max_reserved_gb: float
|
|
20
|
+
free_gb: float
|
|
21
|
+
total_gb: float
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"stage": self.stage,
|
|
26
|
+
"allocated_gb": round(self.allocated_gb, 4),
|
|
27
|
+
"reserved_gb": round(self.reserved_gb, 4),
|
|
28
|
+
"max_allocated_gb": round(self.max_allocated_gb, 4),
|
|
29
|
+
"max_reserved_gb": round(self.max_reserved_gb, 4),
|
|
30
|
+
"free_gb": round(self.free_gb, 4),
|
|
31
|
+
"total_gb": round(self.total_gb, 4),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def reset_peak() -> None:
|
|
36
|
+
import torch # type: ignore
|
|
37
|
+
|
|
38
|
+
if torch.cuda.is_available():
|
|
39
|
+
torch.cuda.reset_peak_memory_stats()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def snapshot(stage: str) -> MemorySnapshot:
|
|
43
|
+
import torch # type: ignore
|
|
44
|
+
|
|
45
|
+
if not torch.cuda.is_available():
|
|
46
|
+
return MemorySnapshot(stage=stage, allocated_gb=0, reserved_gb=0,
|
|
47
|
+
max_allocated_gb=0, max_reserved_gb=0,
|
|
48
|
+
free_gb=0, total_gb=0)
|
|
49
|
+
alloc = torch.cuda.memory_allocated() / (1024**3)
|
|
50
|
+
reserved = torch.cuda.memory_reserved() / (1024**3)
|
|
51
|
+
max_alloc = torch.cuda.max_memory_allocated() / (1024**3)
|
|
52
|
+
max_res = torch.cuda.max_memory_reserved() / (1024**3)
|
|
53
|
+
free_b, total_b = torch.cuda.mem_get_info()
|
|
54
|
+
return MemorySnapshot(
|
|
55
|
+
stage=stage,
|
|
56
|
+
allocated_gb=alloc,
|
|
57
|
+
reserved_gb=reserved,
|
|
58
|
+
max_allocated_gb=max_alloc,
|
|
59
|
+
max_reserved_gb=max_res,
|
|
60
|
+
free_gb=free_b / (1024**3),
|
|
61
|
+
total_gb=total_b / (1024**3),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def empty_cache() -> None:
|
|
66
|
+
import torch # type: ignore
|
|
67
|
+
|
|
68
|
+
if torch.cuda.is_available():
|
|
69
|
+
torch.cuda.empty_cache()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Helpers to capture CUDA OOM without losing whatever progress was made."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OomReport:
|
|
10
|
+
happened: bool = False
|
|
11
|
+
stage: str = ""
|
|
12
|
+
message: str = ""
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> dict[str, str | bool]:
|
|
15
|
+
return {"happened": self.happened, "stage": self.stage, "message": self.message}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_oom(exc: BaseException) -> bool:
|
|
19
|
+
"""Return True if ``exc`` is a CUDA out-of-memory error."""
|
|
20
|
+
msg = str(exc).lower()
|
|
21
|
+
if "out of memory" in msg or "cuda oom" in msg:
|
|
22
|
+
return True
|
|
23
|
+
try:
|
|
24
|
+
import torch # type: ignore
|
|
25
|
+
|
|
26
|
+
if isinstance(exc, torch.cuda.OutOfMemoryError):
|
|
27
|
+
return True
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_oom_report(stage: str, exc: BaseException) -> OomReport:
|
|
34
|
+
return OomReport(happened=True, stage=stage, message=str(exc)[:600])
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""The actual local benchmark loop.
|
|
2
|
+
|
|
3
|
+
``run_bench(config)`` loads a model (with optional 4-bit quantization), wraps
|
|
4
|
+
it with a PEFT LoRA adapter (when applicable), runs a handful of forward /
|
|
5
|
+
backward / optimizer steps on synthetic tokens, and returns a JSON-serializable
|
|
6
|
+
:class:`BenchResult` with peak VRAM at each stage.
|
|
7
|
+
|
|
8
|
+
Everything heavy is imported lazily so this module is safe to import on CPU-only
|
|
9
|
+
installs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import gc
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
from ..estimator.memory import EstimateRequest, estimate
|
|
24
|
+
from ..estimator.model_metadata import fetch_metadata
|
|
25
|
+
from ..utils.gpu import probe_cuda
|
|
26
|
+
from ..utils.logging import get_logger, utc_now_iso
|
|
27
|
+
from .memory_trace import MemorySnapshot, empty_cache, reset_peak, snapshot
|
|
28
|
+
from .oom import OomReport, is_oom, make_oom_report
|
|
29
|
+
from .synthetic_data import make_batch
|
|
30
|
+
|
|
31
|
+
log = get_logger("bench.runner")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BenchConfig(BaseModel):
|
|
35
|
+
model_id: str
|
|
36
|
+
method: Literal["full", "lora", "qlora"] = "lora"
|
|
37
|
+
seq_len: int = Field(128, gt=0)
|
|
38
|
+
micro_batch_size: int = Field(1, gt=0)
|
|
39
|
+
steps: int = Field(2, gt=0)
|
|
40
|
+
lora_rank: int = Field(8, gt=0)
|
|
41
|
+
lora_alpha: int = Field(16, gt=0)
|
|
42
|
+
lora_dropout: float = 0.0
|
|
43
|
+
lora_target_scope: Literal["attention", "all_linear", "conservative"] = "attention"
|
|
44
|
+
optimizer: str = "paged_adamw_8bit"
|
|
45
|
+
quantization: str = "nf4_double_quant"
|
|
46
|
+
base_dtype: str = "bf16"
|
|
47
|
+
gradient_checkpointing: bool = True
|
|
48
|
+
attention_implementation: str = "sdpa"
|
|
49
|
+
device: str = "cuda"
|
|
50
|
+
# If set, the runner will only do `forward()` (no backward) — used for
|
|
51
|
+
# extra-tiny smoke tests.
|
|
52
|
+
forward_only: bool = False
|
|
53
|
+
# If True, the runner records the static-estimate alongside the measurement.
|
|
54
|
+
record_estimate: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BenchResult(BaseModel):
|
|
58
|
+
config: BenchConfig
|
|
59
|
+
model_family: str
|
|
60
|
+
timestamp: str
|
|
61
|
+
env: dict[str, Any]
|
|
62
|
+
gpu: dict[str, Any]
|
|
63
|
+
snapshots: list[dict[str, Any]] = Field(default_factory=list)
|
|
64
|
+
tokens_per_second: float = 0.0
|
|
65
|
+
avg_step_time_s: float = 0.0
|
|
66
|
+
oom: dict[str, Any] = Field(default_factory=lambda: OomReport().to_dict())
|
|
67
|
+
measured: dict[str, Any] = Field(default_factory=dict)
|
|
68
|
+
estimated_total_gb: float = 0.0
|
|
69
|
+
notes: list[str] = Field(default_factory=list)
|
|
70
|
+
success: bool = True
|
|
71
|
+
method: str = "lora"
|
|
72
|
+
|
|
73
|
+
def save(self, path: Path) -> Path:
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
path.write_text(self.model_dump_json(indent=2), encoding="utf-8")
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_clear() -> None:
|
|
80
|
+
gc.collect()
|
|
81
|
+
with contextlib.suppress(Exception):
|
|
82
|
+
empty_cache()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _make_lora_config(cfg: BenchConfig, family: str) -> Any:
|
|
86
|
+
from peft import LoraConfig # type: ignore
|
|
87
|
+
|
|
88
|
+
from ..estimator.formulas import default_target_modules
|
|
89
|
+
|
|
90
|
+
target_modules = default_target_modules(family, scope=cfg.lora_target_scope)
|
|
91
|
+
return LoraConfig(
|
|
92
|
+
r=cfg.lora_rank,
|
|
93
|
+
lora_alpha=cfg.lora_alpha,
|
|
94
|
+
lora_dropout=cfg.lora_dropout,
|
|
95
|
+
bias="none",
|
|
96
|
+
target_modules=target_modules,
|
|
97
|
+
task_type="CAUSAL_LM",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_optimizer(params, name: str):
|
|
102
|
+
import torch # type: ignore
|
|
103
|
+
|
|
104
|
+
name = name.lower()
|
|
105
|
+
if name in {"paged_adamw_8bit", "adamw_8bit"}:
|
|
106
|
+
try:
|
|
107
|
+
import bitsandbytes as bnb # type: ignore
|
|
108
|
+
|
|
109
|
+
return bnb.optim.PagedAdamW8bit(params, lr=2e-4)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
log.warning("bitsandbytes 8-bit optimizer unavailable (%s); falling back to AdamW", e)
|
|
112
|
+
return torch.optim.AdamW(params, lr=2e-4)
|
|
113
|
+
if name in {"adamw_torch", "adamw_torch_fused", "adamw"}:
|
|
114
|
+
return torch.optim.AdamW(params, lr=2e-4)
|
|
115
|
+
if name == "sgd":
|
|
116
|
+
return torch.optim.SGD(params, lr=1e-3)
|
|
117
|
+
return torch.optim.AdamW(params, lr=2e-4)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _model_dtype_kwarg(value: Any) -> dict[str, Any]:
|
|
121
|
+
"""Return ``{"dtype": value}`` for transformers ≥ 5.x, ``{"torch_dtype": value}`` for older.
|
|
122
|
+
|
|
123
|
+
transformers 5.0 renamed ``torch_dtype`` → ``dtype``. We prefer the new
|
|
124
|
+
name; if the installed transformers complains, the caller falls back.
|
|
125
|
+
"""
|
|
126
|
+
return {"dtype": value}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_model(cfg: BenchConfig):
|
|
130
|
+
import torch # type: ignore
|
|
131
|
+
from transformers import AutoConfig, AutoModelForCausalLM # type: ignore
|
|
132
|
+
|
|
133
|
+
kwargs: dict[str, Any] = {"trust_remote_code": False}
|
|
134
|
+
|
|
135
|
+
# bf16 vs fp16 dtype
|
|
136
|
+
if cfg.base_dtype.lower() == "bf16":
|
|
137
|
+
dtype_value = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
138
|
+
elif cfg.base_dtype.lower() == "fp16":
|
|
139
|
+
dtype_value = torch.float16
|
|
140
|
+
elif cfg.base_dtype.lower() == "fp32":
|
|
141
|
+
dtype_value = torch.float32
|
|
142
|
+
else:
|
|
143
|
+
dtype_value = torch.float32
|
|
144
|
+
kwargs.update(_model_dtype_kwarg(dtype_value))
|
|
145
|
+
|
|
146
|
+
if cfg.method == "qlora":
|
|
147
|
+
try:
|
|
148
|
+
from transformers import BitsAndBytesConfig # type: ignore
|
|
149
|
+
|
|
150
|
+
compute_dtype = (
|
|
151
|
+
torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
152
|
+
)
|
|
153
|
+
kwargs["quantization_config"] = BitsAndBytesConfig(
|
|
154
|
+
load_in_4bit=True,
|
|
155
|
+
bnb_4bit_compute_dtype=compute_dtype,
|
|
156
|
+
bnb_4bit_quant_type="nf4",
|
|
157
|
+
bnb_4bit_use_double_quant=cfg.quantization == "nf4_double_quant",
|
|
158
|
+
)
|
|
159
|
+
kwargs.update(_model_dtype_kwarg(compute_dtype))
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.warning("BitsAndBytesConfig unavailable (%s); falling back to LoRA without 4-bit.", e)
|
|
162
|
+
|
|
163
|
+
if cfg.attention_implementation in {"sdpa", "flash_attention_2", "eager"}:
|
|
164
|
+
kwargs["attn_implementation"] = cfg.attention_implementation
|
|
165
|
+
|
|
166
|
+
# For QLoRA, bitsandbytes places shards on GPU automatically when
|
|
167
|
+
# device_map is set. For LoRA / full, we load on CPU and `.to(device)` after.
|
|
168
|
+
if cfg.method == "qlora" and cfg.device.startswith("cuda"):
|
|
169
|
+
kwargs["device_map"] = {"": 0}
|
|
170
|
+
|
|
171
|
+
def _load(**extra: Any):
|
|
172
|
+
merged = {**kwargs, **extra}
|
|
173
|
+
return AutoModelForCausalLM.from_pretrained(cfg.model_id, **merged)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
model = _load()
|
|
177
|
+
except TypeError as e:
|
|
178
|
+
kwargs.pop("attn_implementation", None)
|
|
179
|
+
if "dtype" in kwargs:
|
|
180
|
+
kwargs["torch_dtype"] = kwargs.pop("dtype")
|
|
181
|
+
try:
|
|
182
|
+
model = _load()
|
|
183
|
+
except TypeError:
|
|
184
|
+
log.warning("Retrying without dtype/torch_dtype after %s", e)
|
|
185
|
+
kwargs.pop("torch_dtype", None)
|
|
186
|
+
kwargs.pop("dtype", None)
|
|
187
|
+
model = _load()
|
|
188
|
+
|
|
189
|
+
if cfg.method != "qlora" and cfg.device.startswith("cuda"):
|
|
190
|
+
model = model.to(cfg.device)
|
|
191
|
+
|
|
192
|
+
# For QLoRA we need to prepare the model for 4-bit fine-tuning.
|
|
193
|
+
if cfg.method == "qlora":
|
|
194
|
+
try:
|
|
195
|
+
from peft import prepare_model_for_kbit_training # type: ignore
|
|
196
|
+
|
|
197
|
+
model = prepare_model_for_kbit_training(
|
|
198
|
+
model,
|
|
199
|
+
use_gradient_checkpointing=cfg.gradient_checkpointing,
|
|
200
|
+
)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
log.warning("prepare_model_for_kbit_training failed: %s", e)
|
|
203
|
+
|
|
204
|
+
if cfg.gradient_checkpointing and not getattr(model, "is_gradient_checkpointing", False):
|
|
205
|
+
try:
|
|
206
|
+
model.gradient_checkpointing_enable()
|
|
207
|
+
except Exception as e:
|
|
208
|
+
log.warning("gradient_checkpointing_enable failed: %s", e)
|
|
209
|
+
|
|
210
|
+
config = AutoConfig.from_pretrained(cfg.model_id)
|
|
211
|
+
return model, config
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _attach_lora(model, cfg: BenchConfig, family: str):
|
|
215
|
+
if cfg.method == "full":
|
|
216
|
+
return model
|
|
217
|
+
from peft import get_peft_model # type: ignore
|
|
218
|
+
|
|
219
|
+
lora_cfg = _make_lora_config(cfg, family)
|
|
220
|
+
return get_peft_model(model, lora_cfg)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _torch_env() -> dict[str, Any]:
|
|
224
|
+
out: dict[str, Any] = {}
|
|
225
|
+
try:
|
|
226
|
+
import torch # type: ignore
|
|
227
|
+
|
|
228
|
+
out["torch_version"] = torch.__version__
|
|
229
|
+
out["cuda_version"] = getattr(torch.version, "cuda", "") or ""
|
|
230
|
+
out["cudnn_version"] = getattr(torch.backends.cudnn, "version", lambda: "")()
|
|
231
|
+
out["bf16_supported"] = bool(torch.cuda.is_bf16_supported()) if torch.cuda.is_available() else False
|
|
232
|
+
except Exception:
|
|
233
|
+
out["torch_version"] = "not installed"
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _gpu_snapshot_dict() -> dict[str, Any]:
|
|
238
|
+
info = probe_cuda()
|
|
239
|
+
if info.gpus:
|
|
240
|
+
return info.gpus[0].to_dict()
|
|
241
|
+
return {"available": False, "name": "unknown"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _build_estimate_total(cfg: BenchConfig, gpu_total_gb: float) -> float:
|
|
245
|
+
if gpu_total_gb <= 0.0:
|
|
246
|
+
return 0.0
|
|
247
|
+
req = EstimateRequest(
|
|
248
|
+
model_id=cfg.model_id,
|
|
249
|
+
method=cfg.method,
|
|
250
|
+
gpu_vram_gb=gpu_total_gb,
|
|
251
|
+
seq_len=cfg.seq_len,
|
|
252
|
+
micro_batch_size=cfg.micro_batch_size,
|
|
253
|
+
base_dtype=cfg.base_dtype,
|
|
254
|
+
quantization=cfg.quantization if cfg.method == "qlora" else "bf16",
|
|
255
|
+
lora_rank=cfg.lora_rank,
|
|
256
|
+
lora_target_scope=cfg.lora_target_scope,
|
|
257
|
+
optimizer=cfg.optimizer,
|
|
258
|
+
gradient_checkpointing=cfg.gradient_checkpointing,
|
|
259
|
+
attention_implementation=cfg.attention_implementation,
|
|
260
|
+
)
|
|
261
|
+
return estimate(req).memory.total_estimated_gb
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_bench(cfg: BenchConfig) -> BenchResult:
|
|
265
|
+
"""Run the benchmark and return a fully populated :class:`BenchResult`."""
|
|
266
|
+
snapshots: list[MemorySnapshot] = []
|
|
267
|
+
notes: list[str] = []
|
|
268
|
+
|
|
269
|
+
gpu = _gpu_snapshot_dict()
|
|
270
|
+
env = _torch_env()
|
|
271
|
+
|
|
272
|
+
# Pre-compute the static estimate for the same config so the result file
|
|
273
|
+
# is directly usable by ``canifinetune calibrate``.
|
|
274
|
+
estimated_total = 0.0
|
|
275
|
+
try:
|
|
276
|
+
md = fetch_metadata(cfg.model_id)
|
|
277
|
+
family = md.family
|
|
278
|
+
if cfg.record_estimate:
|
|
279
|
+
estimated_total = _build_estimate_total(
|
|
280
|
+
cfg, gpu_total_gb=float(gpu.get("total_vram_gb") or 0.0)
|
|
281
|
+
)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
notes.append(f"Could not resolve model metadata in advance: {e}")
|
|
284
|
+
family = "unknown"
|
|
285
|
+
|
|
286
|
+
result = BenchResult(
|
|
287
|
+
config=cfg,
|
|
288
|
+
model_family=family,
|
|
289
|
+
timestamp=utc_now_iso(),
|
|
290
|
+
env=env,
|
|
291
|
+
gpu=gpu,
|
|
292
|
+
snapshots=[],
|
|
293
|
+
oom=OomReport().to_dict(),
|
|
294
|
+
estimated_total_gb=estimated_total,
|
|
295
|
+
notes=notes,
|
|
296
|
+
method=cfg.method,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
import torch # type: ignore
|
|
301
|
+
except Exception as e:
|
|
302
|
+
result.success = False
|
|
303
|
+
result.notes.append(f"torch not importable: {e}")
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
if cfg.device.startswith("cuda") and not torch.cuda.is_available():
|
|
307
|
+
result.success = False
|
|
308
|
+
result.notes.append("CUDA not available; bench requires a GPU.")
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
_safe_clear()
|
|
312
|
+
reset_peak()
|
|
313
|
+
snapshots.append(snapshot("before_load"))
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
model, hf_cfg = _build_model(cfg)
|
|
317
|
+
except BaseException as e:
|
|
318
|
+
if is_oom(e):
|
|
319
|
+
result.oom = make_oom_report("model_load", e).to_dict()
|
|
320
|
+
result.success = False
|
|
321
|
+
result.notes.append(f"model load failed: {type(e).__name__}: {e}")
|
|
322
|
+
result.notes.append(traceback.format_exc(limit=2))
|
|
323
|
+
result.snapshots = [s.to_dict() for s in snapshots]
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
family = getattr(hf_cfg, "model_type", family) or family
|
|
327
|
+
result.model_family = family
|
|
328
|
+
snapshots.append(snapshot("after_load"))
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
model = _attach_lora(model, cfg, family)
|
|
332
|
+
except BaseException as e:
|
|
333
|
+
result.success = False
|
|
334
|
+
result.notes.append(f"LoRA attach failed: {type(e).__name__}: {e}")
|
|
335
|
+
result.snapshots = [s.to_dict() for s in snapshots]
|
|
336
|
+
return result
|
|
337
|
+
snapshots.append(snapshot("after_lora_attach"))
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
trainable = [p for p in model.parameters() if p.requires_grad]
|
|
341
|
+
optimizer = _build_optimizer(trainable, cfg.optimizer)
|
|
342
|
+
except BaseException as e:
|
|
343
|
+
result.success = False
|
|
344
|
+
result.notes.append(f"optimizer init failed: {type(e).__name__}: {e}")
|
|
345
|
+
result.snapshots = [s.to_dict() for s in snapshots]
|
|
346
|
+
return result
|
|
347
|
+
snapshots.append(snapshot("after_optimizer_init"))
|
|
348
|
+
|
|
349
|
+
model.train()
|
|
350
|
+
vocab_size = int(getattr(hf_cfg, "vocab_size", 32000))
|
|
351
|
+
|
|
352
|
+
step_times: list[float] = []
|
|
353
|
+
total_tokens = 0
|
|
354
|
+
last_loss: float | None = None
|
|
355
|
+
|
|
356
|
+
for step in range(cfg.steps):
|
|
357
|
+
try:
|
|
358
|
+
batch = make_batch(
|
|
359
|
+
batch_size=cfg.micro_batch_size,
|
|
360
|
+
seq_len=cfg.seq_len,
|
|
361
|
+
vocab_size=vocab_size,
|
|
362
|
+
device=cfg.device,
|
|
363
|
+
seed=step,
|
|
364
|
+
)
|
|
365
|
+
except BaseException as e:
|
|
366
|
+
if is_oom(e):
|
|
367
|
+
result.oom = make_oom_report("make_batch", e).to_dict()
|
|
368
|
+
result.notes.append(f"batch generation failed at step {step}: {e}")
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
t0 = time.perf_counter()
|
|
372
|
+
try:
|
|
373
|
+
outputs = model(
|
|
374
|
+
input_ids=batch.input_ids,
|
|
375
|
+
attention_mask=batch.attention_mask,
|
|
376
|
+
labels=batch.labels,
|
|
377
|
+
)
|
|
378
|
+
loss = outputs.loss
|
|
379
|
+
last_loss = float(loss.detach().to("cpu").item())
|
|
380
|
+
if step == 0:
|
|
381
|
+
snapshots.append(snapshot("after_first_forward"))
|
|
382
|
+
if not cfg.forward_only:
|
|
383
|
+
loss.backward()
|
|
384
|
+
if step == 0:
|
|
385
|
+
snapshots.append(snapshot("after_first_backward"))
|
|
386
|
+
optimizer.step()
|
|
387
|
+
optimizer.zero_grad(set_to_none=True)
|
|
388
|
+
if step == 0:
|
|
389
|
+
snapshots.append(snapshot("after_first_optimizer_step"))
|
|
390
|
+
torch.cuda.synchronize() if torch.cuda.is_available() else None
|
|
391
|
+
dt = time.perf_counter() - t0
|
|
392
|
+
step_times.append(dt)
|
|
393
|
+
total_tokens += cfg.micro_batch_size * cfg.seq_len
|
|
394
|
+
except BaseException as e:
|
|
395
|
+
stage = "forward" if step == 0 else f"step_{step}"
|
|
396
|
+
if is_oom(e):
|
|
397
|
+
result.oom = make_oom_report(stage, e).to_dict()
|
|
398
|
+
result.notes.append(
|
|
399
|
+
f"OOM at stage {stage}. Try lower micro_batch_size, smaller seq_len, "
|
|
400
|
+
"or enable gradient_checkpointing/QLoRA."
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
result.notes.append(f"step {step} failed: {type(e).__name__}: {e}")
|
|
404
|
+
result.success = False
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
snapshots.append(snapshot("after_run"))
|
|
408
|
+
|
|
409
|
+
final = snapshots[-1]
|
|
410
|
+
peak_alloc = max((s.max_allocated_gb for s in snapshots), default=0.0)
|
|
411
|
+
peak_reserved = max((s.max_reserved_gb for s in snapshots), default=0.0)
|
|
412
|
+
result.measured = {
|
|
413
|
+
"peak_allocated_gb": round(peak_alloc, 4),
|
|
414
|
+
"peak_reserved_gb": round(peak_reserved, 4),
|
|
415
|
+
"peak_total_gb": round(peak_reserved, 4), # reserved ~= what the allocator holds
|
|
416
|
+
"final_allocated_gb": round(final.allocated_gb, 4),
|
|
417
|
+
"final_reserved_gb": round(final.reserved_gb, 4),
|
|
418
|
+
"loss_last_step": last_loss,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if step_times:
|
|
422
|
+
result.avg_step_time_s = round(sum(step_times) / len(step_times), 4)
|
|
423
|
+
result.tokens_per_second = round(total_tokens / max(1e-6, sum(step_times)), 2)
|
|
424
|
+
|
|
425
|
+
result.snapshots = [s.to_dict() for s in snapshots]
|
|
426
|
+
|
|
427
|
+
# Cleanup; the runner expects to be callable again in-process.
|
|
428
|
+
with contextlib.suppress(UnboundLocalError):
|
|
429
|
+
del model, optimizer
|
|
430
|
+
_safe_clear()
|
|
431
|
+
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def result_path_for(
|
|
436
|
+
out_dir: Path | str,
|
|
437
|
+
cfg: BenchConfig,
|
|
438
|
+
*,
|
|
439
|
+
suffix: str = "",
|
|
440
|
+
) -> Path:
|
|
441
|
+
"""Compute a deterministic file path for this benchmark result."""
|
|
442
|
+
base = Path(out_dir)
|
|
443
|
+
safe_model = cfg.model_id.replace("/", "__")
|
|
444
|
+
name = (
|
|
445
|
+
f"{safe_model}_{cfg.method}_s{cfg.seq_len}_b{cfg.micro_batch_size}"
|
|
446
|
+
f"_r{cfg.lora_rank}_steps{cfg.steps}"
|
|
447
|
+
)
|
|
448
|
+
if suffix:
|
|
449
|
+
name = f"{name}_{suffix}"
|
|
450
|
+
return base / f"{name}.json"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Generate a tiny synthetic causal-LM batch for benchmark runs.
|
|
2
|
+
|
|
3
|
+
The runner only needs shape-realistic tokens to measure VRAM, so this module
|
|
4
|
+
fabricates them in memory instead of pulling a dataset off the Hub.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SyntheticBatch:
|
|
14
|
+
input_ids: object # torch.LongTensor
|
|
15
|
+
attention_mask: object # torch.LongTensor
|
|
16
|
+
labels: object # torch.LongTensor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_batch(
|
|
20
|
+
*,
|
|
21
|
+
batch_size: int,
|
|
22
|
+
seq_len: int,
|
|
23
|
+
vocab_size: int,
|
|
24
|
+
device: str = "cuda",
|
|
25
|
+
seed: int = 0,
|
|
26
|
+
) -> SyntheticBatch:
|
|
27
|
+
"""Produce one synthetic batch on ``device``.
|
|
28
|
+
|
|
29
|
+
The tokens are uniform random in ``[0, vocab_size)``. Labels equal inputs
|
|
30
|
+
so the loss computation has something to chew on; for QLoRA we mask the
|
|
31
|
+
pad token via the attention mask (no pad tokens here, all-ones mask).
|
|
32
|
+
"""
|
|
33
|
+
import torch # type: ignore
|
|
34
|
+
|
|
35
|
+
g = torch.Generator(device="cpu").manual_seed(seed)
|
|
36
|
+
input_ids = torch.randint(
|
|
37
|
+
low=0,
|
|
38
|
+
high=max(1, vocab_size),
|
|
39
|
+
size=(batch_size, seq_len),
|
|
40
|
+
dtype=torch.long,
|
|
41
|
+
generator=g,
|
|
42
|
+
)
|
|
43
|
+
attention_mask = torch.ones_like(input_ids)
|
|
44
|
+
labels = input_ids.clone()
|
|
45
|
+
if device.startswith("cuda"):
|
|
46
|
+
input_ids = input_ids.to(device, non_blocking=True)
|
|
47
|
+
attention_mask = attention_mask.to(device, non_blocking=True)
|
|
48
|
+
labels = labels.to(device, non_blocking=True)
|
|
49
|
+
return SyntheticBatch(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_text_dataset(num_rows: int, seq_len_chars: int = 256) -> list[dict[str, str]]:
|
|
53
|
+
"""A trivial instruction dataset for recipe smoke tests.
|
|
54
|
+
|
|
55
|
+
Every row is the same template, varying only by an index.
|
|
56
|
+
"""
|
|
57
|
+
rows = []
|
|
58
|
+
for i in range(num_rows):
|
|
59
|
+
instruction = "Summarize the following sentence in one short sentence."
|
|
60
|
+
text = (
|
|
61
|
+
f"Sample number {i}: open-source language models can be fine-tuned "
|
|
62
|
+
"with parameter-efficient methods like LoRA and QLoRA on consumer "
|
|
63
|
+
"GPUs when memory is managed carefully."
|
|
64
|
+
)
|
|
65
|
+
response = (
|
|
66
|
+
f"Sample {i}: open LLMs can be fine-tuned with LoRA/QLoRA on consumer GPUs."
|
|
67
|
+
)
|
|
68
|
+
rows.append(
|
|
69
|
+
{
|
|
70
|
+
"instruction": instruction[: seq_len_chars],
|
|
71
|
+
"input": text[: seq_len_chars],
|
|
72
|
+
"output": response[: seq_len_chars],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return rows
|