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.
Files changed (24) hide show
  1. {streamft-0.2.2 → streamft-0.2.4}/PKG-INFO +20 -20
  2. {streamft-0.2.2 → streamft-0.2.4}/README.md +19 -19
  3. {streamft-0.2.2 → streamft-0.2.4}/pyproject.toml +1 -1
  4. {streamft-0.2.2 → streamft-0.2.4}/streamft/cli.py +43 -12
  5. {streamft-0.2.2 → streamft-0.2.4}/streamft/fit.py +41 -13
  6. {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/PKG-INFO +20 -20
  7. {streamft-0.2.2 → streamft-0.2.4}/tests/test_streamft_smoke.py +32 -0
  8. {streamft-0.2.2 → streamft-0.2.4}/LICENSE +0 -0
  9. {streamft-0.2.2 → streamft-0.2.4}/setup.cfg +0 -0
  10. {streamft-0.2.2 → streamft-0.2.4}/streamft/__init__.py +0 -0
  11. {streamft-0.2.2 → streamft-0.2.4}/streamft/__main__.py +0 -0
  12. {streamft-0.2.2 → streamft-0.2.4}/streamft/autotune.py +0 -0
  13. {streamft-0.2.2 → streamft-0.2.4}/streamft/data.py +0 -0
  14. {streamft-0.2.2 → streamft-0.2.4}/streamft/disk_quant_store.py +0 -0
  15. {streamft-0.2.2 → streamft-0.2.4}/streamft/doctor.py +0 -0
  16. {streamft-0.2.2 → streamft-0.2.4}/streamft/lora.py +0 -0
  17. {streamft-0.2.2 → streamft-0.2.4}/streamft/metrics.py +0 -0
  18. {streamft-0.2.2 → streamft-0.2.4}/streamft/stream_quant_lora.py +0 -0
  19. {streamft-0.2.2 → streamft-0.2.4}/streamft/stream_train.py +0 -0
  20. {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/SOURCES.txt +0 -0
  21. {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/dependency_links.txt +0 -0
  22. {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/entry_points.txt +0 -0
  23. {streamft-0.2.2 → streamft-0.2.4}/streamft.egg-info/requires.txt +0 -0
  24. {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.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
- ```powershell
66
+ ```bash
67
67
  pip install streamft
68
+ streamft doctor # torch / CUDA / bitsandbytes / free VRAM
68
69
 
69
- # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
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
- # 1) token shards for the LM path (SFT on Dolly needs none of this)
74
- streamft prepare --model models/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
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
- # 1) turn a local bf16 model into a pre-quantized NF4 checkpoint (once)
78
- python -m streamft.cli --prepare --model models/Qwen2.5-32B-Instruct `
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
- # 2) fine-tune it, streaming from disk
82
- python tools/finetune.py --model checkpoints/qwen32b_nf4 --mode lm --seq 1024 --steps 50
83
- python tools/finetune.py --model checkpoints/qwen14b_nf4 --data dolly --seq 512 --steps 240 `
84
- --save checkpoints/my_adapter
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
- # show the exact engine command without running it
87
- python tools/finetune.py ... --print-only
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
- # CPU smoke tests (5 cases, no GPU, no downloads)
90
- pip install -r requirements-dev.txt; python -m pytest tests -q
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
- ```powershell
36
+ ```bash
37
37
  pip install streamft
38
+ streamft doctor # torch / CUDA / bitsandbytes / free VRAM
38
39
 
39
- # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
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
- # 1) token shards for the LM path (SFT on Dolly needs none of this)
44
- streamft prepare --model models/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
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
- # 1) turn a local bf16 model into a pre-quantized NF4 checkpoint (once)
48
- python -m streamft.cli --prepare --model models/Qwen2.5-32B-Instruct `
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
- # 2) fine-tune it, streaming from disk
52
- python tools/finetune.py --model checkpoints/qwen32b_nf4 --mode lm --seq 1024 --steps 50
53
- python tools/finetune.py --model checkpoints/qwen14b_nf4 --data dolly --seq 512 --steps 240 `
54
- --save checkpoints/my_adapter
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
- # show the exact engine command without running it
57
- python tools/finetune.py ... --print-only
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
- # CPU smoke tests (5 cases, no GPU, no downloads)
60
- pip install -r requirements-dev.txt; python -m pytest tests -q
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.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 = str(user_path(model))
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
- labels = list(full)
135
- if 0 < len(prompt) < len(full) and full[:len(prompt)] == prompt[:len(full)]:
136
- k = min(len(prompt), len(full))
137
- labels = [-100] * k + labels[k:]
138
- if len(labels) < 2:
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
- return total / max(n, 1)
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 = str(user_path(args.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, train_rows[i % len(train_rows)], args.seq)
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
- (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
- }
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
- m = model_math(load_config(args.model))
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 (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)
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 on RTX 3050 6 GB: {measured['blocks']} block(s), "
119
- f"{measured['peak_gb']} GB peak, {measured['step_s']} s/step at seq {measured['ref_seq']}")
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.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
- ```powershell
66
+ ```bash
67
67
  pip install streamft
68
+ streamft doctor # torch / CUDA / bitsandbytes / free VRAM
68
69
 
69
- # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
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
- # 1) token shards for the LM path (SFT on Dolly needs none of this)
74
- streamft prepare --model models/Qwen2.5-0.5B --save-quant checkpoints/qwen0.5b_nf4
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
- # 1) turn a local bf16 model into a pre-quantized NF4 checkpoint (once)
78
- python -m streamft.cli --prepare --model models/Qwen2.5-32B-Instruct `
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
- # 2) fine-tune it, streaming from disk
82
- python tools/finetune.py --model checkpoints/qwen32b_nf4 --mode lm --seq 1024 --steps 50
83
- python tools/finetune.py --model checkpoints/qwen14b_nf4 --data dolly --seq 512 --steps 240 `
84
- --save checkpoints/my_adapter
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
- # show the exact engine command without running it
87
- python tools/finetune.py ... --print-only
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
- # CPU smoke tests (5 cases, no GPU, no downloads)
90
- pip install -r requirements-dev.txt; python -m pytest tests -q
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