train_lora

QLoRA fine-tune of one preset base model on marola's dataset — Tier 2 in README.md.

STATUS: the tiny preset has been run end to end (see finetune/README.md); every larger preset is written against the documented peft/transformers/trl APIs and NOT RUN here. Treat a first run on a new preset as a debugging session, not a build step.

Usage: pip install -r requirements.txt huggingface-cli login # only the gated base preset needs this python build_dataset.py # or: just finetune-dataset python train_lora.py --preset qwen-7b --epochs 3 # then convert out//adapter with llama.cpp's convert_lora_to_gguf.py and # just finetune-adapter-model preset=qwen-7b

Presets (see PRESETS): tiny (SmolLM2-360M, ungated, Apache-2.0, trains on CPU in minutes — the default), small/base (Llama 3.2 1B/3B; the 3B is gated), qwen-4b/qwen-7b/qwen-14b/ qwen-27b (Apache-2.0 throughout). tiny is the default rather than small so an unqualified run never silently produces a Llama derivative: the Llama 3.2 Community Licence requires such a model's name to begin with "Llama" and to ship the agreement plus a "Built with Llama" notice (see finetune/README.md). Both Llama presets remain available and are fine to use — they just have to be chosen, and their obligations met, on purpose.

Switching base is safe by construction: each preset trains into its own out/<preset>/ directory, and a run refuses to start if the directory it is about to write already holds another base model's work. On CPU pass --no-4bit (bitsandbytes needs CUDA).

  1"""QLoRA fine-tune of one preset base model on marola's dataset — Tier 2 in README.md.
  2
  3STATUS: the `tiny` preset has been run end to end (see finetune/README.md); every larger preset is
  4written against the documented peft/transformers/trl APIs and NOT RUN here. Treat a first run on a
  5new preset as a debugging session, not a build step.
  6
  7Usage:
  8    pip install -r requirements.txt
  9    huggingface-cli login                      # only the gated `base` preset needs this
 10    python build_dataset.py                    # or: just finetune-dataset
 11    python train_lora.py --preset qwen-7b --epochs 3
 12    # then convert out/<preset>/adapter with llama.cpp's convert_lora_to_gguf.py and
 13    #   just finetune-adapter-model preset=qwen-7b
 14
 15Presets (see PRESETS): `tiny` (SmolLM2-360M, ungated, Apache-2.0, trains on CPU in minutes — **the
 16default**), `small`/`base` (Llama 3.2 1B/3B; the 3B is gated), `qwen-4b`/`qwen-7b`/`qwen-14b`/
 17`qwen-27b` (Apache-2.0 throughout). tiny is the default rather than small so an unqualified run
 18never silently produces a *Llama derivative*: the Llama 3.2 Community Licence requires such a
 19model's name to begin with "Llama" and to ship the agreement plus a "Built with Llama" notice (see
 20finetune/README.md). Both Llama presets remain available and are fine to use — they just have to
 21be chosen, and their obligations met, on purpose.
 22
 23Switching base is safe by construction: each preset trains into its own `out/<preset>/` directory,
 24and a run refuses to start if the directory it is about to write already holds another base
 25model's work. On CPU pass --no-4bit (bitsandbytes needs CUDA).
 26"""
 27
 28from __future__ import annotations
 29
 30import argparse
 31import json
 32import re
 33from pathlib import Path
 34
 35# Iterate fast on a tiny model, then re-run the same script on the real one: the dataset, the LoRA
 36# config and the GGUF/Ollama steps are identical, only `--preset` changes.
 37#
 38# Every field has exactly one consumer, so a new preset cannot half-exist:
 39#   hf/ollama      the HF id to train from, and the Ollama model an adapter trained on it attaches
 40#                  to — `ollama create` fails loudly on a tag that does not exist, but attaching to
 41#                  the WRONG one of a family loads fine and then answers nonsense, so both the tag
 42#                  and its variant (instruct vs thinking) are part of the identity.
 43#   params_b       preflight's VRAM/RAM/disk estimator, and the batch/accum split below.
 44#   gated          whether a Hugging Face login is needed before the weights download.
 45#   licence        an HF licence *id*, passed through to publish_hf.py --base-license.
 46#   name_prefix    Meta's Community Licence requires a Llama derivative's name to start with
 47#                  "Llama-" (MIP-0025 §5.1(3)); merge_export.py reads this instead of guessing
 48#                  from the id.
 49#   thinking       the base's chat template emits reasoning blocks, which changes what an SFT run
 50#                  teaches and what the Ollama model answers with.
 51#   target_modules which projections LoRA attaches to. peft matches by suffix and silently skips
 52#                  what it does not find, so a model whose attention is not q/k/v/o_proj needs its
 53#                  own list rather than the standard one.
 54ATTENTION_AND_MLP = (
 55    "q_proj",
 56    "k_proj",
 57    "v_proj",
 58    "o_proj",
 59    "gate_proj",
 60    "up_proj",
 61    "down_proj",
 62)
 63
 64_DEFAULTS = {
 65    "gated": False,
 66    "licence": "apache-2.0",
 67    "name_prefix": "",
 68    "thinking": False,
 69    "target_modules": ATTENTION_AND_MLP,
 70}
 71
 72PRESETS = {
 73    "tiny": {
 74        **_DEFAULTS,
 75        "hf": "HuggingFaceTB/SmolLM2-360M-Instruct",
 76        "ollama": "smollm2:360m",
 77        "params_b": 0.36,
 78    },
 79    "small": {
 80        **_DEFAULTS,
 81        "hf": "unsloth/Llama-3.2-1B-Instruct",
 82        "ollama": "llama3.2:1b",
 83        "params_b": 1.24,
 84        "licence": "llama3.2",
 85        "name_prefix": "Llama-",
 86    },
 87    "base": {
 88        **_DEFAULTS,
 89        "hf": "meta-llama/Llama-3.2-3B-Instruct",
 90        "ollama": "llama3.2",
 91        "gated": True,
 92        "params_b": 3.2,
 93        "licence": "llama3.2",
 94        "name_prefix": "Llama-",
 95    },
 96    # Qwen: Apache-2.0 throughout, so no naming obligation of any kind — the reason these are the
 97    # recommended step up from `tiny` rather than the Llama presets above. Sizes and licences
 98    # verified against huggingface.co/api/models/<id>, Ollama tags against ollama.com/library,
 99    # both on 2026-09-12. Qwen2.5-3B-Instruct is deliberately absent: its card says `other`, not
100    # apache-2.0, unlike every other size in the family.
101    "qwen-4b": {
102        **_DEFAULTS,
103        "hf": "Qwen/Qwen3-4B-Instruct-2507",
104        # NOT `qwen3:4b` — that tag is 4b-thinking-2507 (digest 359d7dd4bcda), a different
105        # checkpoint from the Instruct-2507 weights trained here (0edcdef34593).
106        "ollama": "qwen3:4b-instruct",
107        "params_b": 4.02,
108    },
109    "qwen-7b": {
110        **_DEFAULTS,
111        "hf": "Qwen/Qwen2.5-7B-Instruct",
112        "ollama": "qwen2.5:7b",
113        "params_b": 7.62,
114    },
115    "qwen-14b": {
116        **_DEFAULTS,
117        "hf": "Qwen/Qwen2.5-14B-Instruct",
118        "ollama": "qwen2.5:14b",
119        "params_b": 14.77,
120    },
121    # The odd one out, and the reason `target_modules`/`thinking` are preset fields at all.
122    # Qwen3.8-27B is post-trained (not a base checkpoint), multimodal
123    # (`Qwen3_5ForConditionalGeneration`, pipeline image-text-to-text — the merge drops the vision
124    # tower) and hybrid-attention: only 16 of its 64 layers use q/k/v/o_proj, the other 48 are
125    # linear-attention layers with different names, so the standard target list would quietly
126    # train a quarter of the attention stack. Its template also wraps every assistant turn in an
127    # empty <think></think> block. NOT RUN here — treat a first run as an experiment, not a build.
128    "qwen-27b": {
129        **_DEFAULTS,
130        "hf": "Qwen/Qwen3.8-27B",
131        "ollama": "qwen3.8:27b",
132        "params_b": 27.78,
133        "thinking": True,
134        "target_modules": "all-linear",
135    },
136}
137
138PRESET_FIELDS = (
139    "hf",
140    "ollama",
141    "gated",
142    "params_b",
143    "licence",
144    "name_prefix",
145    "thinking",
146    "target_modules",
147)
148
149BASE_MARKER = ".marola-base"
150
151
152def preset_for(hf_id: str) -> str | None:
153    """The preset name that owns this HF id, or None for a `--base` outside the table."""
154    for name, p in PRESETS.items():
155        if p["hf"] == hf_id:
156            return name
157    return None
158
159
160def ollama_base_for(hf_id: str) -> str:
161    """The `FROM` line Modelfile.adapter needs — the adapter only fits the family it was trained on."""
162    name = preset_for(hf_id)
163    return PRESETS[name]["ollama"] if name else "<the Ollama model matching " + hf_id + ">"
164
165
166def run_slug(hf_id: str) -> str:
167    """The per-run directory name: the preset when there is one, else a slug of the model id.
168
169    Artifacts are namespaced by this rather than shared, because everything downstream of training
170    is model-shaped: an adapter, a merged checkpoint and a GGUF built from SmolLM2 mean nothing to
171    a Qwen run, and `save_total_limit` deletes by step number without looking at whose checkpoint
172    it is — the two bases' step counts differ, so a shared directory loses whichever run wrote
173    fewer steps.
174    """
175    name = preset_for(hf_id)
176    if name:
177        return name
178    return re.sub(r"[^a-z0-9]+", "-", hf_id.rsplit("/", 1)[-1].lower()).strip("-") or "custom"
179
180
181def run_dir(hf_id: str) -> Path:
182    """`finetune/out/<preset>` — every artifact of one base's run lives under here."""
183    return Path(__file__).parent / "out" / run_slug(hf_id)
184
185
186def default_out(hf_id: str, kind: str = "adapter") -> Path:
187    """`finetune/out/<preset>/adapter` — see run_slug for why this is not `finetune/out/adapter`."""
188    return run_dir(hf_id) / kind
189
190
191def has_artifacts(out: Path) -> bool:
192    """Does this directory already hold a training run's output?"""
193    return bool(list(out.glob("checkpoint-*"))) or (out / "adapter_config.json").exists()
194
195
196def base_conflict(marker_text: str | None, base: str) -> bool:
197    """True when a directory's recorded base model is not the one about to be trained."""
198    return bool(marker_text) and marker_text.strip() != base
199
200
201def read_marker(out: Path) -> str | None:
202    marker = out / BASE_MARKER
203    return marker.read_text() if marker.exists() else None
204
205
206def check_base(out: Path, base: str) -> None:
207    """Refuse to write a second base model's run into another's directory.
208
209    Optimizer, scheduler and adapter shapes are all model-specific: resuming a SmolLM2 checkpoint
210    into a Qwen run either explodes with a shape error or, worse, trains something meaningless.
211    This runs whether or not `--resume` was passed, because an unguarded run does not resume the
212    other base's work — it overwrites it, and `save_total_limit` can delete it mid-run.
213    """
214    if not out.exists():
215        return
216    previous = read_marker(out)
217    if base_conflict(previous, base) and has_artifacts(out):
218        raise SystemExit(
219            f"train_lora: {out} holds a run trained from {previous.strip()!r}, but this run uses "
220            f"{base!r}. Refusing to mix base models in one directory — delete {out}, or pass "
221            "--out for this base (the default is already per-preset)."
222        )
223
224
225def adapter_base(adapter: Path) -> str | None:
226    """Which base model an existing LoRA adapter was trained on, per its own adapter_config.json."""
227    cfg = Path(adapter) / "adapter_config.json"
228    if not cfg.exists():
229        return None
230    try:
231        return json.loads(cfg.read_text()).get("base_model_name_or_path")
232    except json.JSONDecodeError:
233        return None
234
235
236def check_adapter_base(adapter: Path, base: str) -> None:
237    """Refuse a mismatched adapter *before* the base model loads.
238
239    peft raises on its own eventually, but only after transformers has pulled a full checkpoint
240    into RAM — up to tens of GB for the larger presets. This is one JSON read.
241    """
242    found = adapter_base(adapter)
243    if found and found != base:
244        raise SystemExit(
245            f"the adapter at {adapter} was trained on {found!r}, not {base!r}. A LoRA adapter only "
246            "fits the family it was trained on — point --sft-adapter/--adapter at this base's run "
247            "(finetune/out/<preset>/), or retrain."
248        )
249
250
251def latest_checkpoint(out: Path) -> str | None:
252    """The newest `checkpoint-N` under --out, or None to train from scratch.
253
254    Decided from what is on disk rather than from the flag alone: `--resume` on a clean machine
255    must start from scratch, not fail, or the first run of any CI job breaks.
256    """
257    ckpts = sorted(
258        (d for d in out.glob("checkpoint-*") if d.is_dir()),
259        key=lambda d: int(d.name.rsplit("-", 1)[1]),
260    )
261    return str(ckpts[-1]) if ckpts else None
262
263
264def mark_base(out: Path, base: str) -> None:
265    out.mkdir(parents=True, exist_ok=True)
266    (out / BASE_MARKER).write_text(base + "\n")
267
268
269def self_test() -> int:
270    """Everything above is pure or filesystem-only — no torch, no network, seconds to run."""
271    import tempfile
272
273    fails = 0
274
275    def ok(got, want, label):
276        nonlocal fails
277        if got == want:
278            print(f"  ok   {label}")
279        else:
280            fails += 1
281            print(f"  FAIL {label} — got {got!r}, want {want!r}")
282
283    for name, p in PRESETS.items():
284        ok(sorted(p), sorted(PRESET_FIELDS), f"{name} declares every preset field")
285        ok(
286            p["licence"] in ("apache-2.0", "llama3.2"),
287            True,
288            f"{name}'s licence is an HF id, not prose (publish_hf --base-license takes it)",
289        )
290        ok(
291            p["name_prefix"] == ("Llama-" if "llama" in p["hf"].lower() else ""),
292            True,
293            f"{name}'s name_prefix matches Meta's obligation for its base",
294        )
295
296    ok(preset_for("Qwen/Qwen2.5-7B-Instruct"), "qwen-7b", "a base id resolves back to its preset")
297    ok(preset_for("mistralai/Whatever"), None, "an unknown base id resolves to no preset")
298    ok(ollama_base_for(PRESETS["qwen-27b"]["hf"]), "qwen3.8:27b", "27B's Ollama tag is qwen3.8")
299    ok(run_slug("Qwen/Qwen2.5-7B-Instruct"), "qwen-7b", "the run directory is the preset name")
300    ok(run_slug("acme/Some_Model-v2"), "some-model-v2", "an off-table base still gets a slug")
301    ok(
302        default_out("Qwen/Qwen2.5-7B-Instruct").parts[-2:],
303        ("qwen-7b", "adapter"),
304        "the default --out is namespaced by preset",
305    )
306    ok(
307        default_out(PRESETS["tiny"]["hf"]) != default_out(PRESETS["qwen-7b"]["hf"]),
308        True,
309        "tiny and qwen-7b cannot land in the same directory",
310    )
311
312    ok(base_conflict("a\n", "b"), True, "a different recorded base is a conflict")
313    ok(base_conflict("a\n", "a"), False, "the same base is not a conflict")
314
315    with tempfile.TemporaryDirectory() as tmp:
316        ad = Path(tmp) / "adapter"
317        ad.mkdir()
318        smol, qwen = PRESETS["tiny"]["hf"], PRESETS["qwen-7b"]["hf"]
319        check_adapter_base(ad, qwen)  # no adapter_config.json yet — nothing to contradict
320        (ad / "adapter_config.json").write_text(json.dumps({"base_model_name_or_path": smol}))
321        ok(adapter_base(ad), smol, "an adapter reports the base it was trained on")
322        check_adapter_base(ad, smol)
323        try:
324            check_adapter_base(ad, qwen)
325            ok("no refusal", "SystemExit", "a foreign adapter must be refused before loading")
326        except SystemExit as exc:
327            ok(smol in str(exc), True, "the refusal names the adapter's real base")
328    ok(base_conflict(None, "a"), False, "an unmarked directory is not a conflict")
329
330    with tempfile.TemporaryDirectory() as tmp:
331        out = Path(tmp) / "adapter"
332        smol, qwen = PRESETS["tiny"]["hf"], PRESETS["qwen-7b"]["hf"]
333        check_base(out, smol)  # nothing on disk yet
334        mark_base(out, smol)
335        (out / "checkpoint-40").mkdir()
336        ok(latest_checkpoint(out), str(out / "checkpoint-40"), "the newest checkpoint is found")
337        try:
338            check_base(out, qwen)
339            ok("no refusal", "SystemExit", "switching base in a used directory must be refused")
340        except SystemExit as exc:
341            ok(smol in str(exc) and qwen in str(exc), True, "the refusal names both base models")
342        check_base(out, smol)  # the same base is still fine
343        (out / "checkpoint-40").rmdir()
344        ok(latest_checkpoint(out), None, "an empty directory resumes from scratch")
345
346    print("train_lora self-test:", "ok" if not fails else f"{fails} FAILED")
347    return 1 if fails else 0
348
349
350def main() -> None:
351    ap = argparse.ArgumentParser()
352    ap.add_argument(
353        "--preset",
354        choices=sorted(PRESETS),
355        default=None,
356        help="tiny = SmolLM2-360M (CPU, minutes, ungated — for iterating on the dataset); "
357        "small/base = Llama 3.2 1B/3B (the 3B is gated, and both carry Meta's naming obligation); "
358        "qwen-4b/7b/14b/27b = Apache-2.0 all the way down, the recommended step up from tiny. "
359        "Each preset trains into its own out/<preset>/ directory",
360    )
361    ap.add_argument(
362        "--base",
363        default=None,
364        help="explicit HF model id; overrides --preset. An id in the preset table still gets that "
365        "preset's directory, Ollama tag, licence and LoRA targets",
366    )
367    ap.add_argument(
368        "--self-test",
369        action="store_true",
370        help="check the preset table and the base-switching guards; no torch, no network",
371    )
372    ap.add_argument(
373        "--resume",
374        action="store_true",
375        help="continue from the newest checkpoint in --out if one exists. Trainer checkpoints "
376        "carry optimizer, scheduler, RNG and step state, so this resumes mid-epoch rather than "
377        "restarting the epoch — what makes a multi-day or interrupted run survivable",
378    )
379    ap.add_argument(
380        "--save-steps",
381        type=int,
382        default=200,
383        help="checkpoint every N steps; with --resume this bounds what a crash costs",
384    )
385    ap.add_argument("--data", default=str(Path(__file__).parent / "data"))
386    ap.add_argument(
387        "--out",
388        default=None,
389        help="where the adapter and checkpoints go (default: finetune/out/<preset>/adapter)",
390    )
391    ap.add_argument("--epochs", type=int, default=3)
392    ap.add_argument("--lr", type=float, default=2e-4)
393    ap.add_argument("--rank", type=int, default=16)
394    ap.add_argument("--max-len", type=int, default=2048)
395    ap.add_argument(
396        "--no-4bit", action="store_true", help="skip bitsandbytes 4-bit (CPU / no CUDA)"
397    )
398    ap.add_argument(
399        "--device",
400        choices=("auto", "cuda", "cpu", "hybrid"),
401        default="auto",
402        help="auto: cuda when available. hybrid: fill the GPU and spill the rest to CPU RAM "
403        "(accelerate device_map='auto' + max_memory), for a model too large for VRAM alone",
404    )
405    ap.add_argument(
406        "--gpu-mem-gb",
407        type=float,
408        default=None,
409        help="VRAM ceiling for --device hybrid; default is 90%% of the card, leaving headroom for "
410        "activations",
411    )
412    ap.add_argument(
413        "--batch", type=int, default=None, help="per-device batch size (default: picked by size)"
414    )
415    ap.add_argument(
416        "--grad-accum",
417        type=int,
418        default=None,
419        help="gradient accumulation steps (default: by size)",
420    )
421    ap.add_argument(
422        "--no-packing",
423        action="store_true",
424        help="disable example packing; packing is the single biggest throughput win on a corpus of "
425        "short examples like marola's (~2.8k rows, most far below max-len)",
426    )
427    ap.add_argument(
428        "--no-grad-checkpointing",
429        action="store_true",
430        help="disable gradient checkpointing; it trades ~20%% speed for a large drop in activation "
431        "memory, which is what makes the bigger presets fit at all",
432    )
433    args = ap.parse_args()
434    if args.self_test:
435        raise SystemExit(self_test())
436
437    if not args.base:
438        args.base = PRESETS[args.preset or "tiny"]["hf"]
439    # An explicit --base that happens to be a preset's model still gets that preset's settings:
440    # the table is keyed by what the model IS, not by how it was named on the command line.
441    preset_name = args.preset or preset_for(args.base)
442    preset = PRESETS.get(preset_name or "", {})
443    if not args.out:
444        args.out = str(default_out(args.base))
445    out = Path(args.out)
446    check_base(out, args.base)
447    print(
448        f"base model: {args.base} ({preset_name or 'off-table'}) — Ollama FROM for "
449        f"Modelfile.adapter: {ollama_base_for(args.base)}; adapter -> {out}"
450    )
451    if preset.get("thinking"):
452        print(
453            "NOTE: this base's chat template wraps every assistant turn in a reasoning block, so "
454            "the fine-tune teaches that shape too — check one sample before publishing."
455        )
456
457    import torch
458    from datasets import load_dataset
459    from peft import LoraConfig
460    from transformers import AutoModelForCausalLM, AutoTokenizer
461    from trl import SFTConfig, SFTTrainer
462
463    cuda = torch.cuda.is_available()
464    device = args.device
465    if device == "auto":
466        device = "cuda" if cuda else "cpu"
467    if device in ("cuda", "hybrid") and not cuda:
468        raise SystemExit(
469            f"train_lora: --device {device} needs a working CUDA device; torch.cuda.is_available() "
470            "is False. Check `nvidia-smi` — a present card with a broken driver looks exactly like "
471            "no card at all here. Use --device cpu to run anyway."
472        )
473
474    # TF32 costs nothing on Ampere and later and speeds up every matmul that is not already bf16.
475    if cuda:
476        torch.backends.cuda.matmul.allow_tf32 = True
477        torch.backends.cudnn.allow_tf32 = True
478
479    tok = AutoTokenizer.from_pretrained(args.base)
480    tok.pad_token = tok.pad_token or tok.eos_token
481
482    model_kwargs: dict = {"torch_dtype": torch.bfloat16 if cuda else torch.float32}
483    use_4bit = not args.no_4bit and device in ("cuda", "hybrid")
484    if use_4bit:
485        from transformers import BitsAndBytesConfig
486
487        model_kwargs["quantization_config"] = BitsAndBytesConfig(
488            load_in_4bit=True,
489            bnb_4bit_quant_type="nf4",
490            bnb_4bit_compute_dtype=torch.bfloat16,
491            # Quantizing the quantization constants too — ~0.4 bits/param less memory for no
492            # measurable quality cost, which is free headroom on a 24 GB card at 27B.
493            bnb_4bit_use_double_quant=True,
494        )
495    if device == "cuda":
496        model_kwargs["device_map"] = "auto"
497    elif device == "hybrid":
498        # Fill the GPU to a ceiling, spill the remainder to CPU RAM. Slower per step than pure GPU
499        # — every offloaded layer crosses PCIe twice — but it is the difference between running and
500        # an OOM when the model does not fit in VRAM alone. This machine has 188 GB of RAM, so the
501        # CPU side is effectively unbounded.
502        ceiling = args.gpu_mem_gb or (torch.cuda.get_device_properties(0).total_memory / 1e9 * 0.90)
503        model_kwargs["device_map"] = "auto"
504        model_kwargs["max_memory"] = {0: f"{ceiling:.0f}GiB", "cpu": "160GiB"}
505        print(f"hybrid: up to {ceiling:.0f} GiB on the GPU, the rest offloaded to CPU RAM")
506
507    # SDPA is the fastest attention available without a flash-attn build, and unlike flash-attn it
508    # needs no extra wheel — worth asking for explicitly rather than taking the eager default.
509    model_kwargs["attn_implementation"] = "sdpa"
510    model = AutoModelForCausalLM.from_pretrained(args.base, **model_kwargs)
511    if use_4bit:
512        from peft import prepare_model_for_kbit_training
513
514        model = prepare_model_for_kbit_training(
515            model, use_gradient_checkpointing=not args.no_grad_checkpointing
516        )
517
518    data = load_dataset(
519        "json", data_files={"train": f"{args.data}/train.jsonl", "eval": f"{args.data}/eval.jsonl"}
520    )
521
522    # peft matches target modules by suffix and skips what it cannot find, so a hybrid attention
523    # stack needs "all-linear" rather than the q/k/v/o list every other preset uses.
524    targets = preset.get("target_modules", ATTENTION_AND_MLP)
525    lora = LoraConfig(
526        r=args.rank,
527        lora_alpha=2 * args.rank,
528        lora_dropout=0.05,
529        bias="none",
530        task_type="CAUSAL_LM",
531        target_modules="all-linear" if targets == "all-linear" else list(targets),
532    )
533    # Effective batch stays ~8 regardless of size; only how it is split changes. Bigger models get
534    # a smaller per-device batch and more accumulation, because activation memory scales with both
535    # batch and model width.
536    params_b = preset.get("params_b", 1.0)
537    if args.batch is not None:
538        batch = args.batch
539    elif device == "cpu":
540        batch = 1
541    else:
542        batch = 4 if params_b <= 2 else 2 if params_b <= 9 else 1
543    accum = args.grad_accum if args.grad_accum is not None else max(1, 8 // batch)
544
545    cfg = SFTConfig(
546        output_dir=args.out,
547        num_train_epochs=args.epochs,
548        learning_rate=args.lr,
549        per_device_train_batch_size=batch,
550        gradient_accumulation_steps=accum,
551        logging_steps=5,
552        eval_strategy="epoch",
553        save_strategy="epoch",
554        save_total_limit=1,  # a 27B checkpoint per epoch is ~100 GB of churn nobody reads
555        max_length=args.max_len,
556        bf16=cuda,
557        # marola's corpus is ~2.8k mostly-short rows against a 2048-token window, so without
558        # packing most of every batch is padding. Packing concatenates examples up to max_length
559        # and is the single biggest throughput win available here.
560        packing=not args.no_packing,
561        gradient_checkpointing=not args.no_grad_checkpointing,
562        gradient_checkpointing_kwargs={"use_reentrant": False},
563        # Fused optimizer when CUDA is present: fewer kernel launches per step, no accuracy cost.
564        optim="adamw_torch_fused" if cuda else "adamw_torch",
565        # Only useful when packing is off: it batches similar-length examples so a batch is
566        # mostly content rather than padding. With packing on it is redundant.
567        group_by_length=args.no_packing,
568        dataloader_num_workers=4,
569        report_to=[],
570    )
571    print(
572        f"device={device} batch={batch} accum={accum} (effective {batch * accum}) "
573        f"packing={not args.no_packing} grad_checkpointing={not args.no_grad_checkpointing} "
574        f"4bit={use_4bit}"
575    )
576    resume = latest_checkpoint(out) if args.resume else None
577    print(f"resuming from {resume}" if resume else "training from scratch (no checkpoint found)")
578    mark_base(out, args.base)
579    trainer = SFTTrainer(
580        model=model,
581        processing_class=tok,
582        peft_config=lora,
583        args=cfg,
584        train_dataset=data["train"],
585        eval_dataset=data["eval"],
586    )
587    trainer.train(resume_from_checkpoint=resume)
588    trainer.save_model(args.out)
589    print(
590        f"adapter saved to {args.out} — convert with llama.cpp convert_lora_to_gguf.py, then see Modelfile.adapter"
591    )
592
593
594if __name__ == "__main__":
595    main()
ATTENTION_AND_MLP = ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj')
PRESETS = {'tiny': {'gated': False, 'licence': 'apache-2.0', 'name_prefix': '', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'HuggingFaceTB/SmolLM2-360M-Instruct', 'ollama': 'smollm2:360m', 'params_b': 0.36}, 'small': {'gated': False, 'licence': 'llama3.2', 'name_prefix': 'Llama-', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'unsloth/Llama-3.2-1B-Instruct', 'ollama': 'llama3.2:1b', 'params_b': 1.24}, 'base': {'gated': True, 'licence': 'llama3.2', 'name_prefix': 'Llama-', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'meta-llama/Llama-3.2-3B-Instruct', 'ollama': 'llama3.2', 'params_b': 3.2}, 'qwen-4b': {'gated': False, 'licence': 'apache-2.0', 'name_prefix': '', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'Qwen/Qwen3-4B-Instruct-2507', 'ollama': 'qwen3:4b-instruct', 'params_b': 4.02}, 'qwen-7b': {'gated': False, 'licence': 'apache-2.0', 'name_prefix': '', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'Qwen/Qwen2.5-7B-Instruct', 'ollama': 'qwen2.5:7b', 'params_b': 7.62}, 'qwen-14b': {'gated': False, 'licence': 'apache-2.0', 'name_prefix': '', 'thinking': False, 'target_modules': ('q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'), 'hf': 'Qwen/Qwen2.5-14B-Instruct', 'ollama': 'qwen2.5:14b', 'params_b': 14.77}, 'qwen-27b': {'gated': False, 'licence': 'apache-2.0', 'name_prefix': '', 'thinking': True, 'target_modules': 'all-linear', 'hf': 'Qwen/Qwen3.8-27B', 'ollama': 'qwen3.8:27b', 'params_b': 27.78}}
PRESET_FIELDS = ('hf', 'ollama', 'gated', 'params_b', 'licence', 'name_prefix', 'thinking', 'target_modules')
BASE_MARKER = '.marola-base'
def preset_for(hf_id: str) -> str | None:
153def preset_for(hf_id: str) -> str | None:
154    """The preset name that owns this HF id, or None for a `--base` outside the table."""
155    for name, p in PRESETS.items():
156        if p["hf"] == hf_id:
157            return name
158    return None

The preset name that owns this HF id, or None for a --base outside the table.

def ollama_base_for(hf_id: str) -> str:
161def ollama_base_for(hf_id: str) -> str:
162    """The `FROM` line Modelfile.adapter needs — the adapter only fits the family it was trained on."""
163    name = preset_for(hf_id)
164    return PRESETS[name]["ollama"] if name else "<the Ollama model matching " + hf_id + ">"

The FROM line Modelfile.adapter needs — the adapter only fits the family it was trained on.

def run_slug(hf_id: str) -> str:
167def run_slug(hf_id: str) -> str:
168    """The per-run directory name: the preset when there is one, else a slug of the model id.
169
170    Artifacts are namespaced by this rather than shared, because everything downstream of training
171    is model-shaped: an adapter, a merged checkpoint and a GGUF built from SmolLM2 mean nothing to
172    a Qwen run, and `save_total_limit` deletes by step number without looking at whose checkpoint
173    it is — the two bases' step counts differ, so a shared directory loses whichever run wrote
174    fewer steps.
175    """
176    name = preset_for(hf_id)
177    if name:
178        return name
179    return re.sub(r"[^a-z0-9]+", "-", hf_id.rsplit("/", 1)[-1].lower()).strip("-") or "custom"

The per-run directory name: the preset when there is one, else a slug of the model id.

Artifacts are namespaced by this rather than shared, because everything downstream of training is model-shaped: an adapter, a merged checkpoint and a GGUF built from SmolLM2 mean nothing to a Qwen run, and save_total_limit deletes by step number without looking at whose checkpoint it is — the two bases' step counts differ, so a shared directory loses whichever run wrote fewer steps.

def run_dir(hf_id: str) -> pathlib.Path:
182def run_dir(hf_id: str) -> Path:
183    """`finetune/out/<preset>` — every artifact of one base's run lives under here."""
184    return Path(__file__).parent / "out" / run_slug(hf_id)

finetune/out/<preset> — every artifact of one base's run lives under here.

def default_out(hf_id: str, kind: str = 'adapter') -> pathlib.Path:
187def default_out(hf_id: str, kind: str = "adapter") -> Path:
188    """`finetune/out/<preset>/adapter` — see run_slug for why this is not `finetune/out/adapter`."""
189    return run_dir(hf_id) / kind

finetune/out/<preset>/adapter — see run_slug for why this is not finetune/out/adapter.

def has_artifacts(out: pathlib.Path) -> bool:
192def has_artifacts(out: Path) -> bool:
193    """Does this directory already hold a training run's output?"""
194    return bool(list(out.glob("checkpoint-*"))) or (out / "adapter_config.json").exists()

Does this directory already hold a training run's output?

def base_conflict(marker_text: str | None, base: str) -> bool:
197def base_conflict(marker_text: str | None, base: str) -> bool:
198    """True when a directory's recorded base model is not the one about to be trained."""
199    return bool(marker_text) and marker_text.strip() != base

True when a directory's recorded base model is not the one about to be trained.

def read_marker(out: pathlib.Path) -> str | None:
202def read_marker(out: Path) -> str | None:
203    marker = out / BASE_MARKER
204    return marker.read_text() if marker.exists() else None
def check_base(out: pathlib.Path, base: str) -> None:
207def check_base(out: Path, base: str) -> None:
208    """Refuse to write a second base model's run into another's directory.
209
210    Optimizer, scheduler and adapter shapes are all model-specific: resuming a SmolLM2 checkpoint
211    into a Qwen run either explodes with a shape error or, worse, trains something meaningless.
212    This runs whether or not `--resume` was passed, because an unguarded run does not resume the
213    other base's work — it overwrites it, and `save_total_limit` can delete it mid-run.
214    """
215    if not out.exists():
216        return
217    previous = read_marker(out)
218    if base_conflict(previous, base) and has_artifacts(out):
219        raise SystemExit(
220            f"train_lora: {out} holds a run trained from {previous.strip()!r}, but this run uses "
221            f"{base!r}. Refusing to mix base models in one directory — delete {out}, or pass "
222            "--out for this base (the default is already per-preset)."
223        )

Refuse to write a second base model's run into another's directory.

Optimizer, scheduler and adapter shapes are all model-specific: resuming a SmolLM2 checkpoint into a Qwen run either explodes with a shape error or, worse, trains something meaningless. This runs whether or not --resume was passed, because an unguarded run does not resume the other base's work — it overwrites it, and save_total_limit can delete it mid-run.

def adapter_base(adapter: pathlib.Path) -> str | None:
226def adapter_base(adapter: Path) -> str | None:
227    """Which base model an existing LoRA adapter was trained on, per its own adapter_config.json."""
228    cfg = Path(adapter) / "adapter_config.json"
229    if not cfg.exists():
230        return None
231    try:
232        return json.loads(cfg.read_text()).get("base_model_name_or_path")
233    except json.JSONDecodeError:
234        return None

Which base model an existing LoRA adapter was trained on, per its own adapter_config.json.

def check_adapter_base(adapter: pathlib.Path, base: str) -> None:
237def check_adapter_base(adapter: Path, base: str) -> None:
238    """Refuse a mismatched adapter *before* the base model loads.
239
240    peft raises on its own eventually, but only after transformers has pulled a full checkpoint
241    into RAM — up to tens of GB for the larger presets. This is one JSON read.
242    """
243    found = adapter_base(adapter)
244    if found and found != base:
245        raise SystemExit(
246            f"the adapter at {adapter} was trained on {found!r}, not {base!r}. A LoRA adapter only "
247            "fits the family it was trained on — point --sft-adapter/--adapter at this base's run "
248            "(finetune/out/<preset>/), or retrain."
249        )

Refuse a mismatched adapter before the base model loads.

peft raises on its own eventually, but only after transformers has pulled a full checkpoint into RAM — up to tens of GB for the larger presets. This is one JSON read.

def latest_checkpoint(out: pathlib.Path) -> str | None:
252def latest_checkpoint(out: Path) -> str | None:
253    """The newest `checkpoint-N` under --out, or None to train from scratch.
254
255    Decided from what is on disk rather than from the flag alone: `--resume` on a clean machine
256    must start from scratch, not fail, or the first run of any CI job breaks.
257    """
258    ckpts = sorted(
259        (d for d in out.glob("checkpoint-*") if d.is_dir()),
260        key=lambda d: int(d.name.rsplit("-", 1)[1]),
261    )
262    return str(ckpts[-1]) if ckpts else None

The newest checkpoint-N under --out, or None to train from scratch.

Decided from what is on disk rather than from the flag alone: --resume on a clean machine must start from scratch, not fail, or the first run of any CI job breaks.

def mark_base(out: pathlib.Path, base: str) -> None:
265def mark_base(out: Path, base: str) -> None:
266    out.mkdir(parents=True, exist_ok=True)
267    (out / BASE_MARKER).write_text(base + "\n")
def self_test() -> int:
270def self_test() -> int:
271    """Everything above is pure or filesystem-only — no torch, no network, seconds to run."""
272    import tempfile
273
274    fails = 0
275
276    def ok(got, want, label):
277        nonlocal fails
278        if got == want:
279            print(f"  ok   {label}")
280        else:
281            fails += 1
282            print(f"  FAIL {label} — got {got!r}, want {want!r}")
283
284    for name, p in PRESETS.items():
285        ok(sorted(p), sorted(PRESET_FIELDS), f"{name} declares every preset field")
286        ok(
287            p["licence"] in ("apache-2.0", "llama3.2"),
288            True,
289            f"{name}'s licence is an HF id, not prose (publish_hf --base-license takes it)",
290        )
291        ok(
292            p["name_prefix"] == ("Llama-" if "llama" in p["hf"].lower() else ""),
293            True,
294            f"{name}'s name_prefix matches Meta's obligation for its base",
295        )
296
297    ok(preset_for("Qwen/Qwen2.5-7B-Instruct"), "qwen-7b", "a base id resolves back to its preset")
298    ok(preset_for("mistralai/Whatever"), None, "an unknown base id resolves to no preset")
299    ok(ollama_base_for(PRESETS["qwen-27b"]["hf"]), "qwen3.8:27b", "27B's Ollama tag is qwen3.8")
300    ok(run_slug("Qwen/Qwen2.5-7B-Instruct"), "qwen-7b", "the run directory is the preset name")
301    ok(run_slug("acme/Some_Model-v2"), "some-model-v2", "an off-table base still gets a slug")
302    ok(
303        default_out("Qwen/Qwen2.5-7B-Instruct").parts[-2:],
304        ("qwen-7b", "adapter"),
305        "the default --out is namespaced by preset",
306    )
307    ok(
308        default_out(PRESETS["tiny"]["hf"]) != default_out(PRESETS["qwen-7b"]["hf"]),
309        True,
310        "tiny and qwen-7b cannot land in the same directory",
311    )
312
313    ok(base_conflict("a\n", "b"), True, "a different recorded base is a conflict")
314    ok(base_conflict("a\n", "a"), False, "the same base is not a conflict")
315
316    with tempfile.TemporaryDirectory() as tmp:
317        ad = Path(tmp) / "adapter"
318        ad.mkdir()
319        smol, qwen = PRESETS["tiny"]["hf"], PRESETS["qwen-7b"]["hf"]
320        check_adapter_base(ad, qwen)  # no adapter_config.json yet — nothing to contradict
321        (ad / "adapter_config.json").write_text(json.dumps({"base_model_name_or_path": smol}))
322        ok(adapter_base(ad), smol, "an adapter reports the base it was trained on")
323        check_adapter_base(ad, smol)
324        try:
325            check_adapter_base(ad, qwen)
326            ok("no refusal", "SystemExit", "a foreign adapter must be refused before loading")
327        except SystemExit as exc:
328            ok(smol in str(exc), True, "the refusal names the adapter's real base")
329    ok(base_conflict(None, "a"), False, "an unmarked directory is not a conflict")
330
331    with tempfile.TemporaryDirectory() as tmp:
332        out = Path(tmp) / "adapter"
333        smol, qwen = PRESETS["tiny"]["hf"], PRESETS["qwen-7b"]["hf"]
334        check_base(out, smol)  # nothing on disk yet
335        mark_base(out, smol)
336        (out / "checkpoint-40").mkdir()
337        ok(latest_checkpoint(out), str(out / "checkpoint-40"), "the newest checkpoint is found")
338        try:
339            check_base(out, qwen)
340            ok("no refusal", "SystemExit", "switching base in a used directory must be refused")
341        except SystemExit as exc:
342            ok(smol in str(exc) and qwen in str(exc), True, "the refusal names both base models")
343        check_base(out, smol)  # the same base is still fine
344        (out / "checkpoint-40").rmdir()
345        ok(latest_checkpoint(out), None, "an empty directory resumes from scratch")
346
347    print("train_lora self-test:", "ok" if not fails else f"{fails} FAILED")
348    return 1 if fails else 0

Everything above is pure or filesystem-only — no torch, no network, seconds to run.

def main() -> None:
351def main() -> None:
352    ap = argparse.ArgumentParser()
353    ap.add_argument(
354        "--preset",
355        choices=sorted(PRESETS),
356        default=None,
357        help="tiny = SmolLM2-360M (CPU, minutes, ungated — for iterating on the dataset); "
358        "small/base = Llama 3.2 1B/3B (the 3B is gated, and both carry Meta's naming obligation); "
359        "qwen-4b/7b/14b/27b = Apache-2.0 all the way down, the recommended step up from tiny. "
360        "Each preset trains into its own out/<preset>/ directory",
361    )
362    ap.add_argument(
363        "--base",
364        default=None,
365        help="explicit HF model id; overrides --preset. An id in the preset table still gets that "
366        "preset's directory, Ollama tag, licence and LoRA targets",
367    )
368    ap.add_argument(
369        "--self-test",
370        action="store_true",
371        help="check the preset table and the base-switching guards; no torch, no network",
372    )
373    ap.add_argument(
374        "--resume",
375        action="store_true",
376        help="continue from the newest checkpoint in --out if one exists. Trainer checkpoints "
377        "carry optimizer, scheduler, RNG and step state, so this resumes mid-epoch rather than "
378        "restarting the epoch — what makes a multi-day or interrupted run survivable",
379    )
380    ap.add_argument(
381        "--save-steps",
382        type=int,
383        default=200,
384        help="checkpoint every N steps; with --resume this bounds what a crash costs",
385    )
386    ap.add_argument("--data", default=str(Path(__file__).parent / "data"))
387    ap.add_argument(
388        "--out",
389        default=None,
390        help="where the adapter and checkpoints go (default: finetune/out/<preset>/adapter)",
391    )
392    ap.add_argument("--epochs", type=int, default=3)
393    ap.add_argument("--lr", type=float, default=2e-4)
394    ap.add_argument("--rank", type=int, default=16)
395    ap.add_argument("--max-len", type=int, default=2048)
396    ap.add_argument(
397        "--no-4bit", action="store_true", help="skip bitsandbytes 4-bit (CPU / no CUDA)"
398    )
399    ap.add_argument(
400        "--device",
401        choices=("auto", "cuda", "cpu", "hybrid"),
402        default="auto",
403        help="auto: cuda when available. hybrid: fill the GPU and spill the rest to CPU RAM "
404        "(accelerate device_map='auto' + max_memory), for a model too large for VRAM alone",
405    )
406    ap.add_argument(
407        "--gpu-mem-gb",
408        type=float,
409        default=None,
410        help="VRAM ceiling for --device hybrid; default is 90%% of the card, leaving headroom for "
411        "activations",
412    )
413    ap.add_argument(
414        "--batch", type=int, default=None, help="per-device batch size (default: picked by size)"
415    )
416    ap.add_argument(
417        "--grad-accum",
418        type=int,
419        default=None,
420        help="gradient accumulation steps (default: by size)",
421    )
422    ap.add_argument(
423        "--no-packing",
424        action="store_true",
425        help="disable example packing; packing is the single biggest throughput win on a corpus of "
426        "short examples like marola's (~2.8k rows, most far below max-len)",
427    )
428    ap.add_argument(
429        "--no-grad-checkpointing",
430        action="store_true",
431        help="disable gradient checkpointing; it trades ~20%% speed for a large drop in activation "
432        "memory, which is what makes the bigger presets fit at all",
433    )
434    args = ap.parse_args()
435    if args.self_test:
436        raise SystemExit(self_test())
437
438    if not args.base:
439        args.base = PRESETS[args.preset or "tiny"]["hf"]
440    # An explicit --base that happens to be a preset's model still gets that preset's settings:
441    # the table is keyed by what the model IS, not by how it was named on the command line.
442    preset_name = args.preset or preset_for(args.base)
443    preset = PRESETS.get(preset_name or "", {})
444    if not args.out:
445        args.out = str(default_out(args.base))
446    out = Path(args.out)
447    check_base(out, args.base)
448    print(
449        f"base model: {args.base} ({preset_name or 'off-table'}) — Ollama FROM for "
450        f"Modelfile.adapter: {ollama_base_for(args.base)}; adapter -> {out}"
451    )
452    if preset.get("thinking"):
453        print(
454            "NOTE: this base's chat template wraps every assistant turn in a reasoning block, so "
455            "the fine-tune teaches that shape too — check one sample before publishing."
456        )
457
458    import torch
459    from datasets import load_dataset
460    from peft import LoraConfig
461    from transformers import AutoModelForCausalLM, AutoTokenizer
462    from trl import SFTConfig, SFTTrainer
463
464    cuda = torch.cuda.is_available()
465    device = args.device
466    if device == "auto":
467        device = "cuda" if cuda else "cpu"
468    if device in ("cuda", "hybrid") and not cuda:
469        raise SystemExit(
470            f"train_lora: --device {device} needs a working CUDA device; torch.cuda.is_available() "
471            "is False. Check `nvidia-smi` — a present card with a broken driver looks exactly like "
472            "no card at all here. Use --device cpu to run anyway."
473        )
474
475    # TF32 costs nothing on Ampere and later and speeds up every matmul that is not already bf16.
476    if cuda:
477        torch.backends.cuda.matmul.allow_tf32 = True
478        torch.backends.cudnn.allow_tf32 = True
479
480    tok = AutoTokenizer.from_pretrained(args.base)
481    tok.pad_token = tok.pad_token or tok.eos_token
482
483    model_kwargs: dict = {"torch_dtype": torch.bfloat16 if cuda else torch.float32}
484    use_4bit = not args.no_4bit and device in ("cuda", "hybrid")
485    if use_4bit:
486        from transformers import BitsAndBytesConfig
487
488        model_kwargs["quantization_config"] = BitsAndBytesConfig(
489            load_in_4bit=True,
490            bnb_4bit_quant_type="nf4",
491            bnb_4bit_compute_dtype=torch.bfloat16,
492            # Quantizing the quantization constants too — ~0.4 bits/param less memory for no
493            # measurable quality cost, which is free headroom on a 24 GB card at 27B.
494            bnb_4bit_use_double_quant=True,
495        )
496    if device == "cuda":
497        model_kwargs["device_map"] = "auto"
498    elif device == "hybrid":
499        # Fill the GPU to a ceiling, spill the remainder to CPU RAM. Slower per step than pure GPU
500        # — every offloaded layer crosses PCIe twice — but it is the difference between running and
501        # an OOM when the model does not fit in VRAM alone. This machine has 188 GB of RAM, so the
502        # CPU side is effectively unbounded.
503        ceiling = args.gpu_mem_gb or (torch.cuda.get_device_properties(0).total_memory / 1e9 * 0.90)
504        model_kwargs["device_map"] = "auto"
505        model_kwargs["max_memory"] = {0: f"{ceiling:.0f}GiB", "cpu": "160GiB"}
506        print(f"hybrid: up to {ceiling:.0f} GiB on the GPU, the rest offloaded to CPU RAM")
507
508    # SDPA is the fastest attention available without a flash-attn build, and unlike flash-attn it
509    # needs no extra wheel — worth asking for explicitly rather than taking the eager default.
510    model_kwargs["attn_implementation"] = "sdpa"
511    model = AutoModelForCausalLM.from_pretrained(args.base, **model_kwargs)
512    if use_4bit:
513        from peft import prepare_model_for_kbit_training
514
515        model = prepare_model_for_kbit_training(
516            model, use_gradient_checkpointing=not args.no_grad_checkpointing
517        )
518
519    data = load_dataset(
520        "json", data_files={"train": f"{args.data}/train.jsonl", "eval": f"{args.data}/eval.jsonl"}
521    )
522
523    # peft matches target modules by suffix and skips what it cannot find, so a hybrid attention
524    # stack needs "all-linear" rather than the q/k/v/o list every other preset uses.
525    targets = preset.get("target_modules", ATTENTION_AND_MLP)
526    lora = LoraConfig(
527        r=args.rank,
528        lora_alpha=2 * args.rank,
529        lora_dropout=0.05,
530        bias="none",
531        task_type="CAUSAL_LM",
532        target_modules="all-linear" if targets == "all-linear" else list(targets),
533    )
534    # Effective batch stays ~8 regardless of size; only how it is split changes. Bigger models get
535    # a smaller per-device batch and more accumulation, because activation memory scales with both
536    # batch and model width.
537    params_b = preset.get("params_b", 1.0)
538    if args.batch is not None:
539        batch = args.batch
540    elif device == "cpu":
541        batch = 1
542    else:
543        batch = 4 if params_b <= 2 else 2 if params_b <= 9 else 1
544    accum = args.grad_accum if args.grad_accum is not None else max(1, 8 // batch)
545
546    cfg = SFTConfig(
547        output_dir=args.out,
548        num_train_epochs=args.epochs,
549        learning_rate=args.lr,
550        per_device_train_batch_size=batch,
551        gradient_accumulation_steps=accum,
552        logging_steps=5,
553        eval_strategy="epoch",
554        save_strategy="epoch",
555        save_total_limit=1,  # a 27B checkpoint per epoch is ~100 GB of churn nobody reads
556        max_length=args.max_len,
557        bf16=cuda,
558        # marola's corpus is ~2.8k mostly-short rows against a 2048-token window, so without
559        # packing most of every batch is padding. Packing concatenates examples up to max_length
560        # and is the single biggest throughput win available here.
561        packing=not args.no_packing,
562        gradient_checkpointing=not args.no_grad_checkpointing,
563        gradient_checkpointing_kwargs={"use_reentrant": False},
564        # Fused optimizer when CUDA is present: fewer kernel launches per step, no accuracy cost.
565        optim="adamw_torch_fused" if cuda else "adamw_torch",
566        # Only useful when packing is off: it batches similar-length examples so a batch is
567        # mostly content rather than padding. With packing on it is redundant.
568        group_by_length=args.no_packing,
569        dataloader_num_workers=4,
570        report_to=[],
571    )
572    print(
573        f"device={device} batch={batch} accum={accum} (effective {batch * accum}) "
574        f"packing={not args.no_packing} grad_checkpointing={not args.no_grad_checkpointing} "
575        f"4bit={use_4bit}"
576    )
577    resume = latest_checkpoint(out) if args.resume else None
578    print(f"resuming from {resume}" if resume else "training from scratch (no checkpoint found)")
579    mark_base(out, args.base)
580    trainer = SFTTrainer(
581        model=model,
582        processing_class=tok,
583        peft_config=lora,
584        args=cfg,
585        train_dataset=data["train"],
586        eval_dataset=data["eval"],
587    )
588    trainer.train(resume_from_checkpoint=resume)
589    trainer.save_model(args.out)
590    print(
591        f"adapter saved to {args.out} — convert with llama.cpp convert_lora_to_gguf.py, then see Modelfile.adapter"
592    )