File size: 17,848 Bytes
e6f80f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
275
276
277
278
279
280
281
282
283
284
"""LoRA SFT for FastFill v1 (adapted from scripts/sft_train.py).

Differences from the OptiScene script: pre-split files instead of a random split, loss on the
assistant answer only, over-length samples dropped (never truncated mid-JSON), EOS = <|im_end|>
supervised, Qwen3 thinking disabled in the chat template, final adapter saved; run_manifest.json (the build's
MANIFEST + these arguments) is written into --out so every checkpoint says which data it saw.
Hyper-parameters: LoRA r16/alpha32 as in the OptiScene paper, but lr 1e-4 x 2 epochs (~6k steps at batch 32) instead
of the paper's 5e-6 x 10 epochs, which is a full-fine-tuning rate, ~20x too low for a rank-16 adapter.

Effective batch = bs x grad_accum x n_gpus; keep it at 32 when changing the GPU count:
  1 GPU:   CUDA_VISIBLE_DEVICES=0 python -m fastfill.train --model Qwen/Qwen3-8B --data DATA --out outputs/ff-v1 --bs 4 --grad_accum 8
  8 GPUs:  torchrun --nproc_per_node 8 -m fastfill.train --model Qwen/Qwen3-8B --data DATA --out outputs/ff-v1 --bs 4 --grad_accum 1
Resume after an interruption: add --resume (continues from the last checkpoint in --out).
Sequence length: --max_len is the model's limit (Qwen3-8B: 40960), not a memory setting; a batch is padded to its
longest sample, so memory follows the data. Measure it before choosing --bs / --grad_accum:
  --dry_run                       token length and object count percentiles with the real tokenizer, the samples
                                  over --max_len listed (they are dropped from training, never truncated)
  --mem_test 8192,16384,32768,max one forward/backward/optimizer step per length on one GPU with a batch of --bs rows
                                  padded like a training batch (row 0 at that length, the others at half, see
                                  mem_batch); peak memory printed next to the static part (weights + LoRA params/grads
                                  + AdamW states, read after a warm-up step); 'max' = the longest train or dev row,
                                  because Trainer evaluates dev every --save_steps
  --eval_max_len N                leave dev rows longer than N tokens out of that evaluation (eval loss then covers
                                  the remaining subset); pick N from the mem_test table
"""
import argparse
import collections
import hashlib
import json
import math
import os

import torch
from peft import LoraConfig, get_peft_model
from transformers import (AutoModelForCausalLM, AutoTokenizer, DataCollatorForSeq2Seq, Trainer,
                          TrainingArguments, set_seed)

from fastfill.scene import prompt_text


def end_token(tok):
    return "<|im_end|>" if "<|im_end|>" in tok.get_vocab() else tok.eos_token


