streamft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
streamft/metrics.py ADDED
@@ -0,0 +1,110 @@
1
+ """Evaluation metrics: PPL, KL, top-k agreement, routing diagnostics, timing."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import statistics
7
+ import time
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+
12
+
13
+ def cuda_time_ms(fn, warmup=5, iters=20):
14
+ for _ in range(warmup):
15
+ fn()
16
+ torch.cuda.synchronize()
17
+ ts = []
18
+ for _ in range(iters):
19
+ s = torch.cuda.Event(True)
20
+ e = torch.cuda.Event(True)
21
+ s.record()
22
+ fn()
23
+ e.record()
24
+ torch.cuda.synchronize()
25
+ ts.append(s.elapsed_time(e))
26
+ return {"median": round(statistics.median(ts), 4), "min": round(min(ts), 4),
27
+ "mean": round(statistics.mean(ts), 4)}
28
+
29
+
30
+ @torch.no_grad()
31
+ def eval_pair(teacher_logits_fn, student_logits_fn, chunks, device, max_chunks=None,
32
+ pos_chunk=256):
33
+ """Compare teacher vs student next-token distributions over token chunks.
34
+
35
+ Returns dict with CE/PPL for both, KL(teacher||student) and top-k agreements.
36
+ """
37
+ stats = {"ce_t": 0.0, "ce_s": 0.0, "kl_ts": 0.0, "kl_st": 0.0,
38
+ "top1": 0.0, "top5": 0.0, "n": 0}
39
+ n_chunks = len(chunks) if max_chunks is None else min(len(chunks), max_chunks)
40
+ for ci in range(n_chunks):
41
+ ids = chunks[ci].to(device).unsqueeze(0) # (1,T)
42
+ t_logits = teacher_logits_fn(ids)
43
+ s_logits = student_logits_fn(ids)
44
+ # predict tokens 1..T-1
45
+ labels = ids[0, 1:]
46
+ lt = t_logits[0, :-1]
47
+ ls = s_logits[0, :-1]
48
+ T = labels.shape[0]
49
+ for p0 in range(0, T, pos_chunk):
50
+ p1 = min(T, p0 + pos_chunk)
51
+ a = lt[p0:p1].float()
52
+ b = ls[p0:p1].float()
53
+ lab = labels[p0:p1]
54
+ la = F.log_softmax(a, dim=-1)
55
+ lb = F.log_softmax(b, dim=-1)
56
+ stats["ce_t"] += F.nll_loss(la, lab, reduction="sum").item()
57
+ stats["ce_s"] += F.nll_loss(lb, lab, reduction="sum").item()
58
+ stats["kl_ts"] += F.kl_div(lb, la, log_target=True, reduction="sum").item()
59
+ stats["kl_st"] += F.kl_div(la, lb, log_target=True, reduction="sum").item()
60
+ t1 = a.argmax(-1)
61
+ s1 = b.argmax(-1)
62
+ stats["top1"] += (t1 == s1).sum().item()
63
+ tk = torch.topk(a, 5, dim=-1).indices
64
+ stats["top5"] += (tk == s1.unsqueeze(-1)).any(-1).sum().item()
65
+ stats["n"] += lab.numel()
66
+ del t_logits, s_logits
67
+ n = max(stats["n"], 1)
68
+ out = {
69
+ "ntok": n,
70
+ "ce_teacher": stats["ce_t"] / n,
71
+ "ce_student": stats["ce_s"] / n,
72
+ "ppl_teacher": float(torch.exp(torch.tensor(stats["ce_t"] / n))),
73
+ "ppl_student": float(torch.exp(torch.tensor(stats["ce_s"] / n))),
74
+ "kl_teacher_student": stats["kl_ts"] / n,
75
+ "kl_student_teacher": stats["kl_st"] / n,
76
+ "top1_agreement": stats["top1"] / n,
77
+ "top5_agreement": stats["top5"] / n,
78
+ }
79
+ return out
80
+
81
+
82
+ def routing_diagnostics(captured, n_layers=None):
83
+ """Block-mass recall of the selected blocks per layer (from capture entries)."""
84
+ per_layer = []
85
+ for entry in captured:
86
+ if "mass" not in entry or "idx" not in entry:
87
+ continue
88
+ mass = entry["mass"] # (B,H,NB,NB) normalized over J
89
+ idx = entry["idx"] # (B,NB,MMAX)
90
+ B, H, NB, _ = mass.shape
91
+ MMAX = idx.shape[2]
92
+ sel = torch.zeros(B, 1, NB, NB, device=mass.device, dtype=torch.bool)
93
+ valid = idx >= 0
94
+ for j in range(MMAX):
95
+ jj = idx[:, :, j].clamp_min(0).long() # (B,NB)
96
+ sel.scatter_(3, jj[:, None, :, None], True)
97
+ sel = sel & valid.any(-1)[:, None, :, None]
98
+ recall = (mass * sel).sum(-1) / mass.sum(-1).clamp_min(1e-9) # (B,H,NB)
99
+ per_layer.append({"recall_mean": float(recall.mean()), "recall_min": float(recall.min())})
100
+ return per_layer
101
+
102
+
103
+ def append_jsonl(path, record):
104
+ os.makedirs(os.path.dirname(path), exist_ok=True)
105
+ with open(path, "a", encoding="utf-8") as f:
106
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
107
+
108
+
109
+ def now():
110
+ return time.strftime("%Y-%m-%d %H:%M:%S")
@@ -0,0 +1,216 @@
1
+ """Streaming QLoRA: train LoRA adapters on a 4-bit base while blocks stream CPU<->GPU.
2
+
3
+ This is the "our own engine" counterpart to the HF in-memory QLoRA attempt. The
4
+ base model is loaded in 4-bit (NF4) on CPU with `device_map="cpu"`; only one
5
+ decoder block is on the GPU at a time. That keeps VRAM bounded by one block +
6
+ activations + embedding/head, independent of model depth, so 7B can be trained
7
+ on a 6 GB card even though the full 4-bit model + compute temporaries do not fit
8
+ (HF's whole-model QLoRA peaked at 8.4-8.7 GB and spilled).
9
+
10
+ The backward pass re-streams blocks in reverse order and accumulates LoRA
11
+ gradients on CPU; the base 4-bit weights are never updated. This is the same
12
+ recompute strategy as `lcsa/stream_train.py` (P1), but with a PEFT LoRA on a
13
+ bitsandbytes 4-bit base instead of the minimal local LoRA on a bf16 base.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import time
18
+
19
+ import torch
20
+
21
+ from .stream_train import _layer_out, chunked_lm_loss_and_backward
22
+
23
+
24
+ def chunked_lm_loss_and_backward_masked(head, h, ys, chunk=128, ignore_index=-100):
25
+ """Chunked CE with an ignore_index (for SFT prompt masking)."""
26
+ B, T, _ = h.shape
27
+ total = torch.zeros((), device=h.device, dtype=torch.float32)
28
+ valid_total = 0
29
+ for a in range(0, T, chunk):
30
+ b = min(a + chunk, T)
31
+ logits = head(h[:, a:b])
32
+ tgt = ys[:, a:b].reshape(-1).to(h.device)
33
+ valid = int((tgt != ignore_index).sum())
34
+ if valid == 0:
35
+ continue
36
+ loss_sum = torch.nn.functional.cross_entropy(
37
+ logits.reshape(-1, logits.shape[-1]).float(), tgt,
38
+ reduction="sum", ignore_index=ignore_index,
39
+ )
40
+ (loss_sum / valid).backward()
41
+ with torch.no_grad():
42
+ total = total + loss_sum.detach()
43
+ valid_total += valid
44
+ denom = max(valid_total, 1)
45
+ return total / denom, total / denom
46
+
47
+
48
+ def base_model_of(model):
49
+ """Return the underlying HF causal-LM (the module owning `.model.layers` + `.lm_head`).
50
+
51
+ Works for plain HF models and for peft/wrapper models alike: wrappers delegate
52
+ attributes, so we take the *deepest* module that owns both the decoder stack
53
+ and the LM head.
54
+ """
55
+ found = None
56
+ stack = [model]
57
+ seen = set()
58
+ while stack:
59
+ m = stack.pop()
60
+ if id(m) in seen:
61
+ continue
62
+ seen.add(id(m))
63
+ if hasattr(m, "lm_head") and hasattr(getattr(m, "model", None), "layers"):
64
+ found = m
65
+ stack.extend(m.children())
66
+ return found if found is not None else model
67
+
68
+
69
+ class StreamingQuantLoRATrainer:
70
+ """LoRA fine-tuning on a 4-bit base with block streaming."""
71
+
72
+ def __init__(self, model, device: str = "cuda", loss_chunk: int = 128,
73
+ lr: float = 1e-4, betas=(0.9, 0.999), eps: float = 1e-8,
74
+ weight_decay: float = 0.0, clip: float = 0.0,
75
+ verbose: bool = False, stage=None, release=None,
76
+ cache_device: str = "gpu"):
77
+ self.device = device
78
+ self.loss_chunk = loss_chunk
79
+ self.clip = clip
80
+ self.verbose = verbose
81
+ # Where the per-layer input activations for the recompute-backward live.
82
+ # "cpu" offloads them (frees VRAM, costs a PCIe round-trip per layer), which
83
+ # is what makes >1k contexts fit on a 6 GB card.
84
+ self.cache_device = device if cache_device == "gpu" else "cpu"
85
+ self.model = model
86
+ # How a decoder block is brought to the GPU and taken away again. The
87
+ # defaults assume the whole 4-bit base sits in host RAM; a disk-backed
88
+ # store can pass its own load/release callables instead.
89
+ self.stage = stage if stage is not None else (lambda layer, i: layer.to(device))
90
+ self.release = release if release is not None else (lambda layer, i: layer.to("cpu"))
91
+ base = base_model_of(model)
92
+ self.base = base
93
+ self.layers = list(base.model.layers)
94
+ self.embed = base.model.embed_tokens
95
+ self.rotary = base.model.rotary_emb
96
+ self.norm = base.model.norm
97
+ self.lm_head = base.lm_head
98
+
99
+ # Small/shared modules stay resident on the GPU. The input embedding is
100
+ # kept on CPU (4-bit/bf16) and only the gathered activation is moved to
101
+ # the GPU; for 14B the embedding alone is ~1.5 GB bf16. Exception: when
102
+ # the LM head is *tied* to the embedding (e.g. Qwen2.5-3B/0.5B) the same
103
+ # tensor feeds the head, so it has to live where the head runs.
104
+ self.rotary.to(device)
105
+ self.norm.to(device)
106
+ self.tied = self.lm_head.weight is self.embed.weight
107
+ if self.tied:
108
+ self.embed.to(device)
109
+ else:
110
+ self.lm_head.to(device)
111
+
112
+ self.trainables = [p for p in model.parameters() if p.requires_grad]
113
+ self.n_lora = sum(p.numel() for p in self.trainables)
114
+ self.opt = torch.optim.AdamW(self.trainables, lr=lr, betas=betas, eps=eps,
115
+ weight_decay=weight_decay)
116
+ self._acc = {id(p): torch.zeros_like(p, device="cpu", dtype=torch.float32)
117
+ for p in self.trainables}
118
+ # layer indices that received a non-zero adapter gradient (verification:
119
+ # a silently skipped block would never appear here)
120
+ self.grad_layers = set()
121
+
122
+ # ------------------------------------------------------------------ forward
123
+ @torch.no_grad()
124
+ def _forward_cached(self, ids):
125
+ dev = self.device
126
+ # Embedding usually lives on CPU (only the gathered activation moves to
127
+ # the GPU); for tied models it lives on the GPU together with the head.
128
+ emb_dev = self.embed.weight.device
129
+ h = self.embed(ids.to(emb_dev))
130
+ if h.device != torch.device(dev):
131
+ h = h.to(dev)
132
+ pos_ids = torch.arange(ids.shape[1], device=dev).unsqueeze(0)
133
+ pos = self.rotary(h, pos_ids)
134
+ cache = []
135
+ for i, layer in enumerate(self.layers):
136
+ self.stage(layer, i)
137
+ # CPU offload must be synchronous: a non-blocking copy leaves the GPU
138
+ # source alive until the copy drains, and with 64 layers queued that
139
+ # keeps ~the whole cache resident (measured: 6.6 GB at seq 2048).
140
+ cache.append(h if self.cache_device == self.device
141
+ else h.to(self.cache_device))
142
+ h = _layer_out(layer(h, position_embeddings=pos))
143
+ self.release(layer, i)
144
+ if self.verbose and (i + 1) % 8 == 0:
145
+ print(f" [fwd] {i + 1}/{len(self.layers)} blocks "
146
+ f"reserved={torch.cuda.memory_reserved()/1e9:.2f}GB", flush=True)
147
+ return h, cache, pos
148
+
149
+ # ----------------------------------------------------------------- backward
150
+ def _backward_blocks(self, cache, pos, grad):
151
+ dev = self.device
152
+ for i in range(len(self.layers) - 1, -1, -1):
153
+ layer = self.layers[i]
154
+ self.stage(layer, i)
155
+ h_in = cache[i]
156
+ if h_in.device != torch.device(dev):
157
+ h_in = h_in.to(dev)
158
+ h_in.requires_grad_(True)
159
+ out = _layer_out(layer(h_in, position_embeddings=pos))
160
+ out.backward(grad)
161
+ grad = h_in.grad
162
+ h_in.grad = None
163
+ h_in.requires_grad_(False)
164
+ for p in layer.parameters():
165
+ if p.requires_grad and p.grad is not None:
166
+ if float(p.grad.abs().sum()) > 0:
167
+ self.grad_layers.add(i)
168
+ self._acc[id(p)].add_(p.grad.detach().float().cpu())
169
+ p.grad = None
170
+ self.release(layer, i)
171
+ if self.verbose and (len(self.layers) - i) % 8 == 0:
172
+ print(f" [bwd] {len(self.layers) - i}/{len(self.layers)} blocks "
173
+ f"reserved={torch.cuda.memory_reserved()/1e9:.2f}GB", flush=True)
174
+
175
+ # --------------------------------------------------------------------- step
176
+ def backward(self, xs, ys, ignore_index=None):
177
+ """Forward + backward on one batch, without touching the optimizer.
178
+
179
+ Returns (loss, grads, peak_gb) where grads maps id(param) -> cloned tensor.
180
+ This is the exact path step() uses, exposed so the gradients can be checked
181
+ against finite differences or another implementation.
182
+ """
183
+ torch.cuda.synchronize()
184
+ torch.cuda.reset_peak_memory_stats()
185
+ with torch.no_grad():
186
+ h_blocks, cache, pos = self._forward_cached(xs)
187
+ h_b = h_blocks.detach().requires_grad_(True)
188
+ z_g = self.norm(h_b)
189
+ z = z_g.detach().requires_grad_(True)
190
+ if ignore_index is None:
191
+ _, loss = chunked_lm_loss_and_backward(self.lm_head, z, ys, self.loss_chunk)
192
+ else:
193
+ _, loss = chunked_lm_loss_and_backward_masked(
194
+ self.lm_head, z, ys, self.loss_chunk, ignore_index=ignore_index
195
+ )
196
+ z_g.backward(z.grad)
197
+ self._backward_blocks(cache, pos, h_b.grad)
198
+ grads = {id(p): self._acc[id(p)].clone() for p in self.trainables}
199
+ for p in self.trainables:
200
+ self._acc[id(p)].zero_()
201
+ torch.cuda.synchronize()
202
+ return float(loss), grads, torch.cuda.max_memory_reserved() / 1e9
203
+
204
+ def step(self, xs, ys, ignore_index=None):
205
+ torch.cuda.synchronize()
206
+ t0 = time.time()
207
+ loss, grads, peak = self.backward(xs, ys, ignore_index)
208
+ for p in self.trainables:
209
+ p.grad = grads[id(p)].to(p.dtype)
210
+ if self.clip > 0:
211
+ torch.nn.utils.clip_grad_norm_(self.trainables, self.clip)
212
+ self.opt.step()
213
+ for p in self.trainables:
214
+ p.grad = None
215
+ torch.cuda.synchronize()
216
+ return loss, time.time() - t0, peak
@@ -0,0 +1,230 @@
1
+ """P1: block-streaming ("parça parça") LoRA trainer for low-VRAM GPUs.
2
+
3
+ Base weights live in CPU RAM. Each transformer block is moved to the GPU just
4
+ for its forward pass, moved back, and re-streamed in reverse order during the
5
+ backward pass (per-block activation recompute). Peak VRAM is bounded by
6
+ (one block + its activations + embedding/head + loss chunk), independent of
7
+ model depth — so a 6GB GPU can fine-tune models whose full weights alone do
8
+ not fit (e.g. 3B fp16 = 6.2GB).
9
+
10
+ Pipeline per step:
11
+ forward : embed -> [block_i on GPU, run, off] x L -> final norm
12
+ loss : chunked cross-entropy through lm_head (bounded logits memory)
13
+ backward : dL/dh_final -> [block_i back on GPU, recompute forward with grad,
14
+ backward, accumulate LoRA grads on CPU, off] x L (reverse)
15
+ step : AdamW on the (small, CPU-resident) LoRA params
16
+
17
+ Correctness: the recompute backward is mathematically identical to a normal
18
+ autograd backward over the same block list; experiments/exp8_stream.py checks
19
+ the loss trajectory against a fully-on-GPU naive trainer on identical batches.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import time
24
+
25
+ import torch
26
+ import torch.nn.functional as F
27
+
28
+ from .lora import apply_lora, count_lora_params, lora_parameters
29
+
30
+
31
+ def _layer_out(out):
32
+ return out[0] if isinstance(out, tuple) else out
33
+
34
+
35
+ def chunked_lm_loss_and_backward(head, h, ys, chunk=128):
36
+ """Cross-entropy in position chunks; accumulates dL/dh into h.grad.
37
+
38
+ h: (B, T, d) leaf requiring grad (or the output of a small graph).
39
+ Targets are pre-shifted (TrainSampler convention): ys[p] is the target of
40
+ position p, all T positions valid.
41
+
42
+ FIX (19.10.2026): the previous version used F.cross_entropy(mean) per chunk
43
+ and called backward(), which scaled the accumulated gradient by the number
44
+ of chunks (T/chunk). Now each chunk contributes a cross-entropy SUM divided
45
+ by n_pos, so the accumulated gradient is exactly d(mean CE)/dh.
46
+ """
47
+ B, T, _ = h.shape
48
+ n_pos = T
49
+ total = torch.zeros((), device=h.device, dtype=torch.float32)
50
+ last = None
51
+ for a in range(0, n_pos, chunk):
52
+ b = min(a + chunk, n_pos)
53
+ logits = head(h[:, a:b])
54
+ tgt = ys[:, a:b].reshape(-1).to(h.device)
55
+ loss_sum = F.cross_entropy(logits.reshape(-1, logits.shape[-1]).float(),
56
+ tgt, reduction="sum")
57
+ (loss_sum / n_pos).backward()
58
+ with torch.no_grad():
59
+ total = total + loss_sum.detach()
60
+ last = loss_sum
61
+ return last.detach() / n_pos, total / n_pos
62
+
63
+
64
+ @torch.no_grad()
65
+ def stream_eval_ce(trainer, ids, loss_chunk=128):
66
+ """Mean next-token CE over a (1, T) token chunk, via the streaming forward.
67
+
68
+ Positions 0..T-2 predict ids[1..T-1] (standard intra-window LM loss).
69
+ Reuses the trainer's block streaming; no backward, no cache retained.
70
+ """
71
+ h, _cache, _pos = trainer._forward_cached(ids)
72
+ z = trainer.norm(h)
73
+ total = 0.0
74
+ n = 0
75
+ T = ids.shape[1]
76
+ for a in range(0, T - 1, loss_chunk):
77
+ b = min(a + loss_chunk, T - 1)
78
+ logits = trainer.lm_head(z[:, a:b]).float()
79
+ tgt = ids[:, a + 1: b + 1].reshape(-1).to(z.device)
80
+ total += float(F.cross_entropy(logits.reshape(-1, logits.shape[-1]), tgt,
81
+ reduction="sum"))
82
+ n += tgt.numel()
83
+ return total / max(n, 1)
84
+
85
+
86
+ class StreamingLoRATrainer:
87
+ """LoRA fine-tuning while base blocks stream CPU <-> GPU."""
88
+
89
+ def __init__(self, model, device: str = "cuda", loss_chunk: int = 128,
90
+ lr: float = 1e-3, betas=(0.9, 0.999), eps: float = 1e-8,
91
+ weight_decay: float = 0.0, r_attn: int = 16, r_mlp: int = 0,
92
+ clip: float = 0.0, verbose: bool = False):
93
+ self.device = device
94
+ self.loss_chunk = loss_chunk
95
+ self.clip = clip
96
+ self.verbose = verbose
97
+ apply_lora(model, r_attn=r_attn, r_mlp=r_mlp)
98
+ self.model = model
99
+ base = model.model
100
+ self.layers = list(base.layers)
101
+ self.embed = base.embed_tokens
102
+ self.rotary = base.rotary_emb
103
+ self.norm = base.norm
104
+ self.lm_head = model.lm_head
105
+
106
+ model.to("cpu")
107
+ for p in model.parameters():
108
+ p.requires_grad_(False)
109
+ # shared small modules stay resident on GPU
110
+ self.embed.to(device)
111
+ self.rotary.to(device)
112
+ self.norm.to(device)
113
+ if self.lm_head.weight is not self.embed.weight:
114
+ self.lm_head.to(device)
115
+ for p in self.lm_head.parameters():
116
+ p.requires_grad_(False)
117
+
118
+ self.trainables = list(lora_parameters(model))
119
+ for p in self.trainables:
120
+ p.requires_grad_(True)
121
+ self.n_lora = count_lora_params(model)
122
+ self.opt = torch.optim.AdamW(self.trainables, lr=lr, betas=betas, eps=eps,
123
+ weight_decay=weight_decay)
124
+ self._acc = {id(p): torch.zeros_like(p, device="cpu") for p in self.trainables}
125
+
126
+ # ------------------------------------------------------------------ forward
127
+ @torch.no_grad()
128
+ def _forward_cached(self, ids):
129
+ dev = self.device
130
+ h = self.embed(ids.to(dev))
131
+ pos_ids = torch.arange(ids.shape[1], device=dev).unsqueeze(0)
132
+ pos = self.rotary(h, pos_ids)
133
+ cache = []
134
+ for i, layer in enumerate(self.layers):
135
+ layer.to(dev)
136
+ cache.append(h)
137
+ h = _layer_out(layer(h, position_embeddings=pos))
138
+ layer.to("cpu")
139
+ if self.verbose and (i + 1) % 8 == 0:
140
+ print(f" [fwd] {i + 1}/{len(self.layers)} blocks "
141
+ f"reserved={torch.cuda.memory_reserved()/1e9:.2f}GB", flush=True)
142
+ return h, cache, pos
143
+
144
+ # ----------------------------------------------------------------- backward
145
+ def _backward_blocks(self, cache, pos, grad):
146
+ dev = self.device
147
+ for i in range(len(self.layers) - 1, -1, -1):
148
+ layer = self.layers[i]
149
+ layer.to(dev)
150
+ h_in = cache[i]
151
+ h_in.requires_grad_(True)
152
+ out = _layer_out(layer(h_in, position_embeddings=pos))
153
+ out.backward(grad)
154
+ grad = h_in.grad
155
+ h_in.grad = None
156
+ h_in.requires_grad_(False)
157
+ for p in layer.parameters():
158
+ if p.requires_grad and p.grad is not None:
159
+ self._acc[id(p)].add_(p.grad.detach().float().cpu())
160
+ p.grad = None
161
+ layer.to("cpu")
162
+ if self.verbose and (len(self.layers) - i) % 8 == 0:
163
+ print(f" [bwd] {len(self.layers) - i}/{len(self.layers)} blocks "
164
+ f"reserved={torch.cuda.memory_reserved()/1e9:.2f}GB", flush=True)
165
+
166
+ # --------------------------------------------------------------------- step
167
+ def step(self, xs, ys):
168
+ torch.cuda.synchronize()
169
+ t0 = time.time()
170
+ torch.cuda.reset_peak_memory_stats()
171
+ with torch.no_grad():
172
+ h_blocks, cache, pos = self._forward_cached(xs)
173
+ h_b = h_blocks.detach().requires_grad_(True)
174
+ z_g = self.norm(h_b) # graph h_b -> z_g (elementwise, cheap)
175
+ z = z_g.detach().requires_grad_(True) # leaf: chunked head backward accumulates z.grad
176
+ _, loss = chunked_lm_loss_and_backward(self.lm_head, z, ys, self.loss_chunk)
177
+ z_g.backward(z.grad) # norm graph traversed exactly once
178
+ grad = h_b.grad
179
+ self._backward_blocks(cache, pos, grad)
180
+ for p in self.trainables:
181
+ p.grad = self._acc[id(p)]
182
+ if self.clip > 0:
183
+ torch.nn.utils.clip_grad_norm_(self.trainables, self.clip)
184
+ self.opt.step()
185
+ for p in self.trainables:
186
+ p.grad = None
187
+ self._acc[id(p)].zero_()
188
+ torch.cuda.synchronize()
189
+ peak = torch.cuda.max_memory_reserved() / 1e9
190
+ return float(loss), time.time() - t0, peak
191
+
192
+
193
+ class NaiveLoRATrainer:
194
+ """Reference: everything on GPU, standard full-graph autograd + full logits."""
195
+
196
+ def __init__(self, model, device: str = "cuda", lr: float = 1e-3,
197
+ betas=(0.9, 0.999), eps: float = 1e-8, weight_decay: float = 0.0,
198
+ r_attn: int = 16, r_mlp: int = 0, clip: float = 0.0,
199
+ verbose: bool = False):
200
+ self.device = device
201
+ self.clip = clip
202
+ self.verbose = verbose
203
+ apply_lora(model, r_attn=r_attn, r_mlp=r_mlp)
204
+ self.model = model.to(device)
205
+ for p in model.parameters():
206
+ p.requires_grad_(False)
207
+ self.trainables = list(lora_parameters(model))
208
+ for p in self.trainables:
209
+ p.requires_grad_(True)
210
+ self.n_lora = count_lora_params(model)
211
+ self.opt = torch.optim.AdamW(self.trainables, lr=lr, betas=betas, eps=eps,
212
+ weight_decay=weight_decay)
213
+
214
+ def step(self, xs, ys):
215
+ torch.cuda.synchronize()
216
+ t0 = time.time()
217
+ torch.cuda.reset_peak_memory_stats()
218
+ dev = self.device
219
+ ys = ys.to(dev)
220
+ h = self.model.model(input_ids=xs.to(dev), use_cache=False).last_hidden_state
221
+ logits = self.model.lm_head(h).float()
222
+ loss = F.cross_entropy(logits.reshape(-1, logits.shape[-1]), ys.reshape(-1))
223
+ loss.backward()
224
+ if self.clip > 0:
225
+ torch.nn.utils.clip_grad_norm_(self.trainables, self.clip)
226
+ self.opt.step()
227
+ self.opt.zero_grad(set_to_none=True)
228
+ torch.cuda.synchronize()
229
+ peak = torch.cuda.max_memory_reserved() / 1e9
230
+ return float(loss.detach()), time.time() - t0, peak