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/__init__.py +32 -0
- streamft/__main__.py +59 -0
- streamft/autotune.py +236 -0
- streamft/cli.py +450 -0
- streamft/data.py +81 -0
- streamft/disk_quant_store.py +353 -0
- streamft/doctor.py +34 -0
- streamft/fit.py +128 -0
- streamft/lora.py +108 -0
- streamft/metrics.py +110 -0
- streamft/stream_quant_lora.py +216 -0
- streamft/stream_train.py +230 -0
- streamft-0.2.0.dist-info/METADATA +239 -0
- streamft-0.2.0.dist-info/RECORD +18 -0
- streamft-0.2.0.dist-info/WHEEL +5 -0
- streamft-0.2.0.dist-info/entry_points.txt +2 -0
- streamft-0.2.0.dist-info/licenses/LICENSE +21 -0
- streamft-0.2.0.dist-info/top_level.txt +1 -0
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
|
streamft/stream_train.py
ADDED
|
@@ -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
|