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 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())