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.
Files changed (24) hide show
  1. {streamft-0.2.2 → streamft-0.2.3}/PKG-INFO +1 -1
  2. {streamft-0.2.2 → streamft-0.2.3}/pyproject.toml +1 -1
  3. {streamft-0.2.2 → streamft-0.2.3}/streamft/cli.py +29 -8
  4. {streamft-0.2.2 → streamft-0.2.3}/streamft/fit.py +25 -12
  5. {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/PKG-INFO +1 -1
  6. {streamft-0.2.2 → streamft-0.2.3}/tests/test_streamft_smoke.py +32 -0
  7. {streamft-0.2.2 → streamft-0.2.3}/LICENSE +0 -0
  8. {streamft-0.2.2 → streamft-0.2.3}/README.md +0 -0
  9. {streamft-0.2.2 → streamft-0.2.3}/setup.cfg +0 -0
  10. {streamft-0.2.2 → streamft-0.2.3}/streamft/__init__.py +0 -0
  11. {streamft-0.2.2 → streamft-0.2.3}/streamft/__main__.py +0 -0
  12. {streamft-0.2.2 → streamft-0.2.3}/streamft/autotune.py +0 -0
  13. {streamft-0.2.2 → streamft-0.2.3}/streamft/data.py +0 -0
  14. {streamft-0.2.2 → streamft-0.2.3}/streamft/disk_quant_store.py +0 -0
  15. {streamft-0.2.2 → streamft-0.2.3}/streamft/doctor.py +0 -0
  16. {streamft-0.2.2 → streamft-0.2.3}/streamft/lora.py +0 -0
  17. {streamft-0.2.2 → streamft-0.2.3}/streamft/metrics.py +0 -0
  18. {streamft-0.2.2 → streamft-0.2.3}/streamft/stream_quant_lora.py +0 -0
  19. {streamft-0.2.2 → streamft-0.2.3}/streamft/stream_train.py +0 -0
  20. {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/SOURCES.txt +0 -0
  21. {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/dependency_links.txt +0 -0
  22. {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/entry_points.txt +0 -0
  23. {streamft-0.2.2 → streamft-0.2.3}/streamft.egg-info/requires.txt +0 -0
  24. {streamft-0.2.2 → streamft-0.2.3}/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.3
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
@@ -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.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 = 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,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 = str(user_path(args.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
- (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": (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 (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)
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 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']}")
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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamft
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -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