File size: 11,049 Bytes
4e1037f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""QLoRA fine-tune of the student model for plan_autotune S3.

Runs ONLY on a Linux GPU box β€” primary target: **Kaggle free tier** (T4x2 or
P100, 30 GPU-h/week, sessions <=12h; see scripts/autotune/kaggle/), fallback:
rented RunPod/Vast 4090. Never on the Windows dev machine. Heavy imports
(torch/unsloth/trl/datasets) are lazy, inside `train()`, so repo gates
(ruff/mypy/pytest) stay green without them; `--help` works anywhere.

Precision is auto-detected: bf16 on Ampere+ (4090), fp16 on T4/P100 (Turing
and older have no bfloat16). Checkpoints are saved every --save-steps so a
Kaggle 12h session cut mid-epoch resumes with --resume (attach the previous
run's output and copy its checkpoints into --out first β€” the Kaggle notebook
does this automatically).

Kaggle T4 preset (fits 16GB, ~6-12h for 1 epoch):
    python train_qlora.py --epochs 1 --max-seq-len 4096 \\
        --batch-size 1 --grad-accum 16 --no-merge --resume

Node setup (see scripts/autotune/requirements_gpu.txt for install order):
    pip install unsloth && pip install -r scripts/autotune/requirements_gpu.txt

Train (defaults follow plan_autotune S3: r=16, alpha=32, lr 2e-4, 2 epochs):
    python scripts/autotune/train_qlora.py \\
        --train data/autotune/train.jsonl --val data/autotune/val.jsonl \\
        --out /workspace/qlora_out

Serve the result for the eval harness (merged dir is the simplest path):
    vllm serve /workspace/qlora_out/merged --port 8000 \\
        --served-model-name Qwen/Qwen2.5-Coder-7B-Instruct-sqltuned
    # Windows side: NL_SQL_LOCAL_LLM_BASE_URL=http://<host>:8000/v1 in .env,
    # then eval_baseline.py --provider local_vllm \\
    #   --sql-model Qwen/Qwen2.5-Coder-7B-Instruct-sqltuned --fewshot-top-k 0
    # (--sql-model MUST equal --served-model-name or vLLM 404s the request).
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

# Qwen2/2.5 chat-template markers used to mask loss onto assistant tokens only.
QWEN_USER_MARK = "<|im_start|>user\n"
QWEN_ASSISTANT_MARK = "<|im_start|>assistant\n"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--model", default="Qwen/Qwen2.5-Coder-7B-Instruct")
    parser.add_argument("--train", default="data/autotune/train.jsonl")
    parser.add_argument("--val", default="data/autotune/val.jsonl")
    parser.add_argument("--out", default="qlora_out")
    parser.add_argument("--epochs", type=float, default=2.0)
    parser.add_argument("--lr", type=float, default=2e-4)
    parser.add_argument("--rank", type=int, default=16)
    parser.add_argument("--alpha", type=int, default=32)
    parser.add_argument("--max-seq-len", type=int, default=8192)
    parser.add_argument("--batch-size", type=int, default=2)
    parser.add_argument("--grad-accum", type=int, default=8)
    parser.add_argument("--seed", type=int, default=0)
    # ~138 s/step measured on a Kaggle T4 (seq 4096, batch 1, grad-accum 16),
    # so 50 steps is roughly a two-hour checkpoint interval -- small enough that
    # a 12h session cut off mid-epoch loses little.
    parser.add_argument("--save-steps", type=int, default=50)
    parser.add_argument(
        "--val-rows",
        type=int,
        default=64,
        help="cap eval rows (0 = all); eval loss is a smoke signal, not the verdict",
    )
    parser.add_argument(
        "--max-steps",
        type=int,
        default=0,
        help="stop after N optimizer steps (0 = full run); smoke-tests the whole path",
    )
    parser.add_argument(
        "--resume",
        action="store_true",
        help="resume from the last checkpoint in <out>/checkpoints, if any",
    )
    parser.add_argument(
        "--no-merge",
        action="store_true",
        help="skip the merged-16bit export (adapter only)",
    )
    return parser.parse_args()


def load_jsonl(path: Path) -> list[dict[str, str]]:
    rows: list[dict[str, str]] = []
    with path.open(encoding="utf-8") as fh:
        for line in fh:
            if line.strip():
                rows.append(json.loads(line))
    return rows


def accepted_kwargs(cls: Any) -> set[str] | None:
    """Keyword names `cls(...)` really takes, or None when undiscoverable.

    trl renames config fields between releases and raises TypeError on a stale
    name β€” `max_seq_length` -> `max_length` is exactly what killed the first
    Kaggle run. Probing beats guessing per-key. None means "opaque **kwargs
    signature, pass everything through and let the callee decide".
    """
    import dataclasses
    import inspect

    names: set[str] = set()
    if dataclasses.is_dataclass(cls):
        names |= {f.name for f in dataclasses.fields(cls)}
    try:
        params = inspect.signature(cls.__init__).parameters
    except (TypeError, ValueError):
        return names or None
    if not names and any(p.kind is p.VAR_KEYWORD for p in params.values()):
        return None
    names |= {n for n, p in params.items() if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)}
    names.discard("self")
    return names or None


