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/cli.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""streamft.cli: disk-backed (mmap) streaming QLoRA -- weights live on the SSD.
|
|
2
|
+
|
|
3
|
+
Companion to `exp52`. Instead of keeping the 4-bit base in host RAM, the
|
|
4
|
+
pre-quantized bnb-4bit safetensors are memory-mapped and each decoder block is
|
|
5
|
+
materialized on the GPU on demand and dropped afterwards. Host RAM becomes a
|
|
6
|
+
free, OS-managed page cache for the hot blocks, so the model size is bounded by
|
|
7
|
+
disk space (and step time), not by RAM.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# 1) turn a local bf16 model into a bnb-4bit checkpoint (once)
|
|
11
|
+
python -m streamft.cli --prepare --model models/Qwen2.5-3B \
|
|
12
|
+
--save-quant checkpoints/qwen3b_bnb4bit
|
|
13
|
+
# 2) train LoRA on it straight from disk
|
|
14
|
+
python -m streamft.cli --model-dir checkpoints/qwen3b_bnb4bit \
|
|
15
|
+
--steps 5 --seq 128 --eval_chunks 1 --tag disk_qlora3b
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import torch
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
30
|
+
sys.path.insert(0, str(ROOT))
|
|
31
|
+
|
|
32
|
+
from streamft.data import TrainSampler, chunks_from_tokens, load_tokens # noqa: E402
|
|
33
|
+
from streamft.disk_quant_store import ( # noqa: E402
|
|
34
|
+
DiskQuantStore,
|
|
35
|
+
LayerCache,
|
|
36
|
+
build_skeleton,
|
|
37
|
+
load_layer,
|
|
38
|
+
materialize_shared,
|
|
39
|
+
release_layer,
|
|
40
|
+
)
|
|
41
|
+
from streamft.autotune import plan_for # noqa: E402
|
|
42
|
+
from streamft.lora import count_lora_params, lora_state_dict # noqa: E402
|
|
43
|
+
from streamft.metrics import append_jsonl, now # noqa: E402
|
|
44
|
+
from streamft.stream_quant_lora import StreamingQuantLoRATrainer # noqa: E402
|
|
45
|
+
from streamft.stream_train import stream_eval_ce # noqa: E402
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import psutil
|
|
49
|
+
_proc = psutil.Process()
|
|
50
|
+
|
|
51
|
+
def host_rss_gb():
|
|
52
|
+
return round(_proc.memory_info().rss / 1e9, 2)
|
|
53
|
+
|
|
54
|
+
def host_private_gb():
|
|
55
|
+
# private (non-shared) bytes: excludes the mmap'd weight pages, which the
|
|
56
|
+
# OS can evict -- that is the number that matters for "does it fit in RAM"
|
|
57
|
+
try:
|
|
58
|
+
return round(_proc.memory_info().private / 1e9, 2)
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
except Exception:
|
|
62
|
+
def host_rss_gb():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def host_private_gb():
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def adapter_sha(model) -> str:
|
|
70
|
+
"""Stable fingerprint of the trainable (adapter) tensors."""
|
|
71
|
+
h = hashlib.sha256()
|
|
72
|
+
for n, p in sorted(model.named_parameters()):
|
|
73
|
+
if p.requires_grad:
|
|
74
|
+
h.update(n.encode())
|
|
75
|
+
h.update(p.detach().float().cpu().contiguous().numpy().tobytes())
|
|
76
|
+
return h.hexdigest()[:16]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prepare(model: str, save_quant: str) -> int:
|
|
80
|
+
"""Quantize a local bf16 model into a bnb-4bit checkpoint (uint8 + qstate)."""
|
|
81
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
|
82
|
+
|
|
83
|
+
src = str(ROOT / model)
|
|
84
|
+
dst = ROOT / save_quant
|
|
85
|
+
print(f"[prepare] {model} -> {save_quant}", flush=True)
|
|
86
|
+
bnb = BitsAndBytesConfig(
|
|
87
|
+
load_in_4bit=True,
|
|
88
|
+
bnb_4bit_quant_type="nf4",
|
|
89
|
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
|
90
|
+
bnb_4bit_use_double_quant=True,
|
|
91
|
+
llm_int8_enable_fp32_cpu_offload=True,
|
|
92
|
+
)
|
|
93
|
+
t0 = time.time()
|
|
94
|
+
m = AutoModelForCausalLM.from_pretrained(
|
|
95
|
+
src, quantization_config=bnb, device_map="cpu", low_cpu_mem_usage=True)
|
|
96
|
+
print(f"[prepare] loaded {time.time()-t0:.1f}s rss={host_rss_gb()}GB", flush=True)
|
|
97
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
m.save_pretrained(str(dst), safe_serialization=True, max_shard_size="2GB")
|
|
99
|
+
AutoTokenizer.from_pretrained(src).save_pretrained(str(dst))
|
|
100
|
+
print(f"[prepare] saved {dst} rss={host_rss_gb()}GB", flush=True)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_messages(row: dict) -> list[dict]:
|
|
105
|
+
user = row["instruction"]
|
|
106
|
+
if row.get("context"):
|
|
107
|
+
user = f"{user}\n\nContext:\n{row['context']}"
|
|
108
|
+
return [
|
|
109
|
+
{"role": "user", "content": user},
|
|
110
|
+
{"role": "assistant", "content": row["response"]},
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _encode_chat(tokenizer, row: dict, seq: int):
|
|
115
|
+
"""Chat-template encode with the prompt tokens masked out of the loss."""
|
|
116
|
+
messages = _format_messages(row)
|
|
117
|
+
full_text = tokenizer.apply_chat_template(messages, tokenize=False,
|
|
118
|
+
add_generation_prompt=False)
|
|
119
|
+
full = tokenizer(full_text, add_special_tokens=False).input_ids
|
|
120
|
+
prompt_text = tokenizer.apply_chat_template(messages[:-1], tokenize=False,
|
|
121
|
+
add_generation_prompt=True)
|
|
122
|
+
prompt = tokenizer(prompt_text, add_special_tokens=False).input_ids
|
|
123
|
+
full = full[:seq]
|
|
124
|
+
labels = list(full)
|
|
125
|
+
if 0 < len(prompt) < len(full) and full[:len(prompt)] == prompt[:len(full)]:
|
|
126
|
+
k = min(len(prompt), len(full))
|
|
127
|
+
labels = [-100] * k + labels[k:]
|
|
128
|
+
if len(labels) < 2:
|
|
129
|
+
return None
|
|
130
|
+
return (torch.tensor([full], dtype=torch.long),
|
|
131
|
+
torch.tensor([labels], dtype=torch.long))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@torch.no_grad()
|
|
135
|
+
def _masked_eval(trainer, rows, tokenizer, seq: int) -> float:
|
|
136
|
+
"""Masked SFT loss through the streaming (disk-backed) forward."""
|
|
137
|
+
trainer.model.eval()
|
|
138
|
+
total, n = 0.0, 0
|
|
139
|
+
for row in rows:
|
|
140
|
+
enc = _encode_chat(tokenizer, row, seq)
|
|
141
|
+
if enc is None:
|
|
142
|
+
continue
|
|
143
|
+
ids, labels = enc
|
|
144
|
+
ys = torch.full_like(ids, -100)
|
|
145
|
+
ys[:, :-1] = labels[:, 1:]
|
|
146
|
+
h, _cache, _pos = trainer._forward_cached(ids)
|
|
147
|
+
z = trainer.norm(h)
|
|
148
|
+
logits = trainer.lm_head(z).float()
|
|
149
|
+
tgt = ys.reshape(-1)
|
|
150
|
+
valid = tgt != -100
|
|
151
|
+
v = int(valid.sum())
|
|
152
|
+
if v == 0:
|
|
153
|
+
continue
|
|
154
|
+
total += float(torch.nn.functional.cross_entropy(
|
|
155
|
+
logits.reshape(-1, logits.shape[-1])[valid],
|
|
156
|
+
tgt[valid].to(logits.device), reduction="sum"))
|
|
157
|
+
n += v
|
|
158
|
+
trainer.model.train()
|
|
159
|
+
return total / max(n, 1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> int:
|
|
163
|
+
ap = argparse.ArgumentParser()
|
|
164
|
+
ap.add_argument("--prepare", action="store_true")
|
|
165
|
+
ap.add_argument("--model", default="models/Qwen2.5-3B")
|
|
166
|
+
ap.add_argument("--save-quant", default="checkpoints/qwen3b_bnb4bit")
|
|
167
|
+
ap.add_argument("--model-dir", default="checkpoints/qwen3b_bnb4bit")
|
|
168
|
+
ap.add_argument("--data", default="data/wt2_train.pt")
|
|
169
|
+
ap.add_argument("--eval_data", default="data/wt2_test.pt")
|
|
170
|
+
ap.add_argument("--steps", type=int, default=3)
|
|
171
|
+
ap.add_argument("--seq", type=int, default=128)
|
|
172
|
+
ap.add_argument("--chunk", type=int, default=64)
|
|
173
|
+
ap.add_argument("--lr", type=float, default=1e-4)
|
|
174
|
+
ap.add_argument("--lora_r", type=int, default=8)
|
|
175
|
+
ap.add_argument("--lora_alpha", type=int, default=16)
|
|
176
|
+
ap.add_argument("--targets", choices=["attn", "all"], default="attn")
|
|
177
|
+
ap.add_argument("--head", choices=["bf16", "nf4"], default="bf16")
|
|
178
|
+
ap.add_argument("--shared-dtype", choices=["fp32", "as-stored"], default="fp32",
|
|
179
|
+
help="fp32 matches prepare_model_for_kbit_training; 'as-stored' "
|
|
180
|
+
"keeps embed/head in the checkpoint dtype (big models)")
|
|
181
|
+
ap.add_argument("--blocks-on-gpu", default="adaptive",
|
|
182
|
+
help="'adaptive' = start at 1 block and grow from the measured "
|
|
183
|
+
"peak; 'auto' = static plan from free VRAM; or an integer")
|
|
184
|
+
ap.add_argument("--adapt-every", type=int, default=10,
|
|
185
|
+
help="re-measure and resize the block window every N steps "
|
|
186
|
+
"(0 = only after the first step)")
|
|
187
|
+
ap.add_argument("--vram-soft-gb", type=float, default=0.0,
|
|
188
|
+
help="grow towards this VRAM budget (0 = 80%% of the device)")
|
|
189
|
+
ap.add_argument("--vram-hard-gb", type=float, default=0.0,
|
|
190
|
+
help="never exceed this VRAM budget (0 = 88%% of the device); "
|
|
191
|
+
"useful to leave room for the display/other apps")
|
|
192
|
+
ap.add_argument("--eval_chunks", type=int, default=0)
|
|
193
|
+
ap.add_argument("--eval_seq", type=int, default=0,
|
|
194
|
+
help="evaluation sequence length (default = --seq); set it to a "
|
|
195
|
+
"common value to compare runs trained at different lengths")
|
|
196
|
+
ap.add_argument("--activation-cache", choices=["gpu", "cpu"], default="gpu",
|
|
197
|
+
help="where the per-layer inputs for the recompute-backward live; "
|
|
198
|
+
"'cpu' frees VRAM at a PCIe round-trip per layer and is what "
|
|
199
|
+
"lets context beyond ~1k fit on a 6 GB card")
|
|
200
|
+
ap.add_argument("--sft", action="store_true",
|
|
201
|
+
help="train on instruction data (Dolly-15k) with prompt masking")
|
|
202
|
+
ap.add_argument("--dataset", default="databricks/databricks-dolly-15k")
|
|
203
|
+
ap.add_argument("--train_size", type=int, default=256)
|
|
204
|
+
ap.add_argument("--eval_size", type=int, default=8)
|
|
205
|
+
ap.add_argument("--save", default="")
|
|
206
|
+
ap.add_argument("--tag", default="disk_qlora")
|
|
207
|
+
ap.add_argument("--verbose", action="store_true")
|
|
208
|
+
args = ap.parse_args()
|
|
209
|
+
if args.eval_seq <= 0:
|
|
210
|
+
args.eval_seq = args.seq
|
|
211
|
+
|
|
212
|
+
if args.prepare:
|
|
213
|
+
return prepare(args.model, args.save_quant)
|
|
214
|
+
|
|
215
|
+
model_dir = str(ROOT / args.model_dir)
|
|
216
|
+
print(f"[exp54] model_dir={Path(model_dir).name} steps={args.steps} seq={args.seq} "
|
|
217
|
+
f"lora_r={args.lora_r} targets={args.targets} head={args.head}", flush=True)
|
|
218
|
+
|
|
219
|
+
t0 = time.time()
|
|
220
|
+
model, cfg = build_skeleton(
|
|
221
|
+
model_dir, lora_r=args.lora_r, lora_alpha=args.lora_alpha,
|
|
222
|
+
targets=args.targets, head=args.head,
|
|
223
|
+
low_precision_shared=(args.shared_dtype == "as-stored"),
|
|
224
|
+
)
|
|
225
|
+
store = DiskQuantStore(model_dir, device="cuda")
|
|
226
|
+
|
|
227
|
+
# Plan before anything is materialized: torch.cuda.mem_get_info() then sees
|
|
228
|
+
# the real free VRAM (the LM head is loaded *after* this, and plan_for
|
|
229
|
+
# accounts for its size itself).
|
|
230
|
+
mode_blocks = str(args.blocks_on_gpu).lower()
|
|
231
|
+
if mode_blocks in ("auto", "adaptive"):
|
|
232
|
+
plan = plan_for(model_dir, seq=args.seq, lora_r=args.lora_r,
|
|
233
|
+
head=args.head, loss_chunk=args.chunk)
|
|
234
|
+
print(plan.summary(), flush=True)
|
|
235
|
+
n_blocks = plan.blocks_on_gpu
|
|
236
|
+
block_gb, n_layers = plan.block_gb, plan.n_layers
|
|
237
|
+
else:
|
|
238
|
+
plan = None
|
|
239
|
+
n_blocks = int(args.blocks_on_gpu)
|
|
240
|
+
block_gb, n_layers = 0.0, 0
|
|
241
|
+
|
|
242
|
+
# measured autotune: always start from the safest window (1 block) and let
|
|
243
|
+
# the first real step tell us how much room is actually left
|
|
244
|
+
start_blocks = 1 if mode_blocks == "adaptive" else n_blocks
|
|
245
|
+
|
|
246
|
+
base = materialize_shared(model, store, cfg, device="cuda")
|
|
247
|
+
print(f"[load] skeleton+shared {time.time()-t0:.1f}s keys={len(store.weight_map)} "
|
|
248
|
+
f"rss={host_rss_gb()}GB", flush=True)
|
|
249
|
+
|
|
250
|
+
layers = list(base.model.layers)
|
|
251
|
+
cache = LayerCache(store, layers, device="cuda", capacity=start_blocks)
|
|
252
|
+
|
|
253
|
+
# --- measured autotune -------------------------------------------------
|
|
254
|
+
adapt = None
|
|
255
|
+
adapt_hist: list = []
|
|
256
|
+
if mode_blocks == "adaptive" and plan is not None:
|
|
257
|
+
dev_total_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
|
|
258
|
+
soft_gb = args.vram_soft_gb or dev_total_gb * 0.80 # grow towards this
|
|
259
|
+
hard_gb = args.vram_hard_gb or dev_total_gb * 0.88 # never exceed this
|
|
260
|
+
every = max(0, int(args.adapt_every))
|
|
261
|
+
state = {"step": 0, "hist": []}
|
|
262
|
+
adapt_hist = state["hist"]
|
|
263
|
+
|
|
264
|
+
def _marginal_gb() -> float:
|
|
265
|
+
"""Measured VRAM cost of one extra resident block (fallback: nominal).
|
|
266
|
+
|
|
267
|
+
Scan back for the most recent pair of measurements taken at *different*
|
|
268
|
+
capacities; consecutive steps usually share the same window.
|
|
269
|
+
"""
|
|
270
|
+
h = state["hist"]
|
|
271
|
+
for k in range(len(h) - 1, 0, -1):
|
|
272
|
+
c1, p1 = h[k]
|
|
273
|
+
c0, p0 = h[k - 1]
|
|
274
|
+
if c1 != c0:
|
|
275
|
+
return max(1e-6, (p1 - p0) / (c1 - c0))
|
|
276
|
+
return block_gb or 0.25
|
|
277
|
+
|
|
278
|
+
def adapt(peak_gb: float, i: int) -> None:
|
|
279
|
+
state["step"] += 1
|
|
280
|
+
n = state["step"]
|
|
281
|
+
state["hist"].append((cache.capacity, round(peak_gb, 3)))
|
|
282
|
+
if not (n == 1 or (every > 0 and n % every == 0)):
|
|
283
|
+
return
|
|
284
|
+
margin = _marginal_gb()
|
|
285
|
+
if peak_gb > hard_gb:
|
|
286
|
+
shrink = int((peak_gb - hard_gb) // max(margin, 1e-9)) + 1
|
|
287
|
+
cache.set_capacity(max(1, cache.capacity - shrink))
|
|
288
|
+
torch.cuda.empty_cache() # release what the allocator still holds
|
|
289
|
+
print(f"[autotune] step {n}: peak {peak_gb:.2f}GB > hard "
|
|
290
|
+
f"{hard_gb:.2f}GB -> {cache.capacity} blocks", flush=True)
|
|
291
|
+
return
|
|
292
|
+
head = soft_gb - peak_gb
|
|
293
|
+
extra = int((head * 0.9) // max(margin, 1e-9)) if head > 0 else 0
|
|
294
|
+
# never *grow* past the hard limit either: otherwise soft > hard
|
|
295
|
+
# configurations oscillate between grow and shrink
|
|
296
|
+
if extra > 0 and peak_gb + extra * margin > hard_gb:
|
|
297
|
+
extra = max(0, int((hard_gb - peak_gb) // max(margin, 1e-9)))
|
|
298
|
+
if extra >= 1 and cache.capacity < n_layers:
|
|
299
|
+
before = cache.capacity
|
|
300
|
+
cache.set_capacity(min(n_layers, before + extra))
|
|
301
|
+
print(f"[autotune] step {n}: peak {peak_gb:.2f}GB, marginal "
|
|
302
|
+
f"{margin * 1024:.0f}MB/block -> {before}->{cache.capacity} blocks",
|
|
303
|
+
flush=True)
|
|
304
|
+
|
|
305
|
+
def stage(layer, i):
|
|
306
|
+
cache.stage(layer, i)
|
|
307
|
+
|
|
308
|
+
def release(layer, i):
|
|
309
|
+
cache.release(layer, i)
|
|
310
|
+
|
|
311
|
+
print(f"[cache] keeping up to {cache.capacity} block(s) resident on the GPU",
|
|
312
|
+
flush=True)
|
|
313
|
+
|
|
314
|
+
tr = StreamingQuantLoRATrainer(
|
|
315
|
+
model, device="cuda", loss_chunk=args.chunk, lr=args.lr, clip=1.0,
|
|
316
|
+
verbose=args.verbose, stage=stage, release=release,
|
|
317
|
+
cache_device=args.activation_cache,
|
|
318
|
+
)
|
|
319
|
+
sha_before = adapter_sha(model)
|
|
320
|
+
print(f"[trainer] layers={len(tr.layers)} lora_params={tr.n_lora/1e6:.2f}M "
|
|
321
|
+
f"tied={tr.tied} rss={host_rss_gb()}GB params={count_lora_params(model)/1e6:.2f}M",
|
|
322
|
+
flush=True)
|
|
323
|
+
|
|
324
|
+
losses, times, peaks = [], [], []
|
|
325
|
+
ce0 = ce1 = None
|
|
326
|
+
wall = 0.0
|
|
327
|
+
t_all = time.time()
|
|
328
|
+
|
|
329
|
+
if args.sft:
|
|
330
|
+
from datasets import load_dataset
|
|
331
|
+
from transformers import AutoTokenizer
|
|
332
|
+
|
|
333
|
+
tok = AutoTokenizer.from_pretrained(model_dir)
|
|
334
|
+
if tok.pad_token is None:
|
|
335
|
+
tok.pad_token = tok.eos_token
|
|
336
|
+
ds = load_dataset(args.dataset, split="train").shuffle(seed=1234)
|
|
337
|
+
train_rows = [ds[i] for i in range(min(args.train_size, len(ds)))]
|
|
338
|
+
eval_rows = [ds[len(train_rows) + i]
|
|
339
|
+
for i in range(min(args.eval_size, len(ds) - len(train_rows)))]
|
|
340
|
+
print(f"[sft] {Path(args.dataset).name} train={len(train_rows)} "
|
|
341
|
+
f"eval={len(eval_rows)}", flush=True)
|
|
342
|
+
ce0 = _masked_eval(tr, eval_rows, tok, args.seq)
|
|
343
|
+
print(f"[eval] before loss={ce0:.4f}", flush=True)
|
|
344
|
+
for i in range(args.steps):
|
|
345
|
+
enc = _encode_chat(tok, train_rows[i % len(train_rows)], args.seq)
|
|
346
|
+
if enc is None:
|
|
347
|
+
continue
|
|
348
|
+
ids, labels = enc
|
|
349
|
+
ys = torch.full_like(ids, -100)
|
|
350
|
+
ys[:, :-1] = labels[:, 1:]
|
|
351
|
+
loss, dt, peak = tr.step(ids, ys, ignore_index=-100)
|
|
352
|
+
losses.append(round(loss, 6))
|
|
353
|
+
times.append(round(dt, 3))
|
|
354
|
+
peaks.append(round(peak, 3))
|
|
355
|
+
if adapt is not None:
|
|
356
|
+
adapt(peak, i)
|
|
357
|
+
if (i + 1) % 10 == 0:
|
|
358
|
+
print(f"[step] {i+1}/{args.steps} loss={loss:.4f} {dt:.2f}s "
|
|
359
|
+
f"peak={peak:.2f}GB rss={host_rss_gb()}GB", flush=True)
|
|
360
|
+
ce1 = _masked_eval(tr, eval_rows, tok, args.seq)
|
|
361
|
+
print(f"[eval] after loss={ce1:.4f} (delta {ce1-ce0:+.4f})", flush=True)
|
|
362
|
+
wall = time.time() - t_all
|
|
363
|
+
else:
|
|
364
|
+
tokens = load_tokens(str(ROOT / args.data))
|
|
365
|
+
sampler = TrainSampler(tokens, args.seq, generator=torch.Generator().manual_seed(1234))
|
|
366
|
+
batches = [sampler.batch(1) for _ in range(args.steps)]
|
|
367
|
+
|
|
368
|
+
if args.eval_chunks > 0:
|
|
369
|
+
ev = chunks_from_tokens(load_tokens(str(ROOT / args.eval_data)), args.eval_seq,
|
|
370
|
+
args.eval_chunks)
|
|
371
|
+
ce0 = sum(stream_eval_ce(tr, ev[i].unsqueeze(0), args.chunk)
|
|
372
|
+
for i in range(len(ev))) / len(ev)
|
|
373
|
+
print(f"[eval] before CE={ce0:.4f} on {args.eval_chunks} chunks of "
|
|
374
|
+
f"{args.eval_seq}", flush=True)
|
|
375
|
+
|
|
376
|
+
for i, (xs, ys) in enumerate(batches):
|
|
377
|
+
loss, dt, peak = tr.step(xs, ys)
|
|
378
|
+
losses.append(round(loss, 6))
|
|
379
|
+
times.append(round(dt, 3))
|
|
380
|
+
peaks.append(round(peak, 3))
|
|
381
|
+
if adapt is not None:
|
|
382
|
+
adapt(peak, i)
|
|
383
|
+
print(f"[step] {i+1}/{len(batches)} loss={loss:.4f} {dt:.2f}s peak={peak:.2f}GB "
|
|
384
|
+
f"rss={host_rss_gb()}GB", flush=True)
|
|
385
|
+
|
|
386
|
+
if args.eval_chunks > 0:
|
|
387
|
+
ce1 = sum(stream_eval_ce(tr, ev[i].unsqueeze(0), args.chunk)
|
|
388
|
+
for i in range(len(ev))) / len(ev)
|
|
389
|
+
print(f"[eval] after CE={ce1:.4f} (delta {ce1-ce0:+.4f})", flush=True)
|
|
390
|
+
|
|
391
|
+
wall = time.time() - t_all
|
|
392
|
+
|
|
393
|
+
saved = None
|
|
394
|
+
if args.save:
|
|
395
|
+
save_path = ROOT / args.save
|
|
396
|
+
save_path.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
torch.save(lora_state_dict(model), str(save_path / "adapter.pt"))
|
|
398
|
+
(save_path / "adapter_config.json").write_text(json.dumps({
|
|
399
|
+
"r": args.lora_r, "alpha": args.lora_alpha, "targets": args.targets,
|
|
400
|
+
"base": Path(model_dir).name,
|
|
401
|
+
}, indent=2), encoding="utf-8")
|
|
402
|
+
saved = args.save
|
|
403
|
+
print(f"[save] {save_path}", flush=True)
|
|
404
|
+
|
|
405
|
+
row = {
|
|
406
|
+
"exp": "exp54_disk_qlora",
|
|
407
|
+
"tag": args.tag,
|
|
408
|
+
"mode": "sft" if args.sft else "lm",
|
|
409
|
+
"dataset": args.dataset if args.sft else None,
|
|
410
|
+
"model": Path(model_dir).name,
|
|
411
|
+
"weights": "mmap from disk (no RAM-resident base)",
|
|
412
|
+
"base_quant": "nf4",
|
|
413
|
+
"lora_r": args.lora_r,
|
|
414
|
+
"lora_alpha": args.lora_alpha,
|
|
415
|
+
"targets": args.targets,
|
|
416
|
+
"head": args.head,
|
|
417
|
+
"shared_dtype": args.shared_dtype,
|
|
418
|
+
"blocks_mode": args.blocks_on_gpu,
|
|
419
|
+
"blocks_on_gpu": cache.capacity,
|
|
420
|
+
"blocks_on_gpu_initial": start_blocks,
|
|
421
|
+
"blocks_history": adapt_hist,
|
|
422
|
+
"trainable_params": tr.n_lora,
|
|
423
|
+
"grad_layers": len(tr.grad_layers),
|
|
424
|
+
"n_layers": len(tr.layers),
|
|
425
|
+
"adapter_sha_before": sha_before,
|
|
426
|
+
"adapter_sha_after": adapter_sha(model),
|
|
427
|
+
"steps": len(losses),
|
|
428
|
+
"seq": args.seq,
|
|
429
|
+
"lr": args.lr,
|
|
430
|
+
"losses": losses,
|
|
431
|
+
"step_s_med": sorted(times)[len(times) // 2] if times else None,
|
|
432
|
+
"wall_s": round(wall, 1),
|
|
433
|
+
"peak_gb_max": max(peaks) if peaks else None,
|
|
434
|
+
"host_rss_gb": host_rss_gb(),
|
|
435
|
+
"host_private_gb": host_private_gb(),
|
|
436
|
+
"ce_before": round(ce0, 5) if ce0 is not None else None,
|
|
437
|
+
"ce_after": round(ce1, 5) if ce1 is not None else None,
|
|
438
|
+
"ce_delta": round(ce1 - ce0, 6) if (ce0 is not None and ce1 is not None) else None,
|
|
439
|
+
"saved": saved,
|
|
440
|
+
"ts": now(),
|
|
441
|
+
}
|
|
442
|
+
print(json.dumps(row, ensure_ascii=True), flush=True)
|
|
443
|
+
append_jsonl(str(ROOT / "results" / "results.jsonl"), row)
|
|
444
|
+
with open(ROOT / "results" / "exp54_disk_qlora.log", "a", encoding="utf-8") as f:
|
|
445
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
raise SystemExit(main())
|
streamft/data.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Data preparation: wikitext-2 (train/dev), wikitext-103 test, TinyStories validation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _tok(tokenizer, text):
|
|
9
|
+
ids = tokenizer(text)["input_ids"]
|
|
10
|
+
return torch.tensor(ids, dtype=torch.int32)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_wikitext(config="wikitext-2-raw-v1", split="train"):
|
|
14
|
+
from datasets import load_dataset
|
|
15
|
+
ds = load_dataset("Salesforce/wikitext", config, split=split)
|
|
16
|
+
return "\n".join(ds["text"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_tinystories(n_stories=6000):
|
|
20
|
+
from datasets import load_dataset
|
|
21
|
+
ds = load_dataset("roneneldan/TinyStories", split="validation")
|
|
22
|
+
n = min(n_stories, len(ds))
|
|
23
|
+
return "\n\n".join(ds[i]["text"] for i in range(n))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_all(tokenizer, data_dir, force=False):
|
|
27
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
28
|
+
jobs = {
|
|
29
|
+
"wt2_train": lambda: get_wikitext("wikitext-2-raw-v1", "train"),
|
|
30
|
+
"wt2_test": lambda: get_wikitext("wikitext-2-raw-v1", "test"),
|
|
31
|
+
"wt103_test": lambda: get_wikitext("wikitext-103-raw-v1", "test"),
|
|
32
|
+
"tinystories_valid": lambda: get_tinystories(6000),
|
|
33
|
+
}
|
|
34
|
+
out = {}
|
|
35
|
+
for name, fn in jobs.items():
|
|
36
|
+
path = os.path.join(data_dir, f"{name}.pt")
|
|
37
|
+
if os.path.exists(path) and not force:
|
|
38
|
+
out[name] = path
|
|
39
|
+
continue
|
|
40
|
+
text = fn()
|
|
41
|
+
ids = _tok(tokenizer, text)
|
|
42
|
+
torch.save(ids, path)
|
|
43
|
+
out[name] = path
|
|
44
|
+
print(f"[data] {name}: {len(ids)} tokens -> {path}", flush=True)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_tokens(path):
|
|
49
|
+
return torch.load(path, weights_only=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chunks_from_tokens(tokens, seq_len, max_chunks=None):
|
|
53
|
+
"""Non-overlapping chunks of seq_len tokens -> (n, seq_len) int64."""
|
|
54
|
+
n = len(tokens) // seq_len
|
|
55
|
+
if max_chunks is not None:
|
|
56
|
+
n = min(n, max_chunks)
|
|
57
|
+
return tokens[: n * seq_len].view(n, seq_len).long()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def samples_from_tokens(tokens, seq_len, max_samples=None, seed=0):
|
|
61
|
+
"""Random non-overlapping windows for training."""
|
|
62
|
+
n = len(tokens) // seq_len
|
|
63
|
+
if max_samples is not None:
|
|
64
|
+
n = min(n, max_samples)
|
|
65
|
+
return tokens[: n * seq_len].view(n, seq_len).long()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TrainSampler:
|
|
69
|
+
"""Iterate random contiguous windows over a token stream (random offsets)."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, tokens, seq_len, generator=None):
|
|
72
|
+
self.tokens = tokens
|
|
73
|
+
self.seq_len = seq_len
|
|
74
|
+
self.g = generator or torch.Generator().manual_seed(0)
|
|
75
|
+
|
|
76
|
+
def batch(self, batch_size):
|
|
77
|
+
max_start = len(self.tokens) - self.seq_len - 1
|
|
78
|
+
starts = torch.randint(0, max_start, (batch_size,), generator=self.g).tolist()
|
|
79
|
+
xs = torch.stack([self.tokens[s: s + self.seq_len].long() for s in starts])
|
|
80
|
+
ys = torch.stack([self.tokens[s + 1: s + self.seq_len + 1].long() for s in starts])
|
|
81
|
+
return xs, ys
|