streamft 0.2.2__tar.gz → 0.2.3__tar.gz
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-0.2.2 → streamft-0.2.3}/PKG-INFO +1 -1
- {streamft-0.2.2 → streamft-0.2.3}/pyproject.toml +1 -1
- {streamft-0.2.2 → streamft-0.2.3}/streamft/cli.py +29 -8
- {streamft-0.2.2 → streamft-0.2.3}/streamft/fit.py +25 -12
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/PKG-INFO +1 -1
- {streamft-0.2.2 → streamft-0.2.3}/tests/test_streamft_smoke.py +32 -0
- {streamft-0.2.2 → streamft-0.2.3}/LICENSE +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/README.md +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/setup.cfg +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/__init__.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/__main__.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/autotune.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/data.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/disk_quant_store.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/doctor.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/lora.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/metrics.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/stream_quant_lora.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft/stream_train.py +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/SOURCES.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/dependency_links.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/entry_points.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/requires.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "streamft"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Fine-tune models that do not fit: stream NF4 weights from disk, one transformer block at a time."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -86,11 +86,23 @@ def user_path(value) -> Path:
|
|
|
86
86
|
return q if q.is_absolute() else Path.cwd() / q
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def resolve_model(value: str) -> str:
|
|
90
|
+
"""A local directory if one exists at that path, otherwise an HF model id.
|
|
91
|
+
|
|
92
|
+
`user_path` must not be used for models: `Qwen/Qwen2.5-0.5B` is an id, not
|
|
93
|
+
`./Qwen/Qwen2.5-0.5B`. Absolute paths and existing directories win; everything else
|
|
94
|
+
is passed through to transformers/huggingface_hub unchanged.
|
|
95
|
+
"""
|
|
96
|
+
p = Path(value).expanduser()
|
|
97
|
+
local = p if p.is_absolute() else Path.cwd() / p
|
|
98
|
+
return str(local) if local.exists() else value
|
|
99
|
+
|
|
100
|
+
|
|
89
101
|
def prepare(model: str, save_quant: str) -> int:
|
|
90
102
|
"""Quantize a local bf16 model into a bnb-4bit checkpoint (uint8 + qstate)."""
|
|
91
103
|
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
|
92
104
|
|
|
93
|
-
src =
|
|
105
|
+
src = resolve_model(model)
|
|
94
106
|
dst = user_path(save_quant)
|
|
95
107
|
print(f"[prepare] {model} -> {save_quant}", flush=True)
|
|
96
108
|
bnb = BitsAndBytesConfig(
|
|
@@ -131,11 +143,13 @@ def _encode_chat(tokenizer, row: dict, seq: int):
|
|
|
131
143
|
add_generation_prompt=True)
|
|
132
144
|
prompt = tokenizer(prompt_text, add_special_tokens=False).input_ids
|
|
133
145
|
full = full[:seq]
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
146
|
+
# The prompt must be an exact prefix of the templated row. If it is not, the mask
|
|
147
|
+
# would silently cover nothing and the model would be trained on its own prompt, so
|
|
148
|
+
# the example is dropped instead (and counted by the caller).
|
|
149
|
+
if len(prompt) >= len(full) or full[: len(prompt)] != prompt:
|
|
150
|
+
return None
|
|
151
|
+
labels = [-100] * len(prompt) + list(full[len(prompt):])
|
|
152
|
+
if len(labels) < 2 or all(v == -100 for v in labels):
|
|
139
153
|
return None
|
|
140
154
|
return (torch.tensor([full], dtype=torch.long),
|
|
141
155
|
torch.tensor([labels], dtype=torch.long))
|
|
@@ -145,10 +159,11 @@ def _encode_chat(tokenizer, row: dict, seq: int):
|
|
|
145
159
|
def _masked_eval(trainer, rows, tokenizer, seq: int) -> float:
|
|
146
160
|
"""Masked SFT loss through the streaming (disk-backed) forward."""
|
|
147
161
|
trainer.model.eval()
|
|
148
|
-
total, n = 0.0, 0
|
|
162
|
+
total, n, skipped = 0.0, 0, 0
|
|
149
163
|
for row in rows:
|
|
150
164
|
enc = _encode_chat(tokenizer, row, seq)
|
|
151
165
|
if enc is None:
|
|
166
|
+
skipped += 1
|
|
152
167
|
continue
|
|
153
168
|
ids, labels = enc
|
|
154
169
|
ys = torch.full_like(ids, -100)
|
|
@@ -166,6 +181,9 @@ def _masked_eval(trainer, rows, tokenizer, seq: int) -> float:
|
|
|
166
181
|
tgt[valid].to(logits.device), reduction="sum"))
|
|
167
182
|
n += v
|
|
168
183
|
trainer.model.train()
|
|
184
|
+
if skipped:
|
|
185
|
+
print(f"[eval] skipped {skipped}/{len(rows)} rows: prompt is not an exact prefix "
|
|
186
|
+
f"of the templated row (would train on the prompt)", flush=True)
|
|
169
187
|
return total / max(n, 1)
|
|
170
188
|
|
|
171
189
|
|
|
@@ -222,7 +240,7 @@ def main() -> int:
|
|
|
222
240
|
if args.prepare:
|
|
223
241
|
return prepare(args.model, args.save_quant)
|
|
224
242
|
|
|
225
|
-
model_dir =
|
|
243
|
+
model_dir = resolve_model(args.model_dir)
|
|
226
244
|
print(f"[exp54] model_dir={Path(model_dir).name} steps={args.steps} seq={args.seq} "
|
|
227
245
|
f"lora_r={args.lora_r} targets={args.targets} head={args.head}", flush=True)
|
|
228
246
|
|
|
@@ -351,9 +369,11 @@ def main() -> int:
|
|
|
351
369
|
f"eval={len(eval_rows)}", flush=True)
|
|
352
370
|
ce0 = _masked_eval(tr, eval_rows, tok, args.seq)
|
|
353
371
|
print(f"[eval] before loss={ce0:.4f}", flush=True)
|
|
372
|
+
skipped_train = 0
|
|
354
373
|
for i in range(args.steps):
|
|
355
374
|
enc = _encode_chat(tok, train_rows[i % len(train_rows)], args.seq)
|
|
356
375
|
if enc is None:
|
|
376
|
+
skipped_train += 1
|
|
357
377
|
continue
|
|
358
378
|
ids, labels = enc
|
|
359
379
|
ys = torch.full_like(ids, -100)
|
|
@@ -435,6 +455,7 @@ def main() -> int:
|
|
|
435
455
|
"adapter_sha_before": sha_before,
|
|
436
456
|
"adapter_sha_after": adapter_sha(model),
|
|
437
457
|
"steps": len(losses),
|
|
458
|
+
"sft_skipped": skipped_train if args.sft else 0,
|
|
438
459
|
"seq": args.seq,
|
|
439
460
|
"lr": args.lr,
|
|
440
461
|
"losses": losses,
|
|
@@ -22,12 +22,22 @@ from pathlib import Path
|
|
|
22
22
|
|
|
23
23
|
# Measured end-to-end on an RTX 3050 Laptop, 6 GB, Windows 11, AC power, NF4 base,
|
|
24
24
|
# LoRA r=8 on q/k/v/o, batch 1, Dolly-15k. step_s = median warm step.
|
|
25
|
-
MEASURED =
|
|
26
|
-
(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
25
|
+
MEASURED = [
|
|
26
|
+
# Matched on (num_hidden_layers, hidden_size), not on a rounded parameter count: a
|
|
27
|
+
# different 7B with a different architecture must not inherit this measurement.
|
|
28
|
+
{"arch": (36, 2048), "label": "Qwen2.5-3B", "seq": 512, "blocks": 36,
|
|
29
|
+
"peak_gb": 3.85, "step_s": 0.94, "mode": "all resident",
|
|
30
|
+
"tokens": "SFT: one Dolly row per step, median ~134 tokens"},
|
|
31
|
+
{"arch": (28, 3584), "label": "Qwen2.5-7B", "seq": 512, "blocks": 28,
|
|
32
|
+
"peak_gb": 5.51, "step_s": 1.55, "mode": "all resident",
|
|
33
|
+
"tokens": "SFT: one Dolly row per step, median ~134 tokens"},
|
|
34
|
+
{"arch": (48, 5120), "label": "Qwen2.5-14B", "seq": 512, "blocks": 6,
|
|
35
|
+
"peak_gb": 3.86, "step_s": 5.86, "mode": "streaming",
|
|
36
|
+
"tokens": "SFT: one Dolly row per step, median ~134 tokens"},
|
|
37
|
+
{"arch": (64, 5120), "label": "Qwen2.5-32B", "seq": 1024, "blocks": 2,
|
|
38
|
+
"peak_gb": 3.90, "step_s": 12.48, "mode": "streaming",
|
|
39
|
+
"tokens": "SFT: one Dolly row per step, median ~134 tokens"},
|
|
40
|
+
]
|
|
31
41
|
|
|
32
42
|
RESERVE_GB = 0.9 # CUDA context + allocator fragmentation + the bf16 recompute buffers
|
|
33
43
|
|
|
@@ -62,7 +72,7 @@ def model_math(cfg: dict) -> dict:
|
|
|
62
72
|
# presets use --shared-dtype as-stored. A tied head has no separate tensor.
|
|
63
73
|
head_gb = 0.0 if tied else vocab * h * 2 / 1e9
|
|
64
74
|
return {"params": params, "layers": int(layers), "nf4_gb": nf4_gb,
|
|
65
|
-
"head_gb": head_gb, "tied": tied}
|
|
75
|
+
"head_gb": head_gb, "tied": tied, "hidden": int(h)}
|
|
66
76
|
|
|
67
77
|
|
|
68
78
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -78,10 +88,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
78
88
|
fits_whole = need <= args.gpu
|
|
79
89
|
size_b = round(m["params"] / 1e9)
|
|
80
90
|
|
|
91
|
+
arch = (m["layers"], m["hidden"])
|
|
81
92
|
measured = None
|
|
82
|
-
for
|
|
83
|
-
if
|
|
84
|
-
measured = dict(row
|
|
93
|
+
for row in MEASURED:
|
|
94
|
+
if row["arch"] == arch and row["seq"] == args.seq:
|
|
95
|
+
measured = dict(row)
|
|
85
96
|
break
|
|
86
97
|
|
|
87
98
|
out = {
|
|
@@ -115,8 +126,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
115
126
|
else:
|
|
116
127
|
print(" verdict: needs streaming (one or more blocks on the GPU at a time)")
|
|
117
128
|
if measured:
|
|
118
|
-
print(f" measured
|
|
119
|
-
f"
|
|
129
|
+
print(f" measured reference: {measured['label']} (layers {arch[0]}, hidden {arch[1]}) "
|
|
130
|
+
f"on RTX 3050 6 GB -> {measured['blocks']} block(s), "
|
|
131
|
+
f"{measured['peak_gb']} GB peak, {measured['step_s']} s/step at seq {measured['seq']}")
|
|
132
|
+
print(f" note: {measured['tokens']} (seq is a cap on this path)")
|
|
120
133
|
else:
|
|
121
134
|
print(" speed/blocks: not measured for this size at this seq — start with")
|
|
122
135
|
print(" --blocks-on-gpu 1 and let autotune grow it (it measures")
|
|
@@ -177,3 +177,35 @@ def test_disk_store_supports_single_safetensors_file(tmp_path):
|
|
|
177
177
|
store = DiskQuantStore(str(tmp_path))
|
|
178
178
|
assert store.has("a") and store.has("b.c")
|
|
179
179
|
assert store.get("b.c").tolist() == [1.0, 1.0, 1.0]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------ SFT masking failures are loud
|
|
183
|
+
|
|
184
|
+
class _StubTokenizer:
|
|
185
|
+
"""apply_chat_template returns FULL for both messages, PROMPT for the prefix call."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, full, prompt):
|
|
188
|
+
self.full, self.prompt = full, prompt
|
|
189
|
+
|
|
190
|
+
def apply_chat_template(self, messages, tokenize=False, add_generation_prompt=False):
|
|
191
|
+
return "FULL" if len(messages) == 2 else "PROMPT"
|
|
192
|
+
|
|
193
|
+
def __call__(self, text, add_special_tokens=False):
|
|
194
|
+
from types import SimpleNamespace
|
|
195
|
+
|
|
196
|
+
ids = self.full if text == "FULL" else self.prompt
|
|
197
|
+
return SimpleNamespace(input_ids=list(ids))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_encode_chat_drops_rows_whose_prompt_is_not_a_prefix():
|
|
201
|
+
from streamft.cli import _encode_chat
|
|
202
|
+
|
|
203
|
+
row = {"instruction": "q", "response": "a"}
|
|
204
|
+
# prompt is not a prefix of the templated row -> the example must be dropped
|
|
205
|
+
assert _encode_chat(_StubTokenizer([1, 2, 3, 4], [9, 9]), row, 32) is None
|
|
206
|
+
# prompt longer than the row -> dropped
|
|
207
|
+
assert _encode_chat(_StubTokenizer([1, 2], [1, 2, 3]), row, 32) is None
|
|
208
|
+
# valid prefix -> every prompt token masked, the rest kept
|
|
209
|
+
ids, labels = _encode_chat(_StubTokenizer([1, 2, 3, 4], [1, 2]), row, 32)
|
|
210
|
+
assert ids.tolist() == [[1, 2, 3, 4]]
|
|
211
|
+
assert labels.tolist() == [[-100, -100, 3, 4]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|