def encode(tok, rows, max_len, chunk=2000):
    """Tokenize (prompt, answer) pairs in batches (per-row tokenizer calls are ~300x slower); labels mask the
    prompt; rows longer than max_len are dropped, never truncated (a cut JSON answer teaches broken output).
    -> (samples, dropped row indices)."""
    out, dropped, end = [], [], end_token(tok)
    total = len(rows)
    for i in range(0, total, chunk):
        if total > 5000 and (i // chunk) % 10 == 0:
            print(f"  [Tokenize] {i}/{total} samples ({i*100//total}%)...", flush=True)
        part = rows[i:i + chunk]
        ps = tok([prompt_text(tok, m[:-1]) for m in part], add_special_tokens=False)["input_ids"]
        ans = tok([m[-1]["content"] + end for m in part], add_special_tokens=False)["input_ids"]
        for k, (p, a) in enumerate(zip(ps, ans)):
            if len(p) + len(a) > max_len:
                dropped.append(i + k)
            else:
                out.append({"input_ids": p + a, "attention_mask": [1] * (len(p) + len(a)), "labels": [-100] * len(p) + a})
    return out, dropped


def select(recs, exclude_flags=(), source_weight=None):
    """The sampling layer: rows with any flag in exclude_flags are left out; a row of a source with weight w is
    used int(w) times plus once more with probability frac(w) (stable per uid), so w=0.5 keeps half a source and
    w=2 doubles it. Default: every row once (the data's own proportions)."""
    out, gone = [], collections.Counter()
    for r in recs:
        hit = {k for k, v in (r.get("flags") or {}).items() if v} & set(exclude_flags)
        if hit:
            gone["flag:" + sorted(hit)[0]] += 1
            continue
        w = (source_weight or {}).get(r.get("source"), 1.0)
        u = int(hashlib.sha1(r["uid"].encode()).hexdigest(), 16) % 10 ** 6 / 10 ** 6
        n = int(w) + (u < w - int(w))
        gone["weight"] += n == 0
        out += [r] * n
    return out, gone


def load(tok, path, max_len, exclude_flags=(), source_weight=None):
    print(f"Loading and tokenizing {path}...", flush=True)
    recs, gone = select([json.loads(line) for line in open(path)], exclude_flags, source_weight)
    rows, dropped = encode(tok, [r["messages"] for r in recs], max_len)
    comp = collections.Counter(r.get("source") for r in recs)
    print(f"{path}: {len(rows)} samples, dropped {len(dropped)} longer than {max_len} tokens"
          + (f", left out {dict(gone)}" if +gone else "") + f"; by source {dict(comp.most_common())}", flush=True)
    return rows


def length_report(tok, path, max_len):
    """Real-tokenizer profile of one file: tokens per sample (total / answer) and objects per room, the samples over
    max_len listed by uid so they can be looked at instead of silently dropped."""
    recs = [json.loads(line) for line in open(path)]
    rows, over = encode(tok, [r["messages"] for r in recs], float("inf"))
    lens = [len(r["input_ids"]) for r in rows]
    ans = [sum(l != -100 for l in r["labels"]) for r in rows]
    nobj = [len(json.loads(r["messages"][1]["content"])["objects"]) for r in recs]
    nfix = [len(json.loads(r["messages"][1]["content"]).get("fixed", [])) for r in recs]
    pct = lambda v: {k: sorted(v)[min(int(f * len(v)), len(v) - 1)] for k, f in
                     (("p50", .5), ("p90", .9), ("p95", .95), ("p99", .99), ("max", 1.0))}
    rep = {"file": path, "samples": len(rows), "tokens_total": sum(lens), "tokens": pct(lens), "answer_tokens": pct(ans),
           "objects": pct(nobj), "fixed": pct(nfix),
           "over_max_len": [{"uid": recs[i]["uid"], "source": recs[i]["source"], "tokens": lens[i], "objects": nobj[i]}
                            for i in range(len(rows)) if lens[i] > max_len],
           "buckets": {f"<= {b}": sum(l <= b for l in lens) for b in (4096, 8192, 16384, 32768, max_len)}}
    return rep


def mem_batch(tok, longest, spec, bs):
    """The batch DataCollatorForSeq2Seq(padding=True) builds for a --bs step whose longest row has the requested length:
    'max' = the longest train/dev row itself, a number = that many tokens (the longest row's tokens repeated, every
    token supervised: an upper bound). The other bs-1 rows are the same tokens cut to half and right-padded (pad id,
    attention_mask 0, labels -100), so SDPA materializes its (bs,1,L,L) mask as in almost every real batch.
    -> {input_ids, attention_mask, labels}: bs lists of L ints each."""
    if spec == "max":
        ids, lab = longest["input_ids"], longest["labels"]
    else:
        ids = (longest["input_ids"] * (int(spec) // len(longest["input_ids"]) + 1))[:int(spec)]
        lab = ids
    L, h = len(ids), len(ids) // 2
    rows = [(ids, lab)] + [(ids[:h], lab[:h])] * (bs - 1)
    return {"input_ids": [i + [tok.pad_token_id] * (L - len(i)) for i, _ in rows],
            "attention_mask": [[1] * len(i) + [0] * (L - len(i)) for i, _ in rows],
            "labels": [l + [-100] * (L - len(l)) for _, l in rows]}


def mem_test(model, tok, train, dev, specs, bs):
    """One training step per requested length on the current GPU in the training configuration: a batch of bs rows
    padded like the collator's (mem_batch), gradient checkpointing on. 'max' = the longest train or dev row (Trainer
    evaluates dev every save_steps). A warm-up step (512 tokens) first allocates the LoRA grads and AdamW states, so
    static = weights + LoRA params/grads + AdamW states and the per-length column is what the step adds
    (activations, logits, attention mask). OOM at one length moves on to the next."""
    gpu = torch.device("cuda")
    model.to(gpu)
    model.train()
    model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
    opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-4)
    longest = max(train + dev, key=lambda r: len(r["input_ids"]))
    print(f"longest train row {max(len(r['input_ids']) for r in train)} tokens, "
          f"longest dev row {max(len(r['input_ids']) for r in dev)} tokens (eval set)", flush=True)

    def step(spec):                       # -> peak bytes of one forward/backward/optimizer step, None on OOM
        b = None
        torch.cuda.reset_peak_memory_stats()
        try:                              # the batch's own allocation can OOM too
            b = {k: torch.tensor(v, device=gpu) for k, v in mem_batch(tok, longest, spec, bs).items()}
            model(**b).loss.backward()
            opt.step()
            return torch.cuda.max_memory_allocated()
        except torch.cuda.OutOfMemoryError:
            return None
        finally:
            opt.zero_grad(set_to_none=False)  # grads stay resident, as in training
            del b
            torch.cuda.empty_cache()

    if step("512") is None:               # warm-up: creates the LoRA grads and the AdamW moments
        print(f"OOM already at 512 tokens x bs {bs} on {torch.cuda.get_device_name()}: the model does not fit this card "
              f"in this configuration; no table", flush=True)
        return
    static = torch.cuda.memory_allocated()
    print(f"static (weights + LoRA params/grads + AdamW states) {static / 2 ** 30:.1f} GB", flush=True)
    for spec in specs:
        n = len(longest["input_ids"]) if spec == "max" else int(spec)
        peak = step(spec)
        if peak is None:
            print(f"len {n:6d}  bs {bs}  OOM on {torch.cuda.get_device_name()} "
                  f"({torch.cuda.get_device_properties(gpu).total_memory / 2 ** 30:.0f} GB)", flush=True)
        else:
            print(f"len {n:6d}  bs {bs}  peak {peak / 2 ** 30:5.1f} GB  = static {static / 2 ** 30:.1f} + "
                  f"activations/logits/mask {(peak - static) / 2 ** 30:.1f} GB", flush=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", required=True, help="HF id or local path, e.g. Qwen/Qwen3-8B")
    ap.add_argument("--data", required=True, help="dir with train.jsonl / dev.jsonl from fastfill.build")
    ap.add_argument("--out", required=True)
    ap.add_argument("--max_len", type=int, default=40960, help="model sequence limit; longer samples are dropped")
    ap.add_argument("--lr", type=float, default=1e-4)
    ap.add_argument("--epochs", type=float, default=2)
    ap.add_argument("--bs", type=int, default=4, help="per-device batch size")
    ap.add_argument("--grad_accum", type=int, default=8, help="per GPU; divide by the GPU count to keep batch 32")
    ap.add_argument("--lora_r", type=int, default=16)
    ap.add_argument("--lora_alpha", type=int, default=32)
    ap.add_argument("--full_ft", action="store_true", help="full fine-tuning instead of LoRA (use with --deepspeed)")
    ap.add_argument("--deepspeed", default=None)
    ap.add_argument("--attn", default="sdpa", help="sdpa | flash_attention_2")
    ap.add_argument("--save_steps", type=int, default=500)
    ap.add_argument("--report_to", default="none")
    ap.add_argument("--dry_run", action="store_true", help="tokenize and print the length profile only (no GPU)")
    ap.add_argument("--mem_test", default=None, help="e.g. 8192,16384,32768,max: measure peak memory per length, then exit")
    ap.add_argument("--exclude_flags", nargs="*", default=["oob_objects", "overlapping_furniture", "fixed_collision"],
                    help="train rows carrying any of these quality flags are left out (see the row's 'flags'). Default: "
                         "the three whose reference answer serve.py would refuse (objects > 10 cm through the walls; "
                         "copies of one piece through each other; furniture through a door / column / stair box). "
                         "Pass --exclude_flags with no value to train on all")
    ap.add_argument("--source_weight", nargs="*", default=[],
                    help="SOURCE=w: use each train row of SOURCE w times on average (0.5 halves it, 2 doubles it)")
    ap.add_argument("--eval_max_len", type=int, default=0,
                    help="dev rows longer than this are left out of the eval at every save_steps (0 = keep all)")
    ap.add_argument("--resume", action="store_true", help="continue from the last checkpoint in --out")
    a = ap.parse_args()
    local_rank = int(os.environ.get("LOCAL_RANK", 0))
    if torch.cuda.is_available():
        torch.cuda.set_device(local_rank)
    a.source_weight = {k: float(v) for k, v in (x.split("=") for x in a.source_weight)}
    known = {json.loads(line)["source"] for line in open(f"{a.data}/train.jsonl")} if a.source_weight else set()
    if set(a.source_weight) - known:
        raise SystemExit(f"--source_weight: unknown source(s) {sorted(set(a.source_weight) - known)}; known: {sorted(known)}")

    print(f"Loading tokenizer from {a.model}...", flush=True)
    tok = AutoTokenizer.from_pretrained(a.model)
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token
    if a.dry_run:
        for split in ("train", "dev", "test"):
            rep = length_report(tok, f"{a.data}/{split}.jsonl", a.max_len)
            print(json.dumps(rep, indent=1))
        train = load(tok, f"{a.data}/train.jsonl", a.max_len, a.exclude_flags, a.source_weight)
        print(tok.decode(train[0]["input_ids"]))
        print("supervised part:", repr(tok.decode([t for t in train[0]["labels"] if t != -100])))
        return
    train = load(tok, f"{a.data}/train.jsonl", a.max_len, a.exclude_flags, a.source_weight)
    dev = load(tok, f"{a.data}/dev.jsonl", a.max_len)
    if a.eval_max_len:                      # protect the eval at every save_steps; eval loss then covers a subset
        kept = [r for r in dev if len(r["input_ids"]) <= a.eval_max_len]
        print(f"eval set: {len(dev) - len(kept)} dev rows longer than {a.eval_max_len} tokens left out, {len(kept)} kept", flush=True)
        dev = kept
    if int(os.environ.get("RANK", 0)) == 0 and not a.mem_test:   # provenance of this run (build MANIFEST + args)
        os.makedirs(a.out, exist_ok=True)
        man = f"{a.data}/MANIFEST.json"
        rec = {"args": vars(a), "data_manifest": json.load(open(man)) if os.path.exists(man) else None}
        path = f"{a.out}/run_manifest.json"
        if a.resume and os.path.exists(path):   # a resume is recorded beside the run it continues; a new run replaces
            k = 1
            while os.path.exists(f"{a.out}/run_manifest.resume{k}.json"):
                k += 1
            path = f"{a.out}/run_manifest.resume{k}.json"
        json.dump(rec, open(path, "w"), indent=1)

    model = AutoModelForCausalLM.from_pretrained(
        a.model, dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32, attn_implementation=a.attn)
    model.config.use_cache = False
    set_seed(42)                            # before get_peft_model: same LoRA init every run (v1.0 vs v1.1 pairing)
    if not a.full_ft:
        model.enable_input_require_grads()  # needed for gradient checkpointing with frozen embeddings
        model = get_peft_model(model, LoraConfig(
            task_type="CAUSAL_LM", r=a.lora_r, lora_alpha=a.lora_alpha, lora_dropout=0.05,
            target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]))
        model.print_trainable_parameters()
    if a.mem_test:
        mem_test(model, tok, train, dev, a.mem_test.split(","), a.bs)
        return

    # 3% warmup as an integer step count: transformers 4.x rejects fractional warmup_steps, >=5.15 has no warmup_ratio
    # one process that sees several GPUs runs DataParallel: its batch is bs x n_gpu, like DDP's
    world = int(os.environ.get("WORLD_SIZE", 0)) or max(1, torch.cuda.device_count())
    total_steps = math.ceil(math.ceil(len(train) / (a.bs * world)) / a.grad_accum) * a.epochs
    # batch similar lengths together (~1/3 less padding); the argument was renamed in transformers 5.14
    by_length = ({"group_by_length": True} if "group_by_length" in TrainingArguments.__dataclass_fields__
                 else {"train_sampling_strategy": "group_by_length"})
    args = TrainingArguments(
        output_dir=a.out, per_device_train_batch_size=a.bs, per_device_eval_batch_size=1,  # dev pads to 40k tokens
        gradient_accumulation_steps=a.grad_accum, learning_rate=a.lr, num_train_epochs=a.epochs, **by_length,
        lr_scheduler_type="cosine", warmup_steps=max(1, int(0.03 * total_steps)), weight_decay=0.0,
        bf16=torch.cuda.is_available(),
        gradient_checkpointing=True, gradient_checkpointing_kwargs={"use_reentrant": False},
        logging_steps=10, eval_strategy="steps", eval_steps=a.save_steps, save_strategy="steps",
        save_steps=a.save_steps, save_total_limit=3, remove_unused_columns=False,
        report_to=a.report_to, deepspeed=a.deepspeed, ddp_find_unused_parameters=False)
    trainer = Trainer(model=model, args=args, train_dataset=train, eval_dataset=dev,
                      data_collator=DataCollatorForSeq2Seq(tok, padding=True, label_pad_token_id=-100))
    trainer.train(resume_from_checkpoint=True if a.resume else None)
    trainer.save_model(f"{a.out}/final")
    tok.save_pretrained(f"{a.out}/final")


if __name__ == "__main__":
    main()