def train(args: argparse.Namespace) -> None:
    # Lazy heavy imports β€” GPU box only (see module docstring). unsloth MUST be
    # imported before trl/transformers: it patches them for the fast path.
    # isort: off
    from unsloth import FastLanguageModel, is_bfloat16_supported
    from unsloth.chat_templates import train_on_responses_only

    from datasets import Dataset
    from trl import SFTConfig, SFTTrainer

    # isort: on

    bf16_ok = is_bfloat16_supported()

    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=args.model,
        max_seq_length=args.max_seq_len,
        load_in_4bit=True,
        dtype=None,  # unsloth picks bf16 on Ampere+
    )
    model = FastLanguageModel.get_peft_model(
        model,
        r=args.rank,
        lora_alpha=args.alpha,
        lora_dropout=0.0,
        target_modules=[
            "q_proj",
            "k_proj",
            "v_proj",
            "o_proj",
            "gate_proj",
            "up_proj",
            "down_proj",
        ],
        use_gradient_checkpointing="unsloth",
        random_state=args.seed,
    )

    def to_text(rows: list[dict[str, str]]) -> Dataset:
        def render(row: dict[str, str]) -> dict[str, Any]:
            text = tokenizer.apply_chat_template(
                [
                    {"role": "user", "content": row["prompt"]},
                    {"role": "assistant", "content": row["completion"]},
                ],
                tokenize=False,
                add_generation_prompt=False,
            )
            return {"text": text}

        return Dataset.from_list([render(r) for r in rows])

    train_ds = to_text(load_jsonl(Path(args.train)))
    val_ds = to_text(load_jsonl(Path(args.val)))
    print(f"train={len(train_ds)} val={len(val_ds)}")
    # Eval here is a smoke signal, not the verdict (that is BIRD EA from the
    # harness). On a T4 a forward pass costs ~9 s, so evaluating all 500 val
    # rows would burn over an hour of a 12h session for a number we do not
    # judge by. A smoke run needs even less.
    val_cap = 8 if args.max_steps > 0 else args.val_rows
    if val_cap:
        val_ds = val_ds.select(range(min(val_cap, len(val_ds))))
        print(f"val capped to {len(val_ds)}")

    out_dir = Path(args.out)
    cfg_kwargs: dict[str, Any] = {
        "output_dir": str(out_dir / "checkpoints"),
        "dataset_text_field": "text",
        "per_device_train_batch_size": args.batch_size,
        "gradient_accumulation_steps": args.grad_accum,
        "num_train_epochs": args.epochs,
        "learning_rate": args.lr,
        "lr_scheduler_type": "linear",
        "warmup_ratio": 0.03,
        "optim": "adamw_8bit",
        "bf16": bf16_ok,
        "fp16": not bf16_ok,
        "logging_steps": 20,
        # Eval must return the loss and nothing else. Without this the Trainer
        # gathers full logits (batch x seq x 152k vocab) and upcasts them to
        # fp32 -- a 6 GiB allocation that OOMs a 16GB T4 *after* training has
        # already succeeded. The verdict for this track is BIRD EA from the
        # harness anyway; eval loss is only a smoke signal.
        "prediction_loss_only": True,
        "per_device_eval_batch_size": 1,
        "eval_strategy": "epoch",
        "save_strategy": "steps",
        "save_steps": args.save_steps,
        "save_total_limit": 2,
        "seed": args.seed,
        "report_to": "none",
    }
    if args.max_steps > 0:
        cfg_kwargs["max_steps"] = args.max_steps
    # Reconcile with whatever this trl release actually accepts (see
    # accepted_kwargs). Unknown options are dropped LOUDLY β€” a silently
    # swallowed fp16 would train the T4 run in the wrong dtype and we would
    # only notice hours later.
    sft_keys = accepted_kwargs(SFTConfig)
    # Prefer the modern name; fall back to the legacy one only when the release
    # explicitly lacks it (old RunPod images).
    seq_key = "max_seq_length" if sft_keys and "max_length" not in sft_keys else "max_length"
    cfg_kwargs[seq_key] = args.max_seq_len
    if sft_keys:
        dropped = sorted(set(cfg_kwargs) - sft_keys)
        must_keep = [k for k in dropped if k in {"bf16", "fp16", "max_steps", seq_key}]
        if must_keep:
            raise SystemExit(f"SFTConfig rejects required options {must_keep}; update this script")
        for key in dropped:
            print(f"SFTConfig: dropping unsupported option {key!r}", flush=True)
            cfg_kwargs.pop(key)
    print(f"SFTConfig kwargs: {sorted(cfg_kwargs)}", flush=True)
    trainer_kwargs: dict[str, Any] = {
        "model": model,
        "train_dataset": train_ds,
        "eval_dataset": val_ds,
        "args": SFTConfig(**cfg_kwargs),
    }
    trainer_keys = accepted_kwargs(SFTTrainer)
    tok_key = (
        "tokenizer"
        if trainer_keys and "processing_class" not in trainer_keys
        else "processing_class"
    )
    trainer_kwargs[tok_key] = tokenizer
    trainer = SFTTrainer(**trainer_kwargs)
    # Mask loss to assistant tokens only β€” the huge schema prompt must not
    # dominate the gradient signal.
    trainer = train_on_responses_only(
        trainer,
        instruction_part=QWEN_USER_MARK,
        response_part=QWEN_ASSISTANT_MARK,
    )
    has_checkpoint = any((out_dir / "checkpoints").glob("checkpoint-*"))
    trainer.train(resume_from_checkpoint=True if (args.resume and has_checkpoint) else None)
    print("final eval:", trainer.evaluate())

    adapter_dir = out_dir / "adapter"
    model.save_pretrained(str(adapter_dir))
    tokenizer.save_pretrained(str(adapter_dir))
    print(f"adapter saved: {adapter_dir}")
    if not args.no_merge:
        merged_dir = out_dir / "merged"
        model.save_pretrained_merged(str(merged_dir), tokenizer, save_method="merged_16bit")
        print(f"merged model saved: {merged_dir}")


if __name__ == "__main__":
    train(parse_args())