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