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.
Files changed (40) hide show
  1. canifinetune/__init__.py +6 -0
  2. canifinetune/bench/__init__.py +5 -0
  3. canifinetune/bench/memory_trace.py +69 -0
  4. canifinetune/bench/oom.py +34 -0
  5. canifinetune/bench/runner.py +450 -0
  6. canifinetune/bench/synthetic_data.py +75 -0
  7. canifinetune/cli.py +512 -0
  8. canifinetune/doctor.py +165 -0
  9. canifinetune/estimator/__init__.py +29 -0
  10. canifinetune/estimator/calibration.py +191 -0
  11. canifinetune/estimator/formulas.py +315 -0
  12. canifinetune/estimator/memory.py +250 -0
  13. canifinetune/estimator/model_metadata.py +342 -0
  14. canifinetune/estimator/recommender.py +228 -0
  15. canifinetune/recipes/__init__.py +5 -0
  16. canifinetune/recipes/generator.py +147 -0
  17. canifinetune/recipes/templates/README.md.j2 +56 -0
  18. canifinetune/recipes/templates/config.yaml.j2 +38 -0
  19. canifinetune/recipes/templates/dataset_format.md.j2 +35 -0
  20. canifinetune/recipes/templates/eval_smoke.py.j2 +56 -0
  21. canifinetune/recipes/templates/expected_vram.md.j2 +51 -0
  22. canifinetune/recipes/templates/requirements.txt.j2 +11 -0
  23. canifinetune/recipes/templates/run.sh.j2 +9 -0
  24. canifinetune/recipes/templates/sample_dataset.jsonl.j2 +8 -0
  25. canifinetune/recipes/templates/train.py.j2 +300 -0
  26. canifinetune/recipes/templates/troubleshooting.md.j2 +69 -0
  27. canifinetune/reports/__init__.py +11 -0
  28. canifinetune/reports/html.py +97 -0
  29. canifinetune/reports/markdown.py +167 -0
  30. canifinetune/utils/__init__.py +1 -0
  31. canifinetune/utils/gpu.py +145 -0
  32. canifinetune/utils/hf.py +60 -0
  33. canifinetune/utils/logging.py +48 -0
  34. canifinetune/utils/subprocess.py +30 -0
  35. canifinetune/utils/units.py +96 -0
  36. canifinetune-0.1.0.dist-info/METADATA +251 -0
  37. canifinetune-0.1.0.dist-info/RECORD +40 -0
  38. canifinetune-0.1.0.dist-info/WHEEL +4 -0
  39. canifinetune-0.1.0.dist-info/entry_points.txt +2 -0
  40. canifinetune-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ """canifinetune: estimate, benchmark, and recipe LLM fine-tuning on consumer GPUs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ """Local benchmark runner: measure actual VRAM during a few training steps."""
2
+
3
+ from .runner import BenchConfig, BenchResult, run_bench
4
+
5
+ __all__ = ["BenchConfig", "BenchResult", "run_bench"]
@@ -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