File size: 17,527 Bytes
60b165e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
#!/usr/bin/env python3
"""
DiffusionGemma block-diffusion QLoRA trainer for Apple Silicon (MLX).

There is no diffusion-aware trainer in the MLX ecosystem (mlx-vlm's SFT trainer
computes autoregressive next-token loss β€” the wrong objective for this model).
This implements the ground-truth DiffusionGemma SFT recipe, established from
Google's JAX reference (gemma hackable_diffusion_adapter), NeMo Automodel's
diffusion_gemma_lora.yaml, and Unsloth's notebook:

  * Corruption: D3PM-uniform. One t ~ U(eps, 1) per example (eps=0.001);
    each canvas position independently replaced with prob t by a token drawn
    uniformly from [0, vocab). NO mask token β€” matches inference, which
    initializes/renoises the canvas with mx.random.randint over the vocab.
  * Canvas: response-relative 256-token grid. Response tokens (ending with
    <turn|> id 106) are EOS-filled (id 1) to the canvas boundary; the fill is
    supervised and attended (the model learns termination).
  * Loss: flat UNWEIGHTED cross-entropy over ALL canvas positions β€” corrupted
    and uncorrupted alike (Google NoWeightDiscreteLoss; the corrupted-only
    variant was a documented NeMo bug). No 1/t weighting (that is the
    absorbing-kernel ELBO weight; does not apply to the uniform kernel).
  * v1 simplifications (Unsloth-proven, 1.5%->89.5% on Sudoku): no co-trained
    encoder AR loss, no self-conditioning passes. v2 candidates documented.
  * LoRA (NeMo parity): r=16 alpha=32 on self_attn {q,k,v,o}_proj (v_proj only
    exists on the 25 sliding layers) + dense mlp {gate,up,down}_proj.
    MoE experts, router, embeddings frozen. QLoRA: base stays 4-bit quantized
    (mlx_lm LoRALinear.from_base is QuantizedLinear-aware).
  * Optimizer (NeMo): AdamW (bias-corrected) lr 1.5e-4, betas (0.95, 0.99),
    eps 1e-8, wd 1e-4, grad-norm clip 1.0, 25-step warmup, cosine to 1.5e-5,
    grad-accum 8 at micro-batch 1. Step count via --steps (the cosine horizon
    scales with it). Note: only LoRA weights are checkpointed β€” crash-resume
    restarts Adam moments (recovers over ~100 steps); resumed runs are not
    sample-equivalent to uninterrupted ones.

Adapters are saved in mlx-vlm-compatible format (adapters.safetensors with
full dotted root paths + adapter_config.json carrying lora_parameters.keys),
so mlx_vlm.trainer.utils.apply_lora_layers(model, adapter_dir) can reload them.

Usage:
  python3 diffusion_lora_train.py \
      --model ./diffusiongemma-26B-A4B-it-4bit \
      --data ./data \
      --adapter-path ./adapters/my-adapter \
      --smoke              # 3 fwd/bwd iters + sanity prints, then exit
"""

import argparse
import json
import time
from pathlib import Path

import numpy as np

import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from mlx.utils import tree_flatten, tree_map

from mlx_lm.tuner.lora import LoRALinear
from mlx_vlm.utils import load as vlm_load

VOCAB_SIZE = 262144
CANVAS_LEN = 256
BOS_ID = 2
EOS_FILL_ID = 1     # <eos> β€” Google's CanvasChunker EOS-fill token
TURN_END_ID = 106   # <turn|> β€” response terminator from the chat template
SOFTCAP = 30.0
T_EPS = 0.001       # Google SafeSpan eps 1e-4, NeMo 0.001; we use NeMo's


def parse_args():
    p = argparse.ArgumentParser()
    p.add_argument("--model", required=True)
    p.add_argument("--data", required=True, help="dir with train/valid jsonl of {prompt, response}")
    p.add_argument("--adapter-path", required=True)
    p.add_argument("--steps", type=int, default=800)
    p.add_argument("--accum", type=int, default=8)
    p.add_argument("--lr", type=float, default=1.5e-4)
    p.add_argument("--min-lr", type=float, default=1.5e-5)
    p.add_argument("--warmup", type=int, default=25)
    p.add_argument("--rank", type=int, default=16)
    p.add_argument("--alpha", type=float, default=32.0)
    p.add_argument("--max-prompt-tokens", type=int, default=1920)
    p.add_argument("--val-every", type=int, default=50)
    p.add_argument("--val-samples", type=int, default=32)
    p.add_argument("--save-every", type=int, default=100)
    p.add_argument("--seed", type=int, default=42)
    p.add_argument("--grad-checkpoint", action="store_true")
    p.add_argument("--cache-limit-gb", type=float, default=2.0)
    p.add_argument("--smoke", action="store_true", help="3 iters of sanity checks, then exit")
    p.add_argument("--resume-file", default=None, help="adapter safetensors to resume from")
    p.add_argument("--start-step", type=int, default=0, help="optimizer step to resume at (schedule offset)")
    return p.parse_args()


