File size: 18,810 Bytes
f31f59b
 
 
 
 
 
 
 
 
 
 
 
 
822177f
f31f59b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822177f
f31f59b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822177f
f31f59b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The paper's four arms, identical except the vocabulary representation (and C's body format).

  A  learned    tied learned embedding, 262,144 x 640 (Gemma 3 default), float body
  B  frozen     frozen 512-bit codes for all 262,144 tokens (Gemma 3 270M embedding -> centre -> top-512 principal
                directions -> sign) + fixed log-unigram prior; trainable Linear 512->640 in, Linear 640->512 out;
                float body
  C  ternary    as B, body Linears = official BitNet b1.58 BitLinear (RMSNorm in, int8 absmax activations,
                ternary absmean weights, straight-through); attention math and KV in 16-bit like BitNet
  D  A-small    learned tied table like A, but the WHOLE model sized to B's parameter count: width 280,
                FFN 896 (Gemma's 3.2x ratio), same 18 layers / 4 heads / head 256 -> 99.9M (table 73.4M + body 26.5M);
                same data, steps and recipe as A. Answers: at equal size, learned table vs frozen table.
  body          Gemma-3-270M configuration, RANDOM init (trained from scratch in every arm)
  data          prep_data.py shards; seq 1024; ~262k tokens per optimiser step; AdamW, warmup + cosine
  checkpoints   every --ckpt-every steps to ckpt/<arm>/step_XXXXX.pt and (with --hf) to the HF repo
                QLNI/embeddings-are-overpriced under <arm>/step_XXXXX.pt, never overwritten; --resume continues from the latest

    python train.py --arm A|B|C|D --data data [--tokens 2.05e9] [--micro 8] [--hf]
"""
import argparse, glob, json, math, os, pathlib, time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

V, BITS, SEQ = 262144, 512, 1024


def say(*a):
    print(time.strftime("%H:%M:%S"), *a, flush=True)


def ste(x, q):
    return x + (q - x).detach()


class TLinear(nn.Linear):
    """BitLinear as in the official BitNet b1.58 training code (Microsoft, "The Era of 1-bit LLMs: Training Tips,
    Code and FAQ"): RMSNorm on the input, int8 activations with per-token absmax scale 127/max|x| clamped to
    [-128, 127], ternary weights with absmean scale, both straight-through. Attention math and KV stay 16-bit."""

    def forward(self, x):
        xf = x.float()
        xn = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + 1e-6)
        sx = 127.0 / xn.detach().abs().amax(-1, keepdim=True).clamp_min(1e-5)
        xq = ste(xn, (xn * sx).round().clamp(-128, 127) / sx)
        w = self.weight
        sw = 1.0 / w.detach().abs().mean().clamp_min(1e-5)
        wq = ste(w, (w * sw).round().clamp(-1, 1) / sw)
        return F.linear(xq.to(x.dtype), wq.to(x.dtype), self.bias)


def random_table(counts):
    g = torch.Generator().manual_seed(1234)
    table = torch.randint(0, 2, (V, BITS), generator=g).float() * 2 - 1
    seal = torch.tensor(np.log((counts + 1) / (counts.sum() + V)), dtype=torch.float32)
    return table, seal, torch.zeros(640), torch.zeros(640, BITS)


def frozen_table(counts):
    """512-bit codes from Gemma 3 270M's embedding (section 4.1 of the paper) + log-unigram prior.
    With SAVED_TABLE=1 and the saved table file present, it is loaded, so every arm and every machine use the identical bits (a rebuild on
    another machine flips ~0.008% of near-zero bits through floating-point differences in the eigendecomposition)."""
    saved = pathlib.Path(__file__).with_name("gemma3_270m_512bit_table.npz")
    if os.environ.get("SAVED_TABLE") == "1" and saved.exists():          # opt-in
        z = np.load(saved)
        table = torch.from_numpy(np.unpackbits(z["bits"], axis=1)[:, :BITS].astype(np.float32) * 2 - 1)
        seal = torch.tensor(np.log((counts + 1) / (counts.sum() + V)), dtype=torch.float32)
        print("frozen table: loaded", saved.name, flush=True)
        return table, seal, torch.from_numpy(z["mean"]), torch.from_numpy(z["basis"])
    from safetensors import safe_open
    from huggingface_hub import hf_hub_download
    with safe_open(hf_hub_download("google/gemma-3-270m", "model.safetensors"), "pt") as f:
        E = f.get_tensor([k for k in f.keys() if k.endswith("embed_tokens.weight")][0]).float()[:V]
    mu = E.mean(0)
    evals, evecs = torch.linalg.eigh((E - mu).T @ (E - mu) / (len(E) - 1))
    W = evecs[:, evals.argsort(descending=True)][:, :BITS]
    table = torch.sign((E - mu) @ W)
    table[table == 0] = 1
    seal = torch.tensor(np.log((counts + 1) / (counts.sum() + V)), dtype=torch.float32)
    return table, seal, mu, W


class Model(nn.Module):
    def __init__(self, arm, counts, attn="sdpa", learned_bias=False):
        super().__init__()
        from transformers import AutoConfig, AutoModelForCausalLM
        cfg = AutoConfig.from_pretrained("google/gemma-3-270m")
        if arm == "D":                                  # A-small: same total size as B
            cfg.hidden_size, cfg.intermediate_size = 280, 896
        torch.manual_seed(0)
        g = AutoModelForCausalLM.from_config(cfg, attn_implementation=attn, torch_dtype=torch.float32)
        self.arm = arm
        self.body = g.model
        d = cfg.hidden_size
        if arm in ("A", "D"):
            self.embed_scale = float(self.body.embed_tokens.embed_scale) if hasattr(self.body.embed_tokens, "embed_scale") else math.sqrt(d)
        else:
            self.body.embed_tokens = None
            table, seal, mu, W = frozen_table(counts)
            self.register_buffer("table", table)
            if learned_bias:            # SHADOW-250M style: per-token bias STARTS at the log-unigram seal and is trained
                self.seal = nn.Parameter(seal)
            else:                       # SHADOW-50M style: the seal is fixed (buffer, never trained)
                self.register_buffer("seal", seal)
            self.register_buffer("table_mu", mu)
            self.register_buffer("table_W", W)
            self.inp = nn.Linear(BITS, d, bias=False)
            self.out = nn.Linear(d, BITS, bias=False)
        if arm == "C":
            for _, mod in list(self.body.layers.named_modules()):
                for name, child in list(mod.named_children()):
                    if type(child) is nn.Linear:
                        t = TLinear(child.in_features, child.out_features, bias=child.bias is not None)
                        t.weight = child.weight
                        t.bias = child.bias
                        setattr(mod, name, t)

    def hidden(self, ids):
        if self.arm in ("A", "D"):
            return self.body(input_ids=ids, use_cache=False).last_hidden_state
        return self.body(inputs_embeds=self.inp(self.table[ids]), use_cache=False).last_hidden_state

    def logits(self, h):
        if self.arm in ("A", "D"):
            return h @ self.body.embed_tokens.weight.T
        return (self.out(h) @ self.table.T) / math.sqrt(BITS) + self.seal

    fused = False

    def loss(self, ids, chunk=256):
        """Mean next-token cross-entropy, readout computed in chunks of positions to bound memory.
        fused=True: Liger fused linear + cross-entropy (never materialises the full logits; same maths)."""
        h = self.hidden(ids[:, :-1])
        tgt = ids[:, 1:]
        B, T, D = h.shape
        hf, tf = h.reshape(-1, D), tgt.reshape(-1)
        if self.fused:
            from liger_kernel.transformers.functional import liger_fused_linear_cross_entropy as flce
            if self.arm in ("A", "D"):
                return flce(hf, self.body.embed_tokens.weight, tf)
            return flce(self.out(hf), self.table / math.sqrt(BITS), tf, self.seal)
        tot = 0.0
        for s in range(0, len(hf), chunk * B):
            lg = self.logits(hf[s:s + chunk * B]).float()
            tot = tot + F.cross_entropy(lg, tf[s:s + chunk * B], reduction="sum")
        return tot / len(tf)


class Data:
    def __init__(self, root, seed):
        self.shards = [np.load(p, mmap_mode="r") for p in sorted(glob.glob(str(pathlib.Path(root) / "train_*.npy")))]
        self.sizes = np.array([len(s) for s in self.shards])
        self.rng = np.random.default_rng(seed)

    def batch(self, n):
        k = self.rng.choice(len(self.shards), n, p=self.sizes / self.sizes.sum())
        out = np.empty((n, SEQ + 1), np.int64)
        for i, j in enumerate(k):
            s = self.rng.integers(0, self.sizes[j] - SEQ - 1)
            out[i] = self.shards[j][s:s + SEQ + 1]
        return torch.from_numpy(out)

    def skip(self, n):
        """advance the random stream exactly as batch(n) would, without reading data (exact resume)"""
        k = self.rng.choice(len(self.shards), n, p=self.sizes / self.sizes.sum())
        for j in k:
            self.rng.integers(0, self.sizes[j] - SEQ - 1)


@torch.no_grad()
def val_loss(m, val, n_seq=64, micro=8):
    m.eval()
    x = torch.from_numpy(val[:n_seq * (SEQ + 1)].astype(np.int64).reshape(n_seq, SEQ + 1))
    tot = 0.0
    for i in range(0, n_seq, micro):
        with torch.autocast("cuda", dtype=torch.bfloat16):
            tot += m.loss(x[i:i + micro].cuda()).item()
    m.train()
    return tot / (n_seq // micro)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--arm", required=True, choices=["A", "B", "C", "D"])
    ap.add_argument("--data", default="data")
    ap.add_argument("--tokens", type=float, default=2.05e9)
    ap.add_argument("--batch-tokens", type=int, default=262144)
    ap.add_argument("--micro", type=int, default=8)
    ap.add_argument("--lr", type=float, default=6e-4)
    ap.add_argument("--ckpt-every", type=int, default=800)
    ap.add_argument("--eval-every", type=int, default=200)
    ap.add_argument("--max-steps", type=int, default=0, help="stop early (speed tests)")
    ap.add_argument("--hf", action="store_true")
    ap.add_argument("--resume", action="store_true")
    ap.add_argument("--compile", action="store_true", help="torch.compile the body")
    ap.add_argument("--fused-ce", action="store_true", help="Liger fused linear cross-entropy")
    ap.add_argument("--attn", default="sdpa", help="sdpa | flash_attention_2 | kernels-community/flash-attn ...")
    ap.add_argument("--learned-bias", action="store_true",
                    help="learned per-token output bias starting at the log-unigram seal, no weight decay (SHADOW-250M style)")
    ap.add_argument("--tag", default="", help="name for checkpoints/uploads/logs (default: the arm letter), e.g. B2")
    a = ap.parse_args()
    torch.backends.cuda.matmul.allow_tf32 = True
    # Multi-GPU (torchrun): every GPU draws the SAME sequence of micro-batches and takes every world-th one, losses are
    # scaled by the global micro-batch count, gradients are summed across GPUs -> the same maths and the same data as one
    # GPU, only the work is split. Logging, validation, checkpoints and uploads happen on rank 0.
    world = int(os.environ.get("WORLD_SIZE", 1))
    rank = int(os.environ.get("RANK", 0))
    if world > 1:
        import torch.distributed as dist
        import datetime
        torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))
        dist.init_process_group("nccl", timeout=datetime.timedelta(minutes=60))
    main0 = rank == 0
    counts = np.load(pathlib.Path(a.data) / "unigram_counts.npy")
    val = np.load(pathlib.Path(a.data) / "val.npy", mmap_mode="r")
    sf = pathlib.Path(__file__).with_name("bitnet_schedule.json")       # per-arm recipe (arm C only)
    arm_cfg = json.load(open(sf)) if sf.exists() else {}
    arm_cfg = arm_cfg if arm_cfg.get("arm") == a.arm else {}
    m = Model(a.arm, counts, a.attn, learned_bias=bool(arm_cfg.get("learned_bias")) or a.learned_bias).cuda()
    tag = a.tag or a.arm
    m.fused = a.fused_ce
    if a.compile:
        m.body = torch.compile(m.body)
    say(f"attention implementation: {m.body.config._attn_implementation}, fused readout+CE: {a.fused_ce}")
    n_train = sum(p.numel() for p in m.parameters() if p.requires_grad)
    accum = a.batch_tokens // (a.micro * SEQ)
    steps = int(a.tokens // (accum * a.micro * SEQ))
    decay = [p for n, p in m.named_parameters() if p.requires_grad and n != "seal"]
    groups = [{"params": decay}]
    if isinstance(getattr(m, "seal", None), nn.Parameter):                # learned bias: never weight-decayed (as SHADOW-250M)
        groups.append({"params": [m.seal], "weight_decay": 0.0, "no_wd": True})
    opt = torch.optim.AdamW(groups, lr=a.lr, betas=(0.9, 0.95), weight_decay=0.1)
    ck = pathlib.Path("ckpt") / tag
    ck.mkdir(parents=True, exist_ok=True)
    start = 0
    if a.resume and sorted(ck.glob("step_*.pt")):
        last = sorted(ck.glob("step_*.pt"))[-1]
        st = torch.load(last, map_location="cuda", weights_only=False)
        sd = st["model"]
        if a.compile:
            sd = {(k.replace("body.", "body._orig_mod.", 1) if k.startswith("body.") else k): v for k, v in sd.items()}
        m.load_state_dict(sd)
        opt.load_state_dict(st["opt"])
        start = st["step"]
        say(f"resumed from {last}")
    # exact resume: the data stream is the one an uninterrupted run would draw - seed 1000, fast-forwarded past the
    # micro-batches of the steps already done - so a resumed arm sees exactly the same batches as an uninterrupted one
    data = Data(a.data, seed=1000)
    for _ in range(start * accum):
        data.skip(a.micro)
    log = open(ck / "log.jsonl", "a") if main0 else None
    if main0:
        say(f"arm {tag}: {n_train:,} trainable parameters, {steps} steps of {accum * a.micro * SEQ:,} tokens, {world} GPU(s)")
    warm = max(1, steps // 100)
    # BitNet b1.58 published recipe (Microsoft, "The Era of 1-bit LLMs: Training Tips, Code and FAQ", Table 2, 700M):
    # two-stage LR 1.5e-3 -> 1e-3 (linear decay; the peak drops at the midpoint), weight decay 0.1 -> 0 at the midpoint,
    # 375 warm-up steps. Applied only to the arm named in bitnet_schedule.json next to this file (arm C).
    sched = arm_cfg or None
    if sched and main0:
        say(f"BitNet schedule for arm {a.arm}: {sched}")

    def lr_wd(step):
        if sched is None:
            return a.lr * min(1, step / warm) * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * step / steps))), 0.1
        half = int(steps * sched["switch_frac"])
        base = min(1, step / sched["warmup"]) * max(0.0, 1 - step / steps)      # warm-up, then linear decay to 0
        if step <= half:
            return sched["peak1"] * base, sched["wd1"]
        return sched["peak2"] * base, sched["wd2"]

    t0 = time.time()
    params = [p for p in m.parameters() if p.requires_grad]
    uploads = []
    for step in range(start + 1, steps + 1):
        lr, wd = lr_wd(step)
        for g in opt.param_groups:
            g["lr"] = lr
            g["weight_decay"] = 0.0 if g.get("no_wd") else wd
        tl = 0.0
        for i in range(accum):
            x = data.batch(a.micro)                     # every GPU draws every micro-batch: identical data stream
            if i % world != rank:
                continue
            with torch.autocast("cuda", dtype=torch.bfloat16):
                loss = m.loss(x.cuda(non_blocking=True)) / accum
            loss.backward()
            tl += loss.item()
        if world > 1:
            grads = [p.grad if p.grad is not None else torch.zeros_like(p) for p in params]
            flat = torch._utils._flatten_dense_tensors(grads)
            dist.all_reduce(flat)
            for p, g in zip(params, torch._utils._unflatten_dense_tensors(flat, grads)):
                p.grad = g
            t = torch.tensor([tl], device="cuda"); dist.all_reduce(t); tl = t.item()
        gn = torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0).item()
        opt.step()
        opt.zero_grad(set_to_none=True)
        rec = {"step": step, "tokens": step * accum * a.micro * SEQ, "loss": tl, "lr": lr, "wd": wd, "grad_norm": gn,
               "tok_s": (step - start) * accum * a.micro * SEQ / (time.time() - t0)}
        if main0 and (step % a.eval_every == 0 or step == steps):
            rec["val_loss"] = val_loss(m, val)
        if main0:
            log.write(json.dumps(rec) + "\n")
            log.flush()
        if main0 and (step % 20 == 0 or "val_loss" in rec):
            say(" ".join(f"{k} {v:.4g}" if isinstance(v, float) else f"{k} {v}" for k, v in rec.items()))
        if main0 and (step % a.ckpt_every == 0 or step == steps):
            p = ck / f"step_{step:05d}.pt"
            sd = m.state_dict()
            if a.compile:                                # keep checkpoint keys identical to an uncompiled model
                sd = {k.replace("body._orig_mod.", "body."): v for k, v in sd.items()}
            torch.save({"model": sd, "opt": opt.state_dict(), "step": step, "arm": a.arm, "tag": tag, "args": vars(a)}, p)
            if a.hf:                                     # upload in the background: training does not wait for the network
                import threading, shutil
                from huggingface_hub import HfApi
                lp = ck / f"log_step_{step:05d}.jsonl"
                shutil.copy(ck / "log.jsonl", lp)
                def up(p=p, lp=lp, step=step):
                    for attempt in range(5):
                        try:
                            HfApi().upload_file(path_or_fileobj=str(p), path_in_repo=f"{tag}/{p.name}", repo_id="QLNI/embeddings-are-overpriced",
                                                commit_message=f"arm {tag} step {step}")
                            HfApi().upload_file(path_or_fileobj=str(lp), path_in_repo=f"{tag}/{lp.name}",
                                                repo_id="QLNI/embeddings-are-overpriced", commit_message=f"arm {tag} log {step}")
                            say(f"uploaded {p.name}")
                            return
                        except Exception as e:
                            say(f"upload {p.name} failed (attempt {attempt + 1}): {e}")
                            time.sleep(60)
                th = threading.Thread(target=up); th.start(); uploads.append(th)
            say(f"checkpoint {p}")
        if a.max_steps and step - start >= a.max_steps:
            peak = torch.cuda.max_memory_allocated() / 2**30
            if main0:
                say(f"stopping after {a.max_steps} steps (speed test): {rec['tok_s']:.0f} tokens/s, peak GPU memory {peak:.1f} GiB")
                with open(ck / "speed.json", "w") as f:
                    json.dump({"arm": a.arm, "steps": a.max_steps, "tokens_per_step": accum * a.micro * SEQ, "micro": a.micro,
                               "tok_s": rec["tok_s"], "peak_gib": peak, "trainable": n_train, "gpus": world,
                               "gpu": torch.cuda.get_device_name(0), "attn": a.attn, "fused_ce": a.fused_ce, "compile": a.compile}, f, indent=1)
            break
    for th in uploads:
        th.join()
    if world > 1:
        dist.barrier()
        dist.destroy_process_group()


if __name__ == "__main__":
    main()