nl-sql / scripts /autotune /train_qlora.py
liovina's picture
Deploy NL_SQL HEAD to HF Space (tracked files only)
4e1037f verified
Raw
History Blame Contribute Delete
11 kB
"""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())