streamft 0.2.2__tar.gz → 0.2.4__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.4}/PKG-INFO +20 -20
- {streamft-0.2.2 → streamft-0.2.4}/README.md +19 -19
- {streamft-0.2.2 → streamft-0.2.4}/pyproject.toml +1 -1
- {streamft-0.2.2 → streamft-0.2.4}/streamft/cli.py +43 -12
- {streamft-0.2.2 → streamft-0.2.4}/streamft/fit.py +41 -13
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/PKG-INFO +20 -20
- {streamft-0.2.2 → streamft-0.2.4}/tests/test_streamft_smoke.py +32 -0
- {streamft-0.2.2 → streamft-0.2.4}/LICENSE +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/setup.cfg +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/__init__.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/__main__.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/autotune.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/data.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/disk_quant_store.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/doctor.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/lora.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/metrics.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/stream_quant_lora.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft/stream_train.py +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/SOURCES.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/dependency_links.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/entry_points.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/requires.txt +0 -0
- {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamft
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Fine-tune models that do not fit: stream NF4 weights from disk, one transformer block at a time.
|
|
5
5
|
Author-email: Gokhan Cava <byjameson@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -61,34 +61,34 @@ the six traps, and which numbers are real. The measurement protocol, the discard
|
|
|
61
61
|
and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
|
|
62
62
|
[`LONGCTX_FINDINGS.md`](LONGCTX_FINDINGS.md).
|
|
63
63
|
|
|
64
|
-
## Quick start
|
|
64
|
+
## Quick start (installed package, nothing else needed)
|
|
65
65
|
|
|
66
|
-
```
|
|
66
|
+
```bash
|
|
67
67
|
pip install streamft
|
|
68
|
+
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
68
69
|
|
|
69
|
-
#
|
|
70
|
+
# will it fit? memory arithmetic from config.json + the measured reference
|
|
70
71
|
streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
|
|
71
|
-
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
72
72
|
|
|
73
|
-
#
|
|
74
|
-
streamft prepare --model
|
|
75
|
-
python scripts/prep_data.py --model models/Qwen2.5-0.5B
|
|
73
|
+
# one NF4 checkpoint per model (HF id or local directory)
|
|
74
|
+
streamft prepare --model Qwen/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
|
|
76
75
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
--save-quant checkpoints/qwen32b_nf4
|
|
76
|
+
# SFT on Dolly-15k — no data prep
|
|
77
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --sft --steps 20 --seq 512 --tag first
|
|
80
78
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
# LM path (optional): build token shards, then train without --sft
|
|
80
|
+
streamft data --model Qwen/Qwen2.5-0.5B --out data
|
|
81
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --steps 20 --seq 256 --tag lm
|
|
82
|
+
```
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
Every run appends one JSON row to `results/results.jsonl` next to where you launched it.
|
|
85
|
+
The same commands are in [`QUICKSTART.md`](QUICKSTART.md), which is what a pip user should
|
|
86
|
+
read — this README also documents the experiments behind the numbers.
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
**From a source checkout only** (not part of the wheel): `tools/finetune.py` is a wrapper
|
|
89
|
+
that encodes the VRAM presets, `scripts/compare_peft.py` reproduces the engine comparison,
|
|
90
|
+
`scripts/verify_gradients.py` the gradient check, `scripts/prep_data.py` the token shards
|
|
91
|
+
(the installed equivalent is `streamft data`), and `pytest tests -q` the CPU suite.
|
|
92
92
|
|
|
93
93
|
`tools/finetune.py` encodes the VRAM presets we measured, because the *combination* of
|
|
94
94
|
flags decides whether a run fits — not any single flag:
|
|
@@ -31,34 +31,34 @@ the six traps, and which numbers are real. The measurement protocol, the discard
|
|
|
31
31
|
and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
|
|
32
32
|
[`LONGCTX_FINDINGS.md`](LONGCTX_FINDINGS.md).
|
|
33
33
|
|
|
34
|
-
## Quick start
|
|
34
|
+
## Quick start (installed package, nothing else needed)
|
|
35
35
|
|
|
36
|
-
```
|
|
36
|
+
```bash
|
|
37
37
|
pip install streamft
|
|
38
|
+
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
38
39
|
|
|
39
|
-
#
|
|
40
|
+
# will it fit? memory arithmetic from config.json + the measured reference
|
|
40
41
|
streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
|
|
41
|
-
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
42
42
|
|
|
43
|
-
#
|
|
44
|
-
streamft prepare --model
|
|
45
|
-
python scripts/prep_data.py --model models/Qwen2.5-0.5B
|
|
43
|
+
# one NF4 checkpoint per model (HF id or local directory)
|
|
44
|
+
streamft prepare --model Qwen/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
|
|
46
45
|
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
--save-quant checkpoints/qwen32b_nf4
|
|
46
|
+
# SFT on Dolly-15k — no data prep
|
|
47
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --sft --steps 20 --seq 512 --tag first
|
|
50
48
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
# LM path (optional): build token shards, then train without --sft
|
|
50
|
+
streamft data --model Qwen/Qwen2.5-0.5B --out data
|
|
51
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --steps 20 --seq 256 --tag lm
|
|
52
|
+
```
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
Every run appends one JSON row to `results/results.jsonl` next to where you launched it.
|
|
55
|
+
The same commands are in [`QUICKSTART.md`](QUICKSTART.md), which is what a pip user should
|
|
56
|
+
read — this README also documents the experiments behind the numbers.
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
**From a source checkout only** (not part of the wheel): `tools/finetune.py` is a wrapper
|
|
59
|
+
that encodes the VRAM presets, `scripts/compare_peft.py` reproduces the engine comparison,
|
|
60
|
+
`scripts/verify_gradients.py` the gradient check, `scripts/prep_data.py` the token shards
|
|
61
|
+
(the installed equivalent is `streamft data`), and `pytest tests -q` the CPU suite.
|
|
62
62
|
|
|
63
63
|
`tools/finetune.py` encodes the VRAM presets we measured, because the *combination* of
|
|
64
64
|
flags decides whether a run fits — not any single flag:
|
|
@@ -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.4"
|
|
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,7 +181,15 @@ 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()
|
|
169
|
-
|
|
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)
|
|
187
|
+
if n == 0:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
"_masked_eval: every row was skipped (no valid prompt mask), so there is no "
|
|
190
|
+
"loss to report. Returning 0.0 here would look like a perfect model."
|
|
191
|
+
)
|
|
192
|
+
return total / n
|
|
170
193
|
|
|
171
194
|
|
|
172
195
|
def main() -> int:
|
|
@@ -222,7 +245,7 @@ def main() -> int:
|
|
|
222
245
|
if args.prepare:
|
|
223
246
|
return prepare(args.model, args.save_quant)
|
|
224
247
|
|
|
225
|
-
model_dir =
|
|
248
|
+
model_dir = resolve_model(args.model_dir)
|
|
226
249
|
print(f"[exp54] model_dir={Path(model_dir).name} steps={args.steps} seq={args.seq} "
|
|
227
250
|
f"lora_r={args.lora_r} targets={args.targets} head={args.head}", flush=True)
|
|
228
251
|
|
|
@@ -351,10 +374,17 @@ def main() -> int:
|
|
|
351
374
|
f"eval={len(eval_rows)}", flush=True)
|
|
352
375
|
ce0 = _masked_eval(tr, eval_rows, tok, args.seq)
|
|
353
376
|
print(f"[eval] before loss={ce0:.4f}", flush=True)
|
|
377
|
+
# Filter once so `--steps N` means N optimizer updates, not "N attempts minus
|
|
378
|
+
# whatever the mask rejected". A silently short run is a lie about the budget.
|
|
379
|
+
valid_rows = [r for r in train_rows if _encode_chat(tok, r, args.seq) is not None]
|
|
380
|
+
skipped_train = len(train_rows) - len(valid_rows)
|
|
381
|
+
if not valid_rows:
|
|
382
|
+
raise SystemExit("[sft] no training row has a valid prompt mask; refusing to run")
|
|
383
|
+
if skipped_train:
|
|
384
|
+
print(f"[sft] {skipped_train}/{len(train_rows)} rows dropped: the prompt is not "
|
|
385
|
+
f"an exact prefix of the templated row", flush=True)
|
|
354
386
|
for i in range(args.steps):
|
|
355
|
-
enc = _encode_chat(tok,
|
|
356
|
-
if enc is None:
|
|
357
|
-
continue
|
|
387
|
+
enc = _encode_chat(tok, valid_rows[i % len(valid_rows)], args.seq)
|
|
358
388
|
ids, labels = enc
|
|
359
389
|
ys = torch.full_like(ids, -100)
|
|
360
390
|
ys[:, :-1] = labels[:, 1:]
|
|
@@ -435,6 +465,7 @@ def main() -> int:
|
|
|
435
465
|
"adapter_sha_before": sha_before,
|
|
436
466
|
"adapter_sha_after": adapter_sha(model),
|
|
437
467
|
"steps": len(losses),
|
|
468
|
+
"sft_skipped": skipped_train if args.sft else 0,
|
|
438
469
|
"seq": args.seq,
|
|
439
470
|
"lr": args.lr,
|
|
440
471
|
"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": ("qwen2", 36, 2048, 11008, 16, 2, 151936), "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": ("qwen2", 28, 3584, 18944, 28, 4, 152064), "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": ("qwen2", 48, 5120, 13824, 40, 8, 152064), "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": ("qwen2", 64, 5120, 27648, 40, 8, 152064), "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
|
|
|
@@ -41,6 +51,19 @@ def load_config(model: str) -> dict:
|
|
|
41
51
|
return json.loads(Path(hf_hub_download(model, "config.json")).read_text(encoding="utf-8"))
|
|
42
52
|
|
|
43
53
|
|
|
54
|
+
def model_fingerprint(cfg: dict) -> tuple:
|
|
55
|
+
"""Everything that changes the measurement: two 7B models are not interchangeable."""
|
|
56
|
+
return (
|
|
57
|
+
str(cfg.get("model_type", "")),
|
|
58
|
+
int(cfg.get("num_hidden_layers") or cfg.get("n_layer") or 0),
|
|
59
|
+
int(cfg.get("hidden_size") or cfg.get("n_embd") or 0),
|
|
60
|
+
int(cfg.get("intermediate_size") or 0),
|
|
61
|
+
int(cfg.get("num_attention_heads") or 0),
|
|
62
|
+
int(cfg.get("num_key_value_heads") or cfg.get("num_attention_heads") or 0),
|
|
63
|
+
int(cfg.get("vocab_size") or 0),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
44
67
|
def model_math(cfg: dict) -> dict:
|
|
45
68
|
h = cfg.get("hidden_size") or cfg.get("n_embd")
|
|
46
69
|
layers = cfg.get("num_hidden_layers") or cfg.get("n_layer")
|
|
@@ -62,7 +85,7 @@ def model_math(cfg: dict) -> dict:
|
|
|
62
85
|
# presets use --shared-dtype as-stored. A tied head has no separate tensor.
|
|
63
86
|
head_gb = 0.0 if tied else vocab * h * 2 / 1e9
|
|
64
87
|
return {"params": params, "layers": int(layers), "nf4_gb": nf4_gb,
|
|
65
|
-
"head_gb": head_gb, "tied": tied}
|
|
88
|
+
"head_gb": head_gb, "tied": tied, "hidden": int(h)}
|
|
66
89
|
|
|
67
90
|
|
|
68
91
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -73,15 +96,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
73
96
|
ap.add_argument("--json", action="store_true")
|
|
74
97
|
args = ap.parse_args(argv)
|
|
75
98
|
|
|
76
|
-
|
|
99
|
+
cfg = load_config(args.model)
|
|
100
|
+
m = model_math(cfg)
|
|
77
101
|
need = m["nf4_gb"] + m["head_gb"] + RESERVE_GB
|
|
78
102
|
fits_whole = need <= args.gpu
|
|
79
103
|
size_b = round(m["params"] / 1e9)
|
|
80
104
|
|
|
105
|
+
arch = model_fingerprint(cfg)
|
|
81
106
|
measured = None
|
|
82
|
-
for
|
|
83
|
-
if
|
|
84
|
-
measured = dict(row
|
|
107
|
+
for row in MEASURED:
|
|
108
|
+
if row["arch"] == arch and row["seq"] == args.seq:
|
|
109
|
+
measured = dict(row)
|
|
85
110
|
break
|
|
86
111
|
|
|
87
112
|
out = {
|
|
@@ -115,8 +140,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
115
140
|
else:
|
|
116
141
|
print(" verdict: needs streaming (one or more blocks on the GPU at a time)")
|
|
117
142
|
if measured:
|
|
118
|
-
print(f" measured
|
|
119
|
-
f"{
|
|
143
|
+
print(f" measured reference: {measured['label']} "
|
|
144
|
+
f"(model_type {arch[0]}, layers {arch[1]}, hidden {arch[2]}) "
|
|
145
|
+
f"on RTX 3050 6 GB -> {measured['blocks']} block(s), "
|
|
146
|
+
f"{measured['peak_gb']} GB peak, {measured['step_s']} s/step at seq {measured['seq']}")
|
|
147
|
+
print(f" note: {measured['tokens']} (seq is a cap on this path)")
|
|
120
148
|
else:
|
|
121
149
|
print(" speed/blocks: not measured for this size at this seq — start with")
|
|
122
150
|
print(" --blocks-on-gpu 1 and let autotune grow it (it measures")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamft
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Fine-tune models that do not fit: stream NF4 weights from disk, one transformer block at a time.
|
|
5
5
|
Author-email: Gokhan Cava <byjameson@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -61,34 +61,34 @@ the six traps, and which numbers are real. The measurement protocol, the discard
|
|
|
61
61
|
and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
|
|
62
62
|
[`LONGCTX_FINDINGS.md`](LONGCTX_FINDINGS.md).
|
|
63
63
|
|
|
64
|
-
## Quick start
|
|
64
|
+
## Quick start (installed package, nothing else needed)
|
|
65
65
|
|
|
66
|
-
```
|
|
66
|
+
```bash
|
|
67
67
|
pip install streamft
|
|
68
|
+
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
68
69
|
|
|
69
|
-
#
|
|
70
|
+
# will it fit? memory arithmetic from config.json + the measured reference
|
|
70
71
|
streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
|
|
71
|
-
streamft doctor # torch / CUDA / bitsandbytes / free VRAM
|
|
72
72
|
|
|
73
|
-
#
|
|
74
|
-
streamft prepare --model
|
|
75
|
-
python scripts/prep_data.py --model models/Qwen2.5-0.5B
|
|
73
|
+
# one NF4 checkpoint per model (HF id or local directory)
|
|
74
|
+
streamft prepare --model Qwen/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
|
|
76
75
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
--save-quant checkpoints/qwen32b_nf4
|
|
76
|
+
# SFT on Dolly-15k — no data prep
|
|
77
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --sft --steps 20 --seq 512 --tag first
|
|
80
78
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
# LM path (optional): build token shards, then train without --sft
|
|
80
|
+
streamft data --model Qwen/Qwen2.5-0.5B --out data
|
|
81
|
+
streamft train --model-dir checkpoints/qwen0.5b_nf4 --steps 20 --seq 256 --tag lm
|
|
82
|
+
```
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
Every run appends one JSON row to `results/results.jsonl` next to where you launched it.
|
|
85
|
+
The same commands are in [`QUICKSTART.md`](QUICKSTART.md), which is what a pip user should
|
|
86
|
+
read — this README also documents the experiments behind the numbers.
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
**From a source checkout only** (not part of the wheel): `tools/finetune.py` is a wrapper
|
|
89
|
+
that encodes the VRAM presets, `scripts/compare_peft.py` reproduces the engine comparison,
|
|
90
|
+
`scripts/verify_gradients.py` the gradient check, `scripts/prep_data.py` the token shards
|
|
91
|
+
(the installed equivalent is `streamft data`), and `pytest tests -q` the CPU suite.
|
|
92
92
|
|
|
93
93
|
`tools/finetune.py` encodes the VRAM presets we measured, because the *combination* of
|
|
94
94
|
flags decides whether a run fits — not any single flag:
|
|
@@ -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
|