# ── LoRA wiring ──────────────────────────────────────────────────────────────

ATTN_PROJS = ("q_proj", "k_proj", "v_proj", "o_proj")
MLP_PROJS = ("gate_proj", "up_proj", "down_proj")


def apply_lora(model, rank, alpha):
    """Freeze everything, wrap attention + dense-MLP projections with LoRA.

    Stock mlx_vlm get_peft_model crashes on diffusion_gemma (_LanguageModelView
    is not an nn.Module), so we wire LoRALinear.from_base manually.
    """
    model.freeze()
    scale = alpha / rank
    keys = []
    for i, layer in enumerate(model.model.decoder.layers):
        for proj in ATTN_PROJS:
            lin = getattr(layer.self_attn, proj)
            if lin is None:  # full-attention layers have v_proj=None (values reuse keys)
                continue
            setattr(layer.self_attn, proj, LoRALinear.from_base(lin, r=rank, scale=scale))
            keys.append(f"model.decoder.layers.{i}.self_attn.{proj}")
        for proj in MLP_PROJS:
            lin = getattr(layer.mlp, proj)
            setattr(layer.mlp, proj, LoRALinear.from_base(lin, r=rank, scale=scale))
            keys.append(f"model.decoder.layers.{i}.mlp.{proj}")
    return keys, scale


def save_adapter(model, adapter_dir, keys, rank, scale, step, extra=None):
    adapter_dir = Path(adapter_dir)
    adapter_dir.mkdir(parents=True, exist_ok=True)
    weights = dict(tree_flatten(model.trainable_parameters()))
    name = "adapters.safetensors" if step is None else f"{step:07d}_adapters.safetensors"
    mx.save_safetensors(str(adapter_dir / name), weights)
    config = {
        "fine_tune_type": "lora",
        "model_type": "diffusion_gemma",
        "num_layers": -1,
        "lora_parameters": {"rank": rank, "scale": scale, "dropout": 0.0, "keys": keys},
        "training": extra or {},
    }
    (adapter_dir / "adapter_config.json").write_text(json.dumps(config, indent=2))


# ── Data ─────────────────────────────────────────────────────────────────────

def load_split(path, tokenizer, max_prompt_tokens):
    samples, skipped = [], 0
    with open(path) as f:
        for line in f:
            obj = json.loads(line)
            p_ids = [BOS_ID] + tokenizer.encode(obj["prompt"], add_special_tokens=False)
            r_ids = tokenizer.encode(obj["response"], add_special_tokens=False)
            if len(p_ids) > max_prompt_tokens or len(r_ids) > CANVAS_LEN:
                skipped += 1
                continue
            clean = r_ids + [EOS_FILL_ID] * (CANVAS_LEN - len(r_ids))
            samples.append((np.asarray(p_ids, np.int32), np.asarray(clean, np.int32), len(r_ids)))
    return samples, skipped


def corrupt(clean, rng):
    """D3PM-uniform: t ~ U(eps,1); replace each position w.p. t by uniform token."""
    t = T_EPS + (1.0 - T_EPS) * rng.random()
    mask = rng.random(clean.shape[0]) < t
    noise = rng.integers(0, VOCAB_SIZE, clean.shape[0], dtype=np.int32)
    return np.where(mask, noise, clean).astype(np.int32), t


# ── Loss ─────────────────────────────────────────────────────────────────────

def make_loss_fn():
    def loss_fn(model, prompt_ids, canvas_ids, targets):
        hidden, _ = model.model(input_ids=prompt_ids, canvas_ids=canvas_ids)
        logits = model.model.decoder.embed_tokens.as_linear(hidden)
        logits = mx.tanh(logits.astype(mx.float32) / SOFTCAP) * SOFTCAP
        ce = nn.losses.cross_entropy(logits, targets, reduction="none")
        return ce.mean()  # all canvas positions supervised, unweighted
    return loss_fn


def main():
    args = parse_args()
    mx.random.seed(args.seed)
    rng = np.random.default_rng(args.seed)
    mx.set_cache_limit(int(args.cache_limit_gb * 1024**3))

    print(f"[load] {args.model}", flush=True)
    model, processor = vlm_load(args.model)
    tokenizer = processor.tokenizer

    # the corruption/canvas constants are checkpoint-specific β€” fail loudly on a mismatch
    text_cfg = getattr(model.config, "text_config", model.config)
    cfg_vocab = getattr(text_cfg, "vocab_size", VOCAB_SIZE)
    cfg_canvas = getattr(model.config, "canvas_length", CANVAS_LEN)
    assert cfg_vocab == VOCAB_SIZE, f"checkpoint vocab_size {cfg_vocab} != {VOCAB_SIZE}"
    assert cfg_canvas == CANVAS_LEN, f"checkpoint canvas_length {cfg_canvas} != {CANVAS_LEN}"

    keys, scale = apply_lora(model, args.rank, args.alpha)
    n_train = sum(v.size for _, v in tree_flatten(model.trainable_parameters()))
    print(f"[lora] {len(keys)} layers wrapped, {n_train/1e6:.2f}M trainable params, scale={scale}", flush=True)

    if args.resume_file:
        ckpt_keys = set(mx.load(args.resume_file).keys())
        trainable_keys = set(dict(tree_flatten(model.trainable_parameters())).keys())
        missing = trainable_keys - ckpt_keys
        assert not missing, f"resume checkpoint missing {len(missing)} trainable keys, e.g. {sorted(missing)[:3]}"
        model.load_weights(args.resume_file, strict=False)
        print(f"[resume] loaded {args.resume_file} ({len(ckpt_keys)} keys), continuing at step {args.start_step}", flush=True)

    if args.grad_checkpoint:
        from mlx_vlm.trainer.utils import grad_checkpoint
        grad_checkpoint(model.model.decoder.layers[0])
        print("[mem] gradient checkpointing ON (class-wide DecoderLayer patch)", flush=True)

    model.train()

    data_dir = Path(args.data)
    train, n_skip_t = load_split(data_dir / "train.jsonl", tokenizer, args.max_prompt_tokens)
    valid, n_skip_v = load_split(data_dir / "valid.jsonl", tokenizer, args.max_prompt_tokens)
    print(f"[data] train={len(train)} (skipped {n_skip_t}), valid={len(valid)} (skipped {n_skip_v})", flush=True)

    # sanity: responses must end with <turn|> before the EOS fill
    _, clean0, rlen0 = train[0]
    assert clean0[rlen0 - 1] == TURN_END_ID, f"response does not end with <turn|>: {clean0[max(0,rlen0-5):rlen0]}"
    if rlen0 < CANVAS_LEN:
        assert clean0[rlen0] == EOS_FILL_ID

    # fixed validation corruptions for comparable val loss across steps
    vrng = np.random.default_rng(args.seed + 1)
    val_idx = vrng.choice(len(valid), size=min(args.val_samples, len(valid)), replace=False)
    val_set = []
    for i in val_idx:
        p, clean, _ = valid[i]
        canvas, _t = corrupt(clean, vrng)
        val_set.append((p, canvas, clean))

    loss_fn = make_loss_fn()
    loss_and_grad = nn.value_and_grad(model, loss_fn)

    # linear covers counters 0..warmup-1 (ends at lr*(w-1)/w); cosine takes over at
    # counter==warmup starting exactly at lr β€” a continuous ramp with increment lr/w
    base_schedule = optim.join_schedules(
        [optim.linear_schedule(0.0, args.lr, args.warmup),
         optim.cosine_decay(args.lr, max(1, args.steps - args.warmup), args.min_lr)],
        [args.warmup],
    )
    # on resume the optimizer's internal counter restarts at 0 β€” offset the schedule
    # so the LR continues from where the crashed run left off (Adam moments are lost)
    schedule = (lambda s: base_schedule(s + args.start_step)) if args.start_step else base_schedule
    # bias_correction=True for parity with torch AdamW (NeMo recipe): mlx defaults
    # to False, which inflates the effective step ~1.3-1.5x during early-mid training
    try:
        optimizer = optim.AdamW(learning_rate=schedule, betas=[0.95, 0.99], eps=1e-8,
                                weight_decay=1e-4, bias_correction=True)
    except TypeError:  # older mlx without the kwarg
        optimizer = optim.AdamW(learning_rate=schedule, betas=[0.95, 0.99], eps=1e-8, weight_decay=1e-4)
        print("[warn] mlx AdamW lacks bias_correction β€” effective LR inflated early in the run", flush=True)

    def val_loss():
        model.eval()
        tot = 0.0
        for p, canvas, clean in val_set:
            l = loss_fn(model, mx.array(p[None]), mx.array(canvas[None]), mx.array(clean[None]))
            mx.eval(l)
            tot += l.item()
        model.train()
        return tot / len(val_set)

    if args.smoke:
        print("[smoke] 3 fwd/bwd iters", flush=True)
        for it in range(3):
            p, clean, rlen = train[it]
            canvas, t = corrupt(clean, rng)
            t0 = time.time()
            (loss), grads = loss_and_grad(model, mx.array(p[None]), mx.array(canvas[None]), mx.array(clean[None]))
            grads, gnorm = optim.clip_grad_norm(grads, 1.0)
            mx.eval(loss, grads)
            dt = time.time() - t0
            print(f"  it={it} t={t:.3f} resp_len={rlen} prompt_len={len(p)} "
                  f"loss={loss.item():.4f} gnorm={gnorm.item():.3f} {dt:.1f}s "
                  f"peak={mx.get_peak_memory()/1e9:.1f}GB", flush=True)
        print(f"[smoke] initial val loss = {val_loss():.4f}", flush=True)
        print("[smoke] OK", flush=True)
        return

    print(f"[train] steps={args.steps} accum={args.accum} lr={args.lr} start={args.start_step}", flush=True)
    log_path = Path(args.adapter_path) / "train_log.jsonl"
    Path(args.adapter_path).mkdir(parents=True, exist_ok=True)
    if args.start_step:  # fresh shuffle stream on resume; exact replay not required for SFT
        rng = np.random.default_rng(args.seed + args.start_step)
    order = rng.permutation(len(train))
    cursor = 0
    t_start = time.time()
    # carry best_val across crash-resumes so a post-resume regression can't
    # overwrite the published best adapter
    best_json = Path(args.adapter_path) / "best.json"
    best_val = float("inf")
    if args.start_step and best_json.exists():
        best_val = json.loads(best_json.read_text())["val_loss"]
        print(f"[resume] best_val carried over: {best_val:.4f}", flush=True)

    for step in range(args.start_step + 1, args.steps + 1):
        acc_grads = None
        acc_loss = 0.0
        for _ in range(args.accum):
            if cursor >= len(order):
                order = rng.permutation(len(train))
                cursor = 0
            p, clean, _ = train[order[cursor]]
            cursor += 1
            canvas, _t = corrupt(clean, rng)
            (loss), grads = loss_and_grad(model, mx.array(p[None]), mx.array(canvas[None]), mx.array(clean[None]))
            acc_grads = grads if acc_grads is None else tree_map(mx.add, acc_grads, grads)
            # materialize accumulated grads each micro-step: frees this step's
            # backward graph so memory stays at single-step peak (~28GB measured)
            # instead of stacking `accum` lazy backward graphs
            mx.eval(acc_grads, loss)
            acc_loss += loss.item()
        acc_grads = tree_map(lambda g: g / args.accum, acc_grads)
        acc_grads, gnorm = optim.clip_grad_norm(acc_grads, 1.0)
        optimizer.update(model, acc_grads)
        mx.eval(model.parameters(), optimizer.state)

        rec = {
            "step": step,
            "loss": acc_loss / args.accum,
            "gnorm": gnorm.item(),
            # optimizer's internal counter is 0-indexed AND starts at 0 on resume,
            # so the LR actually used at global step N is base_schedule(N-1-start_step+start_step)
            # = base_schedule applied to the optimizer counter offset by start_step
            "lr": float(base_schedule(mx.array(step - 1)).item()),
            "peak_gb": mx.get_peak_memory() / 1e9,
            "elapsed_s": round(time.time() - t_start, 1),
        }
        if step % args.val_every == 0 or step == args.steps:
            rec["val_loss"] = val_loss()
            if rec["val_loss"] < best_val:
                best_val = rec["val_loss"]
                save_adapter(model, args.adapter_path, keys, args.rank, scale, None,
                             extra={"step": step, "val_loss": rec["val_loss"], "best": True})
                best_json.write_text(json.dumps({"step": step, "val_loss": best_val}))
                rec["saved_best"] = True
        if step % args.save_every == 0:
            save_adapter(model, args.adapter_path, keys, args.rank, scale, step)
        with open(log_path, "a") as f:
            f.write(json.dumps(rec) + "\n")
        print(f"step {step}/{args.steps} loss={rec['loss']:.4f} "
              f"{'val=' + format(rec['val_loss'], '.4f') + ' ' if 'val_loss' in rec else ''}"
              f"gnorm={rec['gnorm']:.2f} peak={rec['peak_gb']:.1f}GB", flush=True)

    save_adapter(model, args.adapter_path, keys, args.rank, scale, args.steps,
                 extra={"step": args.steps, "final": True, "best_val": best_val})
    print(f"[done] best_val={best_val:.4f} adapter={args.adapter_path} "
          f"total={time.time()-t_start:.0f}s", flush=True)


if __name__ == "__main__":
    main()