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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""streamft — fine-tune models that do not fit, one block at a time.
|
|
2
|
+
|
|
3
|
+
The engine memory-maps a pre-quantized NF4 checkpoint, materialises one transformer
|
|
4
|
+
block on the GPU at a time (with recompute in the backward) and keeps a LoRA adapter
|
|
5
|
+
trainable. Measured on a 6 GB RTX 3050 Laptop: a 32B model at 1024 tokens/step,
|
|
6
|
+
36.7 tok/s, 4.61 GB peak.
|
|
7
|
+
|
|
8
|
+
Modules:
|
|
9
|
+
|
|
10
|
+
cli command line entry point (``python -m streamft.cli --help``)
|
|
11
|
+
disk_quant_store mmap'd NF4 checkpoint, block loader, layer cache
|
|
12
|
+
stream_quant_lora streaming trainer (base frozen, LoRA trainable)
|
|
13
|
+
stream_train chunked loss/backward, per-layer forward hooks, streaming eval
|
|
14
|
+
autotune chooses the resident-block window from VRAM/RAM
|
|
15
|
+
data token shards and the train/eval samplers
|
|
16
|
+
metrics append-only JSONL evidence log
|
|
17
|
+
lora minimal LoRA layers and adapter state dicts
|
|
18
|
+
|
|
19
|
+
The training tag written to the evidence log stays ``exp54_disk_qlora`` so that old
|
|
20
|
+
and new rows are directly comparable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"autotune",
|
|
25
|
+
"cli",
|
|
26
|
+
"data",
|
|
27
|
+
"disk_quant_store",
|
|
28
|
+
"lora",
|
|
29
|
+
"metrics",
|
|
30
|
+
"stream_quant_lora",
|
|
31
|
+
"stream_train",
|
|
32
|
+
]
|
streamft/__main__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Console entry point: `streamft <command>`.
|
|
2
|
+
|
|
3
|
+
streamft fit will this model fit? (memory arithmetic + measured speed when known)
|
|
4
|
+
streamft prepare turn a bf16 model into a pre-quantized NF4 checkpoint
|
|
5
|
+
streamft train the streaming trainer (all exp54 flags)
|
|
6
|
+
streamft doctor environment report (torch, CUDA, bitsandbytes, VRAM)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
USAGE = """streamft - fine-tune models that do not fit
|
|
13
|
+
|
|
14
|
+
streamft fit --model <dir|hf-id> [--gpu 6] [--seq 512]
|
|
15
|
+
streamft prepare --model <dir> --save-quant checkpoints/<name>
|
|
16
|
+
streamft train --model-dir checkpoints/<name> --sft --steps 30 [--seq 1024]
|
|
17
|
+
streamft doctor
|
|
18
|
+
|
|
19
|
+
Docs: https://github.com/byjameson/stream-finetune
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
argv = sys.argv[1:]
|
|
25
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
26
|
+
print(USAGE)
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
cmd, rest = argv[0], argv[1:]
|
|
30
|
+
|
|
31
|
+
if cmd == "fit":
|
|
32
|
+
from streamft.fit import main as fit_main
|
|
33
|
+
|
|
34
|
+
return fit_main(rest)
|
|
35
|
+
|
|
36
|
+
if cmd == "doctor":
|
|
37
|
+
from streamft.doctor import main as doctor_main
|
|
38
|
+
|
|
39
|
+
return doctor_main(rest)
|
|
40
|
+
|
|
41
|
+
if cmd == "prepare":
|
|
42
|
+
from streamft import cli
|
|
43
|
+
|
|
44
|
+
sys.argv = ["streamft", "--prepare"] + rest
|
|
45
|
+
return cli.main()
|
|
46
|
+
|
|
47
|
+
if cmd == "train":
|
|
48
|
+
from streamft import cli
|
|
49
|
+
|
|
50
|
+
sys.argv = ["streamft"] + rest
|
|
51
|
+
return cli.main()
|
|
52
|
+
|
|
53
|
+
print(f"unknown command: {cmd}\n")
|
|
54
|
+
print(USAGE)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
raise SystemExit(main())
|
streamft/autotune.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""VRAM/RAM-aware planning so the engine adapts to whatever GPU the user has.
|
|
2
|
+
|
|
3
|
+
Everything here is derived from the checkpoint metadata alone -- no weights are
|
|
4
|
+
read -- so it costs milliseconds:
|
|
5
|
+
|
|
6
|
+
* per-decoder-block size *in the quantized format that is actually stored*,
|
|
7
|
+
* the non-block ("shared") tensors: embedding, final norm, LM head,
|
|
8
|
+
* the dequantization workspace a 4-bit LM head needs at the loss step.
|
|
9
|
+
|
|
10
|
+
From that it decides:
|
|
11
|
+
* how many decoder blocks can stay resident on the GPU at once
|
|
12
|
+
(`blocks_on_gpu`), which is what makes big cards fast,
|
|
13
|
+
* whether the bf16 LM head fits on the GPU or must stay CPU/4-bit,
|
|
14
|
+
* whether the whole 4-bit base fits in host RAM (RAM-resident path) or has to
|
|
15
|
+
be streamed from disk.
|
|
16
|
+
|
|
17
|
+
The estimates are deliberately conservative: on Windows/WDDM exceeding the VRAM
|
|
18
|
+
limit spills silently into shared memory, which is far worse than being a little
|
|
19
|
+
slower.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
GB = 1e9
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Plan:
|
|
34
|
+
strategy: str # "gpu-full" | "ram-stream" | "disk-stream"
|
|
35
|
+
gpu_name: str
|
|
36
|
+
vram_total_gb: float
|
|
37
|
+
vram_free_gb: float
|
|
38
|
+
vram_budget_gb: float
|
|
39
|
+
host_ram_total_gb: float | None
|
|
40
|
+
host_ram_available_gb: float | None
|
|
41
|
+
model_gb: float
|
|
42
|
+
block_gb: float
|
|
43
|
+
n_layers: int
|
|
44
|
+
blocks_on_gpu: int
|
|
45
|
+
head_gb: float
|
|
46
|
+
head_on_gpu: bool
|
|
47
|
+
embed_on_gpu: bool
|
|
48
|
+
loss_chunk: int
|
|
49
|
+
notes: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def summary(self) -> str:
|
|
52
|
+
lines = [
|
|
53
|
+
f"[auto] strategy={self.strategy} gpu={self.gpu_name}",
|
|
54
|
+
f"[auto] VRAM total={self.vram_total_gb:.1f}GB free={self.vram_free_gb:.1f}GB "
|
|
55
|
+
f"budget={self.vram_budget_gb:.1f}GB",
|
|
56
|
+
f"[auto] model={self.model_gb:.1f}GB block={self.block_gb * 1024:.0f}MB "
|
|
57
|
+
f"layers={self.n_layers}",
|
|
58
|
+
f"[auto] blocks_on_gpu={self.blocks_on_gpu} "
|
|
59
|
+
f"({self.blocks_on_gpu * self.block_gb:.2f}GB) head_on_gpu={self.head_on_gpu} "
|
|
60
|
+
f"({self.head_gb:.2f}GB) loss_chunk={self.loss_chunk}",
|
|
61
|
+
]
|
|
62
|
+
lines += [f"[auto] note: {n}" for n in self.notes]
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def probe_gpu() -> dict | None:
|
|
67
|
+
try:
|
|
68
|
+
import torch
|
|
69
|
+
if not torch.cuda.is_available():
|
|
70
|
+
return None
|
|
71
|
+
p = torch.cuda.get_device_properties(0)
|
|
72
|
+
free, total = torch.cuda.mem_get_info()
|
|
73
|
+
return {"name": p.name, "total_gb": total / GB, "free_gb": free / GB}
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def probe_ram() -> tuple[float | None, float | None]:
|
|
79
|
+
try:
|
|
80
|
+
import psutil
|
|
81
|
+
vm = psutil.virtual_memory()
|
|
82
|
+
return vm.total / GB, vm.available / GB
|
|
83
|
+
except Exception:
|
|
84
|
+
return None, None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def checkpoint_stats(model_dir) -> dict:
|
|
88
|
+
"""Sizes of the stored tensors, grouped into per-block and shared parts."""
|
|
89
|
+
from safetensors import safe_open
|
|
90
|
+
|
|
91
|
+
root = Path(model_dir)
|
|
92
|
+
index = json.loads((root / "model.safetensors.index.json").read_text())
|
|
93
|
+
weight_map: dict[str, str] = dict(index["weight_map"])
|
|
94
|
+
|
|
95
|
+
handles: dict[str, object] = {}
|
|
96
|
+
per_layer: dict[int, int] = {}
|
|
97
|
+
shared = 0
|
|
98
|
+
head_bytes = embed_bytes = 0
|
|
99
|
+
|
|
100
|
+
for key, fname in weight_map.items():
|
|
101
|
+
h = handles.get(fname)
|
|
102
|
+
if h is None:
|
|
103
|
+
h = safe_open(str(root / fname), framework="pt", device="cpu")
|
|
104
|
+
handles[fname] = h
|
|
105
|
+
t = h.get_tensor(key)
|
|
106
|
+
nbytes = t.numel() * t.element_size()
|
|
107
|
+
m = re.match(r"model\.layers\.(\d+)\.", key)
|
|
108
|
+
if m:
|
|
109
|
+
per_layer[int(m.group(1))] = per_layer.get(int(m.group(1)), 0) + nbytes
|
|
110
|
+
elif key == "lm_head.weight":
|
|
111
|
+
head_bytes = nbytes
|
|
112
|
+
elif key == "model.embed_tokens.weight":
|
|
113
|
+
embed_bytes = nbytes
|
|
114
|
+
else:
|
|
115
|
+
shared += nbytes
|
|
116
|
+
|
|
117
|
+
n_layers = max(per_layer) + 1 if per_layer else 0
|
|
118
|
+
block_bytes = (sum(per_layer.values()) / max(len(per_layer), 1))
|
|
119
|
+
total = sum(per_layer.values()) + shared + head_bytes + embed_bytes
|
|
120
|
+
return {
|
|
121
|
+
"n_layers": n_layers,
|
|
122
|
+
"block_bytes": block_bytes,
|
|
123
|
+
"layers_bytes": sum(per_layer.values()),
|
|
124
|
+
"head_bytes": head_bytes,
|
|
125
|
+
"embed_bytes": embed_bytes,
|
|
126
|
+
"other_bytes": shared,
|
|
127
|
+
"total_bytes": total,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def plan_for(model_dir, seq: int = 128, lora_r: int = 8, head: str = "bf16",
|
|
132
|
+
hidden: int | None = None, vocab: int | None = None,
|
|
133
|
+
safety: float = 0.78, reserve_gb: float = 0.6,
|
|
134
|
+
loss_chunk: int | None = None) -> Plan:
|
|
135
|
+
"""Pick a placement strategy for this checkpoint on this machine.
|
|
136
|
+
|
|
137
|
+
`reserve_gb` covers the CUDA caching allocator's overhead and fragmentation:
|
|
138
|
+
the *reserved* peak we measure is noticeably higher than the sum of the
|
|
139
|
+
tensors, and on WDDM an overrun is far more expensive than a few fewer
|
|
140
|
+
resident blocks.
|
|
141
|
+
"""
|
|
142
|
+
stats = checkpoint_stats(model_dir)
|
|
143
|
+
gpu = probe_gpu()
|
|
144
|
+
ram_total, ram_avail = probe_ram()
|
|
145
|
+
notes: list[str] = []
|
|
146
|
+
|
|
147
|
+
cfg_path = Path(model_dir) / "config.json"
|
|
148
|
+
cfg = json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
|
|
149
|
+
hidden = hidden or int(cfg.get("hidden_size", 4096))
|
|
150
|
+
vocab = vocab or int(cfg.get("vocab_size", 152064))
|
|
151
|
+
tied = bool(cfg.get("tie_word_embeddings", False))
|
|
152
|
+
|
|
153
|
+
if gpu is None:
|
|
154
|
+
return Plan("disk-stream", "cpu", 0.0, 0.0, 0.0, ram_total, ram_avail,
|
|
155
|
+
stats["total_bytes"] / GB, stats["block_bytes"] / GB,
|
|
156
|
+
stats["n_layers"], 0, stats["head_bytes"] / GB, False, False,
|
|
157
|
+
loss_chunk or 32, ["no CUDA device found"])
|
|
158
|
+
|
|
159
|
+
# usable VRAM: prefer the *free* number (other apps may hold memory)
|
|
160
|
+
vram = gpu["free_gb"] if gpu["free_gb"] > 1.0 else gpu["total_gb"]
|
|
161
|
+
budget = vram * safety
|
|
162
|
+
|
|
163
|
+
head_gb = stats["head_bytes"] / GB
|
|
164
|
+
if tied:
|
|
165
|
+
head_gb = 0.0
|
|
166
|
+
notes.append("LM head is tied to the embedding; no separate head tensor")
|
|
167
|
+
|
|
168
|
+
# a 4-bit LM head needs a bf16 dequantization workspace at the loss step
|
|
169
|
+
head_workspace = (hidden * vocab * 2 / GB) if (head == "nf4" and not tied) else 0.0
|
|
170
|
+
if head_workspace:
|
|
171
|
+
notes.append(f"4-bit head adds a {head_workspace:.2f}GB dequant workspace")
|
|
172
|
+
|
|
173
|
+
loss_chunk = loss_chunk or (64 if budget > 4.0 else 32)
|
|
174
|
+
logits_gb = loss_chunk * vocab * 4 / GB
|
|
175
|
+
lora_gb = 3 * lora_r * hidden * 8 * stats["n_layers"] * 4 / GB # A+B+grads, fp32
|
|
176
|
+
act_gb = max(0.25, seq * hidden * 2 * 24 / GB)
|
|
177
|
+
|
|
178
|
+
block_gb = stats["block_bytes"] / GB
|
|
179
|
+
# embed: tied -> must be on GPU; untied -> stay on CPU as an mmap view
|
|
180
|
+
embed_on_gpu = tied
|
|
181
|
+
fixed = head_gb + head_workspace + logits_gb + lora_gb + act_gb + reserve_gb
|
|
182
|
+
fixed += (stats["embed_bytes"] / GB) if embed_on_gpu else 0.0
|
|
183
|
+
|
|
184
|
+
room = budget - fixed
|
|
185
|
+
fit = int(max(0, room) // max(block_gb, 1e-9))
|
|
186
|
+
n_layers = stats["n_layers"]
|
|
187
|
+
|
|
188
|
+
if fixed + stats["layers_bytes"] / GB <= budget:
|
|
189
|
+
strategy, blocks = "gpu-full", n_layers
|
|
190
|
+
notes.append("the whole 4-bit base fits: no streaming needed")
|
|
191
|
+
else:
|
|
192
|
+
blocks = max(1, min(fit, n_layers))
|
|
193
|
+
total_gb = stats["total_bytes"] / GB
|
|
194
|
+
if ram_avail and total_gb <= ram_avail * 0.85:
|
|
195
|
+
strategy = "ram-stream"
|
|
196
|
+
notes.append(f"4-bit base ({total_gb:.1f}GB) fits in RAM "
|
|
197
|
+
f"({ram_avail:.1f}GB free): keep it RAM-resident")
|
|
198
|
+
else:
|
|
199
|
+
strategy = "disk-stream"
|
|
200
|
+
notes.append(f"4-bit base ({total_gb:.1f}GB) does not fit comfortably in "
|
|
201
|
+
f"RAM ({ram_avail:.1f}GB free): stream from disk")
|
|
202
|
+
if blocks == 1:
|
|
203
|
+
notes.append("only one block fits: maximum compatibility, slowest")
|
|
204
|
+
|
|
205
|
+
head_on_gpu = bool(head_gb and head_gb + block_gb <= budget * 0.7)
|
|
206
|
+
if head_gb and not head_on_gpu:
|
|
207
|
+
notes.append(f"LM head ({head_gb:.2f}GB) is large vs budget; consider --head nf4")
|
|
208
|
+
|
|
209
|
+
return Plan(strategy, gpu["name"], gpu["total_gb"], gpu["free_gb"], budget,
|
|
210
|
+
ram_total, ram_avail, stats["total_bytes"] / GB, block_gb, n_layers,
|
|
211
|
+
blocks, head_gb, head_on_gpu, embed_on_gpu, loss_chunk, notes)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> int:
|
|
215
|
+
ap = argparse.ArgumentParser(description="Plan a QLoRA placement for this machine")
|
|
216
|
+
ap.add_argument("--model-dir", required=True)
|
|
217
|
+
ap.add_argument("--seq", type=int, default=128)
|
|
218
|
+
ap.add_argument("--lora_r", type=int, default=8)
|
|
219
|
+
ap.add_argument("--head", choices=["bf16", "nf4"], default="bf16")
|
|
220
|
+
ap.add_argument("--safety", type=float, default=0.78)
|
|
221
|
+
ap.add_argument("--reserve_gb", type=float, default=0.6)
|
|
222
|
+
ap.add_argument("--json", action="store_true")
|
|
223
|
+
args = ap.parse_args()
|
|
224
|
+
|
|
225
|
+
plan = plan_for(args.model_dir, seq=args.seq, lora_r=args.lora_r,
|
|
226
|
+
head=args.head, safety=args.safety,
|
|
227
|
+
reserve_gb=args.reserve_gb)
|
|
228
|
+
if args.json:
|
|
229
|
+
print(json.dumps(asdict(plan), indent=2))
|
|
230
|
+
else:
|
|
231
|
+
print(plan.summary())
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
raise SystemExit(main())
|