PEFT
Safetensors
File size: 12,385 Bytes
210f9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tiny random Gemma 4 checks before any 12B run. Run as ``python -m training.selftest [--model DIR] --out FILE``.

Classifier-plan §3.5. The tiny checkpoint is written in the real format (``Gemma4UnifiedForConditionalGeneration``
with small image and audio projections, so the text-only loader must map and ignore keys exactly as for the 12B),
with a byte-level tokenizer that has Gemma's special-token ids (pad 0, eos 1, bos 2). With ``--model`` the real
config's attention shapes (head_dim 256, global head_dim 512, KV heads, sliding window) are kept and only the depth,
width and vocabulary shrink; without it everything is small (CPU tests). Nothing here needs the 12B weights.

Checks (exit 1 if any fails):
1. text-only load: zero missing text weights, multimodal weights ignored, hidden states equal the full model's;
2. heads: one ``Linear(hidden -> K_f)`` per nsb-v1 field (the real head sizes), LoRA on every target projection;
3. padding invariance: a row alone and the same row right-padded in a batch give the same logits (both poolings,
   float64 so that only a masking or pooling error can fail it);
4. loss split: one step as one micro-batch and as two give the same gradients;
5. save/load: adapter + heads reload to the same logits;
6. CPU/GPU parity (only with CUDA): the same tiny model gives the same logits on both devices.
"""
from __future__ import annotations

import argparse
import json
import tempfile
from pathlib import Path

VOCAB = 512
SPECIALS = ["<pad>", "<eos>", "<bos>", "<unk>", "<|turn>", "<turn|>", "<|channel>", "<channel|>"]
LORA = dict(lora_r=8, lora_alpha=8, lora_dropout=0.0, head_dropout=0.0)


def tiny_text(real=None, layers: int = 6) -> dict:
    """Text config kwargs: 5 sliding + 1 full attention layer (the 12B pattern repeated once)."""
    kinds = ["sliding_attention"] * (layers - 1) + ["full_attention"]
    if real is None:
        return dict(vocab_size=VOCAB, hidden_size=64, intermediate_size=128, num_hidden_layers=layers,
                    num_attention_heads=4, num_key_value_heads=2, head_dim=16, global_head_dim=32,
                    num_global_key_value_heads=1, attention_k_eq_v=True, sliding_window=16,
                    max_position_embeddings=4096, final_logit_softcapping=30.0, layer_types=kinds,
                    pad_token_id=0, eos_token_id=1, bos_token_id=2)
    # Read the serialised values: per-layer attributes (KV heads, head_dim) refuse plain attribute access.
    d = real.to_dict()
    keep = ("num_attention_heads", "num_key_value_heads", "head_dim", "sliding_window", "attention_k_eq_v",
            "hidden_activation", "rms_norm_eps", "final_logit_softcapping", "rope_parameters")
    out = {k: d[k] for k in keep if d.get(k) is not None}
    layer = next(iter((d.get("per_layer_config") or {}).values()), {})
    if layer.get("head_dim") is not None:
        out["global_head_dim"] = layer["head_dim"]
    if layer.get("num_key_value_heads") is not None:
        out["num_global_key_value_heads"] = layer["num_key_value_heads"]
    heads = out.get("num_attention_heads", 4)
    out.update(vocab_size=VOCAB, hidden_size=heads * 32, intermediate_size=heads * 64, num_hidden_layers=layers,
               layer_types=kinds, max_position_embeddings=4096, pad_token_id=0, eos_token_id=1, bos_token_id=2)
    return out


def make_tokenizer(path: Path):
    """Byte-level tokenizer (every text encodes) with Gemma's special-token ids; saved next to the model."""
    from tokenizers import Tokenizer, decoders, models, pre_tokenizers
    from transformers import PreTrainedTokenizerFast

    alphabet = sorted(pre_tokenizers.ByteLevel.alphabet())
    vocab = {t: i for i, t in enumerate(SPECIALS)}
    for ch in alphabet:
        vocab.setdefault(ch, len(vocab))
    tk = Tokenizer(models.BPE(vocab=vocab, merges=[], unk_token="<unk>"))
    tk.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False)
    tk.decoder = decoders.ByteLevel()
    tok = PreTrainedTokenizerFast(tokenizer_object=tk, pad_token="<pad>", eos_token="<eos>", bos_token="<bos>",
                                  unk_token="<unk>", additional_special_tokens=SPECIALS[4:])
    tok.save_pretrained(str(path))
    return tok


def make_tiny(path: Path, real_model: str | None = None, layers: int = 6, seed: int = 0, multimodal: bool = True):
    """Save a random tiny Gemma 4 checkpoint (full multimodal class) plus tokenizer; returns the full model."""
    import torch
    from transformers import Gemma4UnifiedConfig, Gemma4UnifiedForConditionalGeneration

    real = None
    if real_model:
        from .model import text_config

        real = text_config(real_model)
    text = tiny_text(real, layers)
    vision = dict(patch_size=2, pooling_kernel_size=2, mm_embed_dim=16, mm_posemb_size=8,
                  output_proj_dims=16) if multimodal else None
    audio = dict(audio_embed_dim=8) if multimodal else None
    cfg = Gemma4UnifiedConfig(text_config=text, vision_config=vision, audio_config=audio)
    torch.manual_seed(seed)
    full = Gemma4UnifiedForConditionalGeneration(cfg).float()
    path = Path(path)
    full.save_pretrained(str(path))
    make_tokenizer(path)
    return full


def tiny_cfg(path, pool: str = "last", dtype: str = "float32") -> dict:
    return dict(LORA, model=str(path), dtype=dtype, pool=pool, gradient_checkpointing=True)


def build_tiny(path, device, pool: str = "last", sizes=None, seed: int = 7, dtype: str = "float32"):
    """Tiny heads model with non-zero LoRA B weights, so LoRA A gradients and the adapter are non-trivial."""
    import torch

    from .model import build
    from .taxonomy import Taxonomy

    sizes = sizes or Taxonomy.default().sizes()
    model = build(tiny_cfg(path, pool, dtype), sizes, device, train=True)
    g = torch.Generator(device="cpu").manual_seed(seed)
    with torch.no_grad():
        for n, p in model.named_parameters():
            if "lora_B" in n:
                p.copy_((torch.randn(p.shape, generator=g) * 0.05).to(p.dtype))
    return model


def rows(lengths, seed: int = 1) -> list[list[int]]:
    import torch

    g = torch.Generator().manual_seed(seed)
    return [[2] + torch.randint(10, VOCAB - 1, (n - 1,), generator=g).tolist() for n in lengths]


def flat_grads(model):
    import torch

    return torch.cat([p.grad.detach().float().flatten() for _, p in model.named_parameters()
                      if p.requires_grad and p.grad is not None])


def forward_backward(model, batch, labels, device, split=None, weights=None):
    import torch

    from .data import step_counts
    from .model import pad, step_loss

    model.zero_grad(set_to_none=True)
    counts = step_counts(labels)
    parts = split or [list(range(len(batch)))]
    for part in parts:
        ids, mask, last = pad([batch[i] for i in part], 0, device)
        y = torch.tensor([labels[i] for i in part], device=device)
        loss, _ = step_loss(model(ids, mask, last), y, counts, weights)
        loss.backward()
    return flat_grads(model)


def rel_err(a, b) -> float:
    return float((a - b).abs().max() / b.abs().max().clamp_min(1e-12))


def cat_logits(model, batch, device):
    import torch

    from .model import pad

    with torch.no_grad():
        return torch.cat([lg.float() for lg in model(*pad(batch, 0, device))], 1)


def run_checks(tmp: Path, device, real_model: str | None = None) -> dict:
    import torch

    from .model import LORA_TARGETS, build, load_backbone, save_checkpoint
    from .taxonomy import Taxonomy

    tax = Taxonomy.default()
    out: dict = {}
    path = tmp / "tiny"
    full = make_tiny(path, real_model)
    # 1. text-only load
    lm, info = load_backbone(path, torch.device("cpu"), dtype=torch.float32)
    ids = torch.tensor([[2, 50, 60, 70, 0, 0]])
    mask = torch.tensor([[1, 1, 1, 1, 0, 0]])
    with torch.no_grad():
        err = float((lm.model(input_ids=ids, attention_mask=mask).last_hidden_state
                     - full.model.language_model(input_ids=ids, attention_mask=mask).last_hidden_state).abs().max())
    ignored = sorted(info["unexpected_keys"])
    out["text_only_load"] = {"missing": sorted(info["missing_keys"]), "ignored_examples": ignored[:4],
                             "ignored": len(ignored), "max_abs_diff_vs_full": err,
                             "pass": not info["missing_keys"] and err < 1e-5}
    del lm, full
    # 2. heads and LoRA
    model = build_tiny(path, device)
    shapes = [tuple(h.weight.shape) for h in model.heads]
    hidden = model.heads[0].in_features
    wrapped = sorted({n.split(".")[-1] for n, m in model.backbone.named_modules() if hasattr(m, "lora_A")})
    out["heads"] = {"shapes": shapes, "lora_modules": wrapped,
                    "pass": shapes == [(k, hidden) for k in tax.sizes()] and set(wrapped) == set(LORA_TARGETS)}
    tol = 1e-4
    # 3. padding invariance, both poolings, in float64: a masking or pooling bug gives errors of order 1, while CUDA
    # fp32 kernels picked per batch shape differ by ~1e-4 on this tiny random model (H100, 2026-10-19: 1.02e-4 in
    # fp32, 2.5e-7 in float64 — the rest is the model's own float32 norm upcasts).
    pad_ok = {}
    for pool in ("last", "mean"):
        m = build_tiny(path, device, pool=pool, dtype="float64")
        m.eval()
        batch = rows([23, 71, 5], seed=2)
        alone = cat_logits(m, [batch[0]], device)
        together = cat_logits(m, batch, device)[0:1]
        pad_ok[pool] = rel_err(alone, together)
    out["padding_invariance"] = {"rel_err": pad_ok, "pass": all(v < tol for v in pad_ok.values())}
    # 4. loss split
    model.train()
    batch = rows([30, 31, 60, 12], seed=3)
    labels = [[0, 1, 2, 0, 1, 2, 3, 1, 0, 2], [1, -1, -1, 1, 0, 0, 11, 3, 5, 0],
              [2, 4, 4, -1, 2, 1, 0, 0, 1, 1], [0, 0, 0, 3, 3, 3, 5, 2, 2, 3]]
    torch.manual_seed(0)
    g_one = forward_backward(model, batch, labels, device, weights=tax.weights())
    torch.manual_seed(0)
    g_two = forward_backward(model, batch, labels, device, split=[[0, 1], [2, 3]], weights=tax.weights())
    split_err = rel_err(g_two, g_one)
    cos = float(torch.nn.functional.cosine_similarity(g_one, g_two, dim=0))
    # fp32 with different padded widths per micro-batch: tiny summation-order differences, not a maths error
    # (a wrong per-step normalisation gives errors of order 1).
    out["loss_split"] = {"grad_rel_err": split_err, "grad_cosine": cos, "pass": split_err < 1e-3 and cos >= 0.9999}
    # 5. save/load
    model.eval()
    before = cat_logits(model, batch, device)
    save_checkpoint(model, tmp / "ckpt", tax.names, {"test": True})
    again = build(tiny_cfg(path), tax.sizes(), device, train=False, adapter_dir=tmp / "ckpt" / "adapter",
                  heads_path=tmp / "ckpt" / "heads.safetensors")
    after = cat_logits(again, batch, device)
    out["save_load"] = {"rel_err": rel_err(after, before), "pass": rel_err(after, before) < tol}
    # 6. CPU/GPU parity
    if torch.device(device).type == "cuda":
        cpu = build(tiny_cfg(path), tax.sizes(), torch.device("cpu"), train=False,
                    adapter_dir=tmp / "ckpt" / "adapter", heads_path=tmp / "ckpt" / "heads.safetensors")
        e = rel_err(cat_logits(cpu, batch, torch.device("cpu")), after.cpu())
        out["cpu_gpu_parity"] = {"rel_err": e, "pass": e < 1e-3}
    else:
        out["cpu_gpu_parity"] = "skipped (no CUDA device)"
    out["ok"] = all(v["pass"] for v in out.values() if isinstance(v, dict))
    return out


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--model", help="Real checkpoint directory (only its config is used)")
    p.add_argument("--device", default="cuda")
    p.add_argument("--out")
    args = p.parse_args(argv)
    import torch

    device = torch.device(args.device if args.device != "cuda" or torch.cuda.is_available() else "cpu")
    with tempfile.TemporaryDirectory() as tmp:
        out = run_checks(Path(tmp), device, args.model)
    out["device"] = str(device)
    if args.device == "cuda" and device.type != "cuda":
        out["ok"] = False
        out["error"] = "CUDA requested but not available"
    text = json.dumps(out, indent=1, default=str)
    print(text)
    if args.out:
        Path(args.out).write_text(text + "\n")
    return 0 if out["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())