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.
@@ -0,0 +1,353 @@
1
+ """Disk-backed (mmap) NF4 weight store: stream weights from SSD into VRAM.
2
+
3
+ Idea
4
+ ----
5
+ The RAM-resident streaming engine (`stream_quant_lora`) keeps the whole 4-bit
6
+ base in host RAM. That is what caps the model size at what fits in RAM
7
+ (a 14B NF4 base is ~8 GB; a 32B one would need ~18 GB). This module removes
8
+ that cap: the pre-quantized weights stay in the bnb-4bit safetensors shards on
9
+ disk and are memory-mapped. `safetensors.safe_open(...).get_tensor()` returns a
10
+ *zero-copy* CPU view into the mmap, so
11
+
12
+ * host RAM is used only as an OS-managed page cache (evictable, LRU), i.e.
13
+ "use as much RAM as is free, keep the rest on SSD" happens for free, and
14
+ * the model-size limit becomes disk space + step time instead of host RAM.
15
+
16
+ A decoder block is materialized directly on the GPU (uint8 data copied H2D,
17
+ `Params4bit.from_prequantized` re-attaches the saved NF4 quant state) and moved
18
+ back to `meta` afterwards, so only one block lives in VRAM at a time.
19
+
20
+ Nothing here mutates the checkpoint: the base stays frozen 4-bit; only the
21
+ LoRA adapters (fp32, CPU) are trained.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+
28
+ import torch
29
+
30
+ # Components of a bnb NF4 quant state as written by transformers >= 4.4x:
31
+ # one packed JSON blob (non-tensor items) + four tensors.
32
+ QUANT_SUFFIXES = (
33
+ "weight.quant_state.bitsandbytes__nf4",
34
+ "weight.absmax",
35
+ "weight.quant_map",
36
+ "weight.nested_absmax",
37
+ "weight.nested_quant_map",
38
+ )
39
+
40
+
41
+ class DiskQuantStore:
42
+ """Key -> mmap'd tensor lookups over a (possibly sharded) bnb-4bit checkpoint."""
43
+
44
+ def __init__(self, model_dir, device: str = "cuda"):
45
+ self.dir = Path(model_dir)
46
+ index = json.loads((self.dir / "model.safetensors.index.json").read_text())
47
+ self.weight_map: dict[str, str] = dict(index["weight_map"])
48
+ self.device = device
49
+ self._handles: dict[str, object] = {}
50
+
51
+ def _handle(self, filename: str):
52
+ h = self._handles.get(filename)
53
+ if h is None:
54
+ from safetensors import safe_open
55
+
56
+ h = safe_open(str(self.dir / filename), framework="pt", device="cpu")
57
+ self._handles[filename] = h
58
+ return h
59
+
60
+ def has(self, key: str) -> bool:
61
+ return key in self.weight_map
62
+
63
+ def get(self, key: str) -> torch.Tensor:
64
+ """Zero-copy CPU view backed by the memory-mapped shard."""
65
+ return self._handle(self.weight_map[key]).get_tensor(key)
66
+
67
+
68
+ def build_skeleton(model_dir, lora_r: int = 8, lora_alpha: int = 16,
69
+ targets: str = "attn", head: str = "bf16",
70
+ low_precision_shared: bool = False, with_lora: bool = True):
71
+ """Instantiate the model on the `meta` device with bnb Linear4bit + LoRA.
72
+
73
+ No weight memory is allocated here; only the (tiny) LoRA adapters are real.
74
+ Mirrors peft's `prepare_model_for_kbit_training`: every parameter is frozen
75
+ and all non-4bit weights (norms, biases, embedding/head) are cast to fp32 --
76
+ except the embedding/head when `low_precision_shared=True`, which keeps them
77
+ in the checkpoint dtype (needed for very large models).
78
+ Returns (model, config).
79
+ """
80
+ from transformers import AutoConfig, AutoModelForCausalLM, BitsAndBytesConfig
81
+ from transformers.integrations.bitsandbytes import replace_with_bnb_linear
82
+
83
+ from .lora import LoRALinear, apply_lora
84
+
85
+ cfg = AutoConfig.from_pretrained(str(model_dir))
86
+ cfg.use_cache = False
87
+
88
+ with torch.device("meta"):
89
+ model = AutoModelForCausalLM.from_config(cfg)
90
+
91
+ qcfg = BitsAndBytesConfig(
92
+ load_in_4bit=True,
93
+ bnb_4bit_quant_type="nf4",
94
+ bnb_4bit_compute_dtype=torch.bfloat16,
95
+ bnb_4bit_use_double_quant=True,
96
+ bnb_4bit_quant_storage=torch.uint8,
97
+ )
98
+ replace_with_bnb_linear(
99
+ model,
100
+ # `lm_head` stays a plain Linear (bf16) unless we explicitly quantize it;
101
+ # tied models must keep it plain because it *is* the embedding tensor.
102
+ modules_to_not_convert=(
103
+ None if (head == "nf4" and not cfg.tie_word_embeddings) else ["lm_head"]
104
+ ),
105
+ quantization_config=qcfg,
106
+ pre_quantized=True,
107
+ )
108
+
109
+ r_attn, r_mlp = (lora_r, 0) if targets == "attn" else (lora_r, lora_r)
110
+ if not with_lora:
111
+ r_attn = r_mlp = 0
112
+
113
+ # 1) freeze everything, like prepare_model_for_kbit_training
114
+ for p in model.parameters():
115
+ p.requires_grad = False
116
+
117
+ # 2) cast all non-4bit params to fp32 (the RAM path does this, so the forward
118
+ # matches bit-for-bit). `low_precision_shared` keeps *everything* in the
119
+ # checkpoint dtype: mixing an fp32 norm with a bf16 head breaks F.linear.
120
+ if not low_precision_shared:
121
+ for name, p in list(model.named_parameters()):
122
+ if p.__class__.__name__ == "Params4bit":
123
+ continue
124
+ if p.dtype not in (torch.float16, torch.bfloat16):
125
+ continue
126
+ mod_name, _, attr = name.rpartition(".")
127
+ target = model.get_submodule(mod_name) if mod_name else model
128
+ target._parameters[attr] = torch.nn.Parameter(
129
+ target._parameters[attr].data.to(torch.float32), requires_grad=False)
130
+
131
+ if with_lora:
132
+ apply_lora(model, r_attn=r_attn, r_mlp=r_mlp, alpha_attn=lora_alpha,
133
+ alpha_mlp=lora_alpha)
134
+ # LoRA adapters were created on `meta` (that is where the base lives); give
135
+ # them real CPU storage so the optimizer can train them.
136
+ import math
137
+
138
+ for mod in model.modules():
139
+ if isinstance(mod, LoRALinear):
140
+ out_f, in_f = mod.base.weight.shape
141
+ mod.A = torch.nn.Parameter(
142
+ torch.randn(mod.r, in_f, dtype=torch.float32) / math.sqrt(in_f))
143
+ mod.B = torch.nn.Parameter(torch.zeros(out_f, mod.r, dtype=torch.float32))
144
+
145
+ # Tie the LM head to the input embedding when the config asks for it (the
146
+ # checkpoint then has no separate `lm_head.weight`).
147
+ if getattr(cfg, "tie_word_embeddings", False):
148
+ from .stream_quant_lora import base_model_of
149
+
150
+ lm = base_model_of(model)
151
+ lm.lm_head._parameters["weight"] = lm.model.embed_tokens._parameters["weight"]
152
+ return model, cfg
153
+
154
+
155
+ def _rope_theta(cfg) -> float:
156
+ """Theta location moved between transformers versions; look in all of them."""
157
+ rp = getattr(cfg, "rope_parameters", None)
158
+ if isinstance(rp, dict) and "rope_theta" in rp:
159
+ return float(rp["rope_theta"])
160
+ rs = getattr(cfg, "rope_scaling", None)
161
+ if isinstance(rs, dict) and "rope_theta" in rs:
162
+ return float(rs["rope_theta"])
163
+ return float(getattr(cfg, "rope_theta", 1_000_000.0))
164
+
165
+
166
+ def fix_rotary(inner, cfg):
167
+ """`rotary_emb` buffers are computed at init, so they are `meta` here.
168
+
169
+ Rebuild the module for real (the constructor knows how to read rope config);
170
+ fall back to filling `inv_freq` by hand.
171
+ """
172
+ rot = inner.rotary_emb
173
+ try:
174
+ fresh = type(rot)(cfg)
175
+ if not fresh.inv_freq.is_meta:
176
+ inner.rotary_emb = fresh
177
+ return
178
+ except Exception:
179
+ pass
180
+
181
+ dim = cfg.hidden_size // cfg.num_attention_heads
182
+ theta = _rope_theta(cfg)
183
+ inv = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
184
+ rot.inv_freq = inv
185
+ rot.original_inv_freq = inv.clone()
186
+ if getattr(rot, "attention_scaling", None) is None:
187
+ rot.attention_scaling = 1.0
188
+
189
+
190
+ def _set_param(module, name: str, tensor, requires_grad: bool = False) -> None:
191
+ """Replace a parameter's storage.
192
+
193
+ Works even when the current parameter lives on `meta`: PyTorch forbids
194
+ `meta_param.data = cuda_tensor` ("incompatible tensor type"), so we swap the
195
+ `Parameter` object instead. Base weights are never in an optimizer, so losing
196
+ object identity is fine.
197
+ """
198
+ module._parameters[name] = torch.nn.Parameter(tensor, requires_grad=requires_grad)
199
+
200
+
201
+ def _set_param_cast(module, name: str, tensor) -> None:
202
+ """`_set_param` that keeps the destination dtype (e.g. fp32 norms)."""
203
+ old = module._parameters.get(name)
204
+ if old is not None and old.dtype != tensor.dtype:
205
+ tensor = tensor.to(old.dtype)
206
+ _set_param(module, name, tensor)
207
+
208
+
209
+ def materialize_shared(model, store: DiskQuantStore, cfg, device: str = "cuda"):
210
+ """Load embed, final norm and lm_head once (they are not streamed)."""
211
+ from .stream_quant_lora import base_model_of
212
+
213
+ lm = base_model_of(model)
214
+ inner = lm.model
215
+ emb = inner.embed_tokens
216
+ tied = lm.lm_head.weight is emb.weight
217
+
218
+ weight = store.get("model.embed_tokens.weight")
219
+ if tied:
220
+ # the same tensor feeds the LM head, so it must sit on the GPU
221
+ _set_param_cast(emb, "weight", weight.to(device))
222
+ else:
223
+ # zero-copy mmap view on CPU: gathering rows only touches the pages for
224
+ # the tokens actually used
225
+ _set_param_cast(emb, "weight", weight)
226
+
227
+ if store.has("model.norm.weight"):
228
+ _set_param_cast(inner.norm, "weight", store.get("model.norm.weight").to(device))
229
+ if tied:
230
+ lm.lm_head._parameters["weight"] = emb._parameters["weight"]
231
+ elif store.has("lm_head.weight"):
232
+ _set_param_cast(lm.lm_head, "weight", store.get("lm_head.weight").to(device))
233
+
234
+ fix_rotary(inner, cfg)
235
+ inner.rotary_emb.to(device)
236
+ return lm
237
+
238
+
239
+ def _layer_prefix(i: int) -> str:
240
+ return f"model.layers.{i}"
241
+
242
+
243
+ def _key_of(prefix: str, module_name: str) -> str:
244
+ """Map a module path inside a layer to its checkpoint prefix."""
245
+ name = module_name
246
+ if name.endswith(".base"):
247
+ name = name[: -len(".base")]
248
+ return f"{prefix}.{name}" if name else prefix
249
+
250
+
251
+ def load_layer(layer, i: int, store: DiskQuantStore, device: str = "cuda") -> None:
252
+ """Materialize decoder block `i` on `device` straight from the mmap'd store."""
253
+ import bitsandbytes as bnb
254
+
255
+ prefix = _layer_prefix(i)
256
+ for name, child in layer.named_modules():
257
+ sub = _key_of(prefix, name)
258
+ if isinstance(child, bnb.nn.Linear4bit):
259
+ wkey = f"{sub}.weight"
260
+ if store.has(wkey):
261
+ qs = {s: store.get(f"{sub}.{s}") for s in QUANT_SUFFIXES
262
+ if store.has(f"{sub}.{s}")}
263
+ child.weight = bnb.nn.Params4bit.from_prequantized(
264
+ data=store.get(wkey), quantized_stats=qs,
265
+ requires_grad=False, device=device, module=child,
266
+ )
267
+ child._is_hf_initialized = True
268
+ if child.bias is not None and store.has(f"{sub}.bias"):
269
+ _set_param_cast(child, "bias", store.get(f"{sub}.bias").to(device))
270
+ else:
271
+ for pname, _p in list(child.named_parameters(recurse=False)):
272
+ key = f"{sub}.{pname}"
273
+ if store.has(key):
274
+ _set_param_cast(child, pname, store.get(key).to(device))
275
+ for bname, b in list(child.named_buffers(recurse=False)):
276
+ key = f"{sub}.{bname}"
277
+ if store.has(key):
278
+ t = store.get(key).to(device)
279
+ if b is not None and b.dtype != t.dtype:
280
+ t = t.to(b.dtype)
281
+ child._buffers[bname] = t
282
+
283
+ _move_lora(layer, device)
284
+
285
+
286
+ def release_layer(layer, i: int, store: DiskQuantStore) -> None:
287
+ """Drop block `i`'s weights (back to `meta`) so VRAM/RAM are freed."""
288
+ for child in layer.modules():
289
+ for pname, p in list(child.named_parameters(recurse=False)):
290
+ if p.requires_grad: # LoRA adapters are kept (moved to CPU)
291
+ continue
292
+ child._parameters[pname] = torch.nn.Parameter(
293
+ torch.empty(tuple(p.shape), dtype=p.dtype, device="meta"),
294
+ requires_grad=False,
295
+ )
296
+ for bname, b in list(child.named_buffers(recurse=False)):
297
+ if b is not None:
298
+ child._buffers[bname] = torch.empty(
299
+ tuple(b.shape), dtype=b.dtype, device="meta")
300
+ _move_lora(layer, "cpu")
301
+
302
+
303
+ def _move_lora(layer, device) -> None:
304
+ """LoRA adapters deliberately stay on CPU.
305
+
306
+ Gradients are accumulated in CPU buffers and the optimizer steps on CPU, so
307
+ moving the adapters with the block would put param and grad on different
308
+ devices. `LoRALinear.forward` hops them to the activation's device instead.
309
+ """
310
+ return
311
+
312
+
313
+ class LayerCache:
314
+ """Keep up to `capacity` decoder blocks resident on the GPU (LRU eviction).
315
+
316
+ capacity=1 reproduces the plain "one block at a time" streaming. A larger
317
+ capacity is what makes a bigger GPU fast: blocks stay resident between the
318
+ forward and the backward pass, and if capacity >= the number of layers the
319
+ weights are loaded once and never streamed again.
320
+ """
321
+
322
+ def __init__(self, store: "DiskQuantStore", layers, device: str = "cuda",
323
+ capacity: int = 1):
324
+ self.store = store
325
+ self.layers = layers
326
+ self.device = device
327
+ self.capacity = max(1, int(capacity))
328
+ self.order: list[int] = []
329
+
330
+ def stage(self, layer, i: int) -> None:
331
+ if i in self.order: # already resident: refresh its LRU position
332
+ self.order.remove(i)
333
+ self.order.append(i)
334
+ return
335
+ while len(self.order) >= self.capacity:
336
+ j = self.order.pop(0)
337
+ release_layer(self.layers[j], j, self.store)
338
+ load_layer(layer, i, self.store, self.device)
339
+ self.order.append(i)
340
+
341
+ def release(self, layer, i: int) -> None:
342
+ # blocks stay resident until the LRU evicts them
343
+ return
344
+
345
+ def set_capacity(self, capacity: int) -> None:
346
+ """Change the window at runtime (used by the measured autotuner)."""
347
+ self.capacity = max(1, int(capacity))
348
+ while len(self.order) > self.capacity:
349
+ j = self.order.pop(0)
350
+ release_layer(self.layers[j], j, self.store)
351
+
352
+ def stats(self) -> dict:
353
+ return {"resident": len(self.order), "capacity": self.capacity}
streamft/doctor.py ADDED
@@ -0,0 +1,34 @@
1
+ """`streamft doctor` — what this machine can actually do."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import platform
6
+ import sys
7
+
8
+
9
+ def main(argv=None) -> int:
10
+ print("python", sys.version.split()[0], "|", platform.platform())
11
+ for pkg in ("torch", "transformers", "bitsandbytes", "safetensors", "datasets", "peft"):
12
+ try:
13
+ m = importlib.import_module(pkg)
14
+ print(f" {pkg:14s} {getattr(m, '__version__', '?')}")
15
+ except Exception:
16
+ print(f" {pkg:14s} MISSING")
17
+ try:
18
+ import torch
19
+
20
+ print("cuda:", torch.cuda.is_available(), "|", torch.version.cuda)
21
+ if torch.cuda.is_available():
22
+ p = torch.cuda.get_device_properties(0)
23
+ free, total = torch.cuda.mem_get_info(0)
24
+ print(f"gpu: {p.name} | {total/1e9:.1f} GB total, {free/1e9:.1f} GB free")
25
+ print(f"capability: {torch.cuda.get_device_capability(0)} | "
26
+ f"sdpa: {hasattr(torch.nn.functional, 'scaled_dot_product_attention')}")
27
+ except Exception as e:
28
+ print("torch check failed:", e)
29
+ print("\ntip: streamft fit --model <dir|hf-id> --gpu <GB> --seq <tokens>")
30
+ return 0
31
+
32
+
33
+ if __name__ == "__main__":
34
+ raise SystemExit(main())
streamft/fit.py ADDED
@@ -0,0 +1,128 @@
1
+ """`streamft fit` — will this model fit on my card, and what do we actually know?
2
+
3
+ Two sources of truth, kept separate on purpose:
4
+
5
+ * **memory** is arithmetic from the config: NF4 weight bytes + the shared tensors that
6
+ stay in fp32 (embeddings / LM head) + a fixed reserve. It answers "whole model, or
7
+ streaming?" and nothing more.
8
+ * **speed and block count** are printed only when this exact size+seq was measured on
9
+ the reference machine (RTX 3050 Laptop 6 GB) and is listed in MEASURED. For anything
10
+ else the tool refuses to guess and tells you to start at one block and let the
11
+ trainer's autotune measure the marginal.
12
+
13
+ Usage:
14
+ streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
15
+ streamft fit --model checkpoints/qwen14b_nf4 --gpu 6 --json
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ from pathlib import Path
22
+
23
+ # Measured end-to-end on an RTX 3050 Laptop, 6 GB, Windows 11, AC power, NF4 base,
24
+ # LoRA r=8 on q/k/v/o, batch 1, Dolly-15k. step_s = median warm step.
25
+ MEASURED = {
26
+ (3, 512): {"blocks": 36, "peak_gb": 3.85, "step_s": 0.938, "mode": "all resident"},
27
+ (7, 512): {"blocks": 28, "peak_gb": 5.51, "step_s": 1.696, "mode": "all resident"},
28
+ (14, 512): {"blocks": 6, "peak_gb": 3.86, "step_s": 6.726, "mode": "streaming"},
29
+ (32, 1024): {"blocks": 2, "peak_gb": 3.90, "step_s": 12.541, "mode": "streaming"},
30
+ }
31
+
32
+ RESERVE_GB = 0.9 # CUDA context + allocator fragmentation + the bf16 recompute buffers
33
+
34
+
35
+ def load_config(model: str) -> dict:
36
+ p = Path(model)
37
+ if p.is_dir() and (p / "config.json").exists():
38
+ return json.loads((p / "config.json").read_text(encoding="utf-8"))
39
+ from huggingface_hub import hf_hub_download
40
+
41
+ return json.loads(Path(hf_hub_download(model, "config.json")).read_text(encoding="utf-8"))
42
+
43
+
44
+ def model_math(cfg: dict) -> dict:
45
+ h = cfg.get("hidden_size") or cfg.get("n_embd")
46
+ layers = cfg.get("num_hidden_layers") or cfg.get("n_layer")
47
+ inter = cfg.get("intermediate_size") or 4 * h
48
+ vocab = cfg.get("vocab_size", 0)
49
+ heads = cfg.get("num_attention_heads", 1)
50
+ kv = cfg.get("num_key_value_heads", heads)
51
+ if not (h and layers):
52
+ raise SystemExit("config.json has no hidden_size / num_hidden_layers")
53
+
54
+ attn = h * h + 2 * h * (h * kv // heads) + h * h
55
+ mlp = 3 * h * inter
56
+ params = float(layers * (attn + mlp) + vocab * h)
57
+
58
+ nf4_gb = params * 0.5 / 1e9 * 1.07 # 4-bit + bitsandbytes overhead
59
+ tied = bool(cfg.get("tie_word_embeddings", False))
60
+ # What actually sits on the GPU: the embedding is pinned to CPU by the engine
61
+ # (only the gathered activation moves over), the head stays in bf16, and the
62
+ # presets use --shared-dtype as-stored. A tied head has no separate tensor.
63
+ head_gb = 0.0 if tied else vocab * h * 2 / 1e9
64
+ return {"params": params, "layers": int(layers), "nf4_gb": nf4_gb,
65
+ "head_gb": head_gb, "tied": tied}
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ ap = argparse.ArgumentParser(prog="streamft fit")
70
+ ap.add_argument("--model", required=True, help="local dir or HF id")
71
+ ap.add_argument("--gpu", type=float, default=6.0, help="VRAM in GB (default 6)")
72
+ ap.add_argument("--seq", type=int, default=512)
73
+ ap.add_argument("--json", action="store_true")
74
+ args = ap.parse_args(argv)
75
+
76
+ m = model_math(load_config(args.model))
77
+ need = m["nf4_gb"] + m["head_gb"] + RESERVE_GB
78
+ fits_whole = need <= args.gpu
79
+ size_b = round(m["params"] / 1e9)
80
+
81
+ measured = None
82
+ for (b, seq), row in MEASURED.items():
83
+ if abs(size_b - b) <= max(1, round(b * 0.1)) and seq == args.seq:
84
+ measured = dict(row, ref_size_b=b, ref_seq=seq)
85
+ break
86
+
87
+ out = {
88
+ "model": args.model,
89
+ "params_b": round(m["params"] / 1e9, 2),
90
+ "layers": m["layers"],
91
+ "nf4_weights_gb": round(m["nf4_gb"], 1),
92
+ "embedding_on_cpu": True,
93
+ "lm_head_bf16_gb": round(m["head_gb"], 1),
94
+ "needed_whole_gb": round(need, 1),
95
+ "gpu_gb": args.gpu,
96
+ "seq": args.seq,
97
+ "fits_whole": fits_whole,
98
+ "needs_streaming": not fits_whole,
99
+ "measured_on_rtx3050_6gb": measured,
100
+ "measured_elsewhere": measured is None,
101
+ }
102
+ if args.json:
103
+ print(json.dumps(out, indent=1))
104
+ return 0
105
+
106
+ print(args.model)
107
+ print(f" {out['params_b']}B params, {m['layers']} layers")
108
+ print(f" NF4 blocks ~{out['nf4_weights_gb']} GB + bf16 head ~{out['lm_head_bf16_gb']} GB "
109
+ f"+ reserve {RESERVE_GB} GB (embedding stays on CPU)")
110
+ print(f" -> whole model needs ~{out['needed_whole_gb']} GB on {args.gpu:g} GB")
111
+ if fits_whole:
112
+ print(" verdict: fits whole at this seq (no streaming needed; >1k can still spill)")
113
+ else:
114
+ print(" verdict: needs streaming (one or more blocks on the GPU at a time)")
115
+ if measured:
116
+ print(f" measured on RTX 3050 6 GB: {measured['blocks']} block(s), "
117
+ f"{measured['peak_gb']} GB peak, {measured['step_s']} s/step at seq {measured['ref_seq']}")
118
+ else:
119
+ print(" speed/blocks: not measured for this size at this seq — start with")
120
+ print(" --blocks-on-gpu 1 and let autotune grow it (it measures")
121
+ print(" the real MB/block on your card instead of guessing)")
122
+ print(" next: streamft prepare --model <dir> --save-quant checkpoints/<name>")
123
+ print(" streamft train --model-dir checkpoints/<name> --sft --steps 30")
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
streamft/lora.py ADDED
@@ -0,0 +1,108 @@
1
+ """Minimal hand-rolled LoRA for the frozen base model (no peft dependency).
2
+
3
+ LoRALinear wraps an existing nn.Linear and adds a low-rank update
4
+ y = base(x) + scale * (x @ A^T @ B^T), scale = alpha / r
5
+ B starts at zero so the wrapped module is an identity at init.
6
+ A/B are kept in fp32 even when the base is fp16 (fp16 updates would vanish
7
+ below resolution); the low-rank path is cheap.
8
+
9
+ merge() folds the update into the base weight (in-place) so that inference
10
+ runs at zero extra cost. Call it once, after training.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+
20
+ class LoRALinear(nn.Module):
21
+ def __init__(self, base: nn.Linear, r: int = 16, alpha: int = 16, dropout: float = 0.0):
22
+ super().__init__()
23
+ self.base = base
24
+ for p in self.base.parameters():
25
+ p.requires_grad_(False)
26
+ self.r = r
27
+ self.scale = alpha / r
28
+ w = base.weight
29
+ self.A = nn.Parameter(torch.randn(r, w.shape[1], device=w.device,
30
+ dtype=torch.float32) / math.sqrt(w.shape[1]))
31
+ self.B = nn.Parameter(torch.zeros(w.shape[0], r, device=w.device, dtype=torch.float32))
32
+ self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
33
+
34
+ def forward(self, x):
35
+ y = self.base(x)
36
+ h = self.dropout(x).float()
37
+ # A/B live on CPU (grads are accumulated there and the optimizer steps
38
+ # there); hop them to the activation's device for the matmul. The low-rank
39
+ # path is tiny, so this costs a negligible amount of PCIe traffic.
40
+ A = self.A if self.A.device == h.device else self.A.to(h.device)
41
+ B = self.B if self.B.device == h.device else self.B.to(h.device)
42
+ return y + (self.scale * (h @ A.t() @ B.t())).to(y.dtype)
43
+
44
+ @torch.no_grad()
45
+ def merge(self):
46
+ self.base.weight.data += (self.scale * (self.B @ self.A)).to(self.base.weight.dtype)
47
+ self.A.data.zero_()
48
+ self.B.data.zero_()
49
+
50
+
51
+ def apply_lora(model, r_attn: int = 16, r_mlp: int = 16, alpha_attn: int = 16,
52
+ alpha_mlp: int = 16, layers=None):
53
+ """Replace q/k/v/o and mlp projections with LoRALinear in-place."""
54
+ base = model.model if hasattr(model, "model") else model
55
+ n = len(base.layers)
56
+ if layers is None:
57
+ layers = list(range(n))
58
+ for i in layers:
59
+ layer = base.layers[i]
60
+ attn = layer.self_attn.orig if hasattr(layer.self_attn, "orig") else layer.self_attn
61
+ for name, r, a in [("q_proj", r_attn, alpha_attn), ("k_proj", r_attn, alpha_attn),
62
+ ("v_proj", r_attn, alpha_attn), ("o_proj", r_attn, alpha_attn)]:
63
+ mod = getattr(attn, name)
64
+ if not isinstance(mod, LoRALinear):
65
+ setattr(attn, name, LoRALinear(mod, r=r, alpha=a))
66
+ if r_mlp > 0:
67
+ for name in ("gate_proj", "up_proj", "down_proj"):
68
+ mod = getattr(layer.mlp, name)
69
+ if not isinstance(mod, LoRALinear):
70
+ setattr(layer.mlp, name, LoRALinear(mod, r=r_mlp, alpha=alpha_mlp))
71
+
72
+
73
+ def lora_parameters(model):
74
+ for mod in model.modules():
75
+ if isinstance(mod, LoRALinear):
76
+ yield mod.A
77
+ yield mod.B
78
+
79
+
80
+ def merge_lora(model):
81
+ for mod in model.modules():
82
+ if isinstance(mod, LoRALinear):
83
+ mod.merge()
84
+
85
+
86
+ def count_lora_params(model):
87
+ return sum(p.numel() for p in lora_parameters(model))
88
+
89
+
90
+ def lora_state_dict(model):
91
+ """Adapter-only state dict (A/B tensors), CPU copies — for saving checkpoints."""
92
+ out = {}
93
+ for name, p in model.named_parameters():
94
+ if p.requires_grad and (name.endswith(".A") or name.endswith(".B")):
95
+ out[name] = p.detach().cpu().clone()
96
+ return out
97
+
98
+
99
+ def set_lora_state_dict(model, sd):
100
+ """Load an adapter saved with lora_state_dict (module tree must match)."""
101
+ own = {n for n, p in model.named_parameters() if p.requires_grad}
102
+ missing = [k for k in sd if k not in own]
103
+ if missing:
104
+ raise KeyError(f"adapter keys not found in model: {missing[:4]}")
105
+ with torch.no_grad():
106
+ for name, p in model.named_parameters():
107
+ if name in sd:
108
+ p.copy_(sd[name].to(device=p.device, dtype=p.dtype))