streamft 0.2.0__tar.gz → 0.2.1__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.0/streamft.egg-info → streamft-0.2.1}/PKG-INFO +39 -17
  2. {streamft-0.2.0 → streamft-0.2.1}/README.md +38 -16
  3. {streamft-0.2.0 → streamft-0.2.1}/pyproject.toml +1 -1
  4. {streamft-0.2.0 → streamft-0.2.1}/streamft/stream_quant_lora.py +22 -10
  5. {streamft-0.2.0 → streamft-0.2.1/streamft.egg-info}/PKG-INFO +39 -17
  6. {streamft-0.2.0 → streamft-0.2.1}/tests/test_streamft_smoke.py +50 -0
  7. {streamft-0.2.0 → streamft-0.2.1}/LICENSE +0 -0
  8. {streamft-0.2.0 → streamft-0.2.1}/setup.cfg +0 -0
  9. {streamft-0.2.0 → streamft-0.2.1}/streamft/__init__.py +0 -0
  10. {streamft-0.2.0 → streamft-0.2.1}/streamft/__main__.py +0 -0
  11. {streamft-0.2.0 → streamft-0.2.1}/streamft/autotune.py +0 -0
  12. {streamft-0.2.0 → streamft-0.2.1}/streamft/cli.py +0 -0
  13. {streamft-0.2.0 → streamft-0.2.1}/streamft/data.py +0 -0
  14. {streamft-0.2.0 → streamft-0.2.1}/streamft/disk_quant_store.py +0 -0
  15. {streamft-0.2.0 → streamft-0.2.1}/streamft/doctor.py +0 -0
  16. {streamft-0.2.0 → streamft-0.2.1}/streamft/fit.py +0 -0
  17. {streamft-0.2.0 → streamft-0.2.1}/streamft/lora.py +0 -0
  18. {streamft-0.2.0 → streamft-0.2.1}/streamft/metrics.py +0 -0
  19. {streamft-0.2.0 → streamft-0.2.1}/streamft/stream_train.py +0 -0
  20. {streamft-0.2.0 → streamft-0.2.1}/streamft.egg-info/SOURCES.txt +0 -0
  21. {streamft-0.2.0 → streamft-0.2.1}/streamft.egg-info/dependency_links.txt +0 -0
  22. {streamft-0.2.0 → streamft-0.2.1}/streamft.egg-info/entry_points.txt +0 -0
  23. {streamft-0.2.0 → streamft-0.2.1}/streamft.egg-info/requires.txt +0 -0
  24. {streamft-0.2.0 → streamft-0.2.1}/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.0
3
+ Version: 0.2.1
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
@@ -30,6 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  # stream-finetune — fine-tune a 32B model on a 6 GB laptop GPU
32
32
 
33
+ [![PyPI](https://img.shields.io/pypi/v/streamft)](https://pypi.org/project/streamft/) [![try it](https://img.shields.io/badge/try-will%20it%20fit%3F-6ee7b7)](https://huggingface.co/spaces/byjameson/will-it-fit)
34
+
33
35
  A small training engine that fine-tunes models that **do not fit in VRAM**. The NF4
34
36
  weights are mmap'd from disk, one transformer block is materialised on the GPU at a
35
37
  time, and the backward recomputes through it. The base stays frozen; a LoRA adapter is
@@ -61,7 +63,7 @@ and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
61
63
  ## Quick start
62
64
 
63
65
  ```powershell
64
- pip install -e . # or: pip install streamft (once it is on PyPI)
66
+ pip install streamft
65
67
 
66
68
  # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
67
69
  streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
@@ -142,30 +144,33 @@ Raw output: `results/gradcheck.json` (git-ignored; the numbers above are copied
142
144
 
143
145
  ## Compared with PEFT (matched runs)
144
146
 
145
- Same data, same LoRA (r=8, α=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
147
+ Same data, same LoRA (r=8, alpha=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
146
148
  masking, same token-weighted masked eval on 8 held-out rows. PEFT uses
147
149
  `prepare_model_for_kbit_training` with gradient checkpointing (the standard QLoRA setup).
150
+ Step times move by up to ~30% between runs on this laptop; quality is the stable number.
148
151
 
149
152
  **3B (fits entirely):**
150
153
 
151
154
  | | streamft (36 blocks resident) | PEFT / transformers |
152
155
  |---|---:|---:|
153
- | step time, median of 30 @ seq 512 | 0.938 s | **0.579 s** |
156
+ | step time, median of 30 @ seq 512 | 0.94–1.24 s | **0.58–0.88 s** |
154
157
  | peak VRAM | **3.85 GB** | 4.90 GB |
155
158
  | eval before | 1.42087 | 1.42087 |
156
- | eval after | **1.30154** (−0.1193) | 1.32722 (−0.0936) |
159
+ | eval after | **1.30720** (−0.1137) | 1.33002 (−0.0909) |
157
160
 
158
- **7B and 14B (do not fit comfortably):**
161
+ **7B / 14B / 32B (do not fit comfortably), after the masked-gradient fix:**
159
162
 
160
- | model | step time, median @ seq 512 | peak VRAM | eval (start → after) |
163
+ | model | step time, median @ seq 512 | peak VRAM | eval (start → after, 30 steps) |
161
164
  |---|---:|---:|---:|
162
- | 7B, streamft (28 blocks) | **1.70 s** | **5.51 GB** | 2.1928 → 1.1094 |
165
+ | 7B, streamft (28 blocks) | **1.55 s** | **5.51 GB** | 2.1928 → **1.1055** |
163
166
  | 7B, PEFT | 8.96 s | 10.05 GB | 2.1914 → 1.1491 |
164
- | 14B, streamft (6 blocks) | **6.73 s** | **3.86 GB** | 2.3748 → 1.5312 (10 steps) |
165
- | 14B, PEFT | 18.56 s | 16.48 GB | 2.3748 (3 steps only) |
167
+ | 14B, streamft (6 blocks) | **5.86 s** | **3.86 GB** | 2.3748 → **0.9742** |
168
+ | 14B, PEFT | 18.56 s | 16.48 GB | 3 steps only |
169
+ | 32B, streamft (2 blocks, seq 1024) | **12.48 s** | **3.90 GB** | 1.8755 → **1.0008** |
170
+ | 32B, PEFT | fails before step 1 | — | — |
166
171
 
167
172
  At 7B the crossover has already happened: PEFT's 4.4 GB of weights plus activations no
168
- longer fit, so it spills (10.05 GB reserved) and pays 5.3× the step time. Note that
173
+ longer fit, so it spills (10.05 GB reserved) and pays ~5.8× the step time. Note that
169
174
  streamft's 7B peak (5.51 GB) is *higher* than its 14B peak (3.86 GB) because the 7B run
170
175
  kept all 28 blocks resident while the 14B run kept 6 — the window is a choice, and the
171
176
  14B number is the one to copy for a 6 GB card.
@@ -174,17 +179,34 @@ kept all 28 blocks resident while the 14B run kept 6 — the window is a choice,
174
179
  into shared memory (19.45 GB reserved on a 6 GiB card), and `prepare_model_for_kbit_training`
175
180
  then dies allocating the fp32 norm copy: *CUDA out of memory. Tried to allocate 2.90 GiB;
176
181
  18.00 GiB is already allocated.* The streaming engine runs the same checkpoint at
177
- **12.5 s/step and 3.90 GB** (64/64 layers, held-out 1.8755 to 0.9959).
182
+ **12.5 s/step and 3.90 GB** (64/64 layers).
178
183
 
179
184
  The 3B row is the honest answer to "is this faster?": **no** — on a model that fits, PEFT
180
- is 1.6× faster and uses 1.05 GB more. The 14B row is the design point: PEFT does not
181
- OOM on Windows, it **spills into shared system memory** (16.48 GB reserved on a 6.4 GB
182
- card) and pays 2.8× the step time; streamft stays at 3.86 GB. Over 30 steps and one seed
183
- the 3B quality difference is not meaningful.
185
+ is faster and uses 1.05 GB more. The 14B/32B rows are the design point: PEFT does not
186
+ OOM on Windows, it **spills into shared system memory** and pays 2.8–5.8× the step time.
187
+
188
+ ### Correction: masked gradient (25 Oct, after an external review)
189
+
190
+ `chunked_lm_loss_and_backward_masked` divided every chunk by **that chunk's** valid-token
191
+ count. The reported loss was still the correct mean, so the curves looked right, but the
192
+ accumulated gradient over-weighted chunks with few unmasked tokens — i.e. the prompt-heavy
193
+ start of an SFT row. Measured on a toy model with an uneven mask: **112–427% relative
194
+ gradient error** at seq 512/1024 with 60% of the row masked (chunk 128), while balanced
195
+ masks were unaffected.
196
+
197
+ The fix divides every chunk by the **global** valid count (computed once, no-grad), so the
198
+ gradient is exactly d(mean CE over valid tokens)/dh. Two tests pin it:
199
+ `test_masked_chunked_gradient_matches_full_with_uneven_masks` and
200
+ `test_masked_chunked_handles_fully_masked_chunks`.
201
+
202
+ All SFT runs above were re-run with the fix. Quality held: 3B −0.114 (was −0.119),
203
+ 7B −1.087 (was −1.083), 32B −0.875 (was −0.880); 14B is now 30 steps (−1.40) instead of
204
+ 10. The numbers in the tables are the post-fix ones.
184
205
 
185
206
  Reproduce: `python scripts/compare_peft.py --model checkpoints/qwen3b_bnb4bit --steps 30`
186
- and `... --model checkpoints/qwen14b_nf4 --steps 3`.
207
+ and `... --model checkpoints/qwen14b_nf4 --steps 30`.
187
208
 
209
+ ## What this does not do
188
210
  ## What this does not do
189
211
 
190
212
  - **2k context does not fit** on 6 GB. It is ~0.6 GB short: with `--activation-cache cpu`
@@ -1,5 +1,7 @@
1
1
  # stream-finetune — fine-tune a 32B model on a 6 GB laptop GPU
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/streamft)](https://pypi.org/project/streamft/) [![try it](https://img.shields.io/badge/try-will%20it%20fit%3F-6ee7b7)](https://huggingface.co/spaces/byjameson/will-it-fit)
4
+
3
5
  A small training engine that fine-tunes models that **do not fit in VRAM**. The NF4
4
6
  weights are mmap'd from disk, one transformer block is materialised on the GPU at a
5
7
  time, and the backward recomputes through it. The base stays frozen; a LoRA adapter is
@@ -31,7 +33,7 @@ and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
31
33
  ## Quick start
32
34
 
33
35
  ```powershell
34
- pip install -e . # or: pip install streamft (once it is on PyPI)
36
+ pip install streamft
35
37
 
36
38
  # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
37
39
  streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
@@ -112,30 +114,33 @@ Raw output: `results/gradcheck.json` (git-ignored; the numbers above are copied
112
114
 
113
115
  ## Compared with PEFT (matched runs)
114
116
 
115
- Same data, same LoRA (r=8, α=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
117
+ Same data, same LoRA (r=8, alpha=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
116
118
  masking, same token-weighted masked eval on 8 held-out rows. PEFT uses
117
119
  `prepare_model_for_kbit_training` with gradient checkpointing (the standard QLoRA setup).
120
+ Step times move by up to ~30% between runs on this laptop; quality is the stable number.
118
121
 
119
122
  **3B (fits entirely):**
120
123
 
121
124
  | | streamft (36 blocks resident) | PEFT / transformers |
122
125
  |---|---:|---:|
123
- | step time, median of 30 @ seq 512 | 0.938 s | **0.579 s** |
126
+ | step time, median of 30 @ seq 512 | 0.94–1.24 s | **0.58–0.88 s** |
124
127
  | peak VRAM | **3.85 GB** | 4.90 GB |
125
128
  | eval before | 1.42087 | 1.42087 |
126
- | eval after | **1.30154** (−0.1193) | 1.32722 (−0.0936) |
129
+ | eval after | **1.30720** (−0.1137) | 1.33002 (−0.0909) |
127
130
 
128
- **7B and 14B (do not fit comfortably):**
131
+ **7B / 14B / 32B (do not fit comfortably), after the masked-gradient fix:**
129
132
 
130
- | model | step time, median @ seq 512 | peak VRAM | eval (start → after) |
133
+ | model | step time, median @ seq 512 | peak VRAM | eval (start → after, 30 steps) |
131
134
  |---|---:|---:|---:|
132
- | 7B, streamft (28 blocks) | **1.70 s** | **5.51 GB** | 2.1928 → 1.1094 |
135
+ | 7B, streamft (28 blocks) | **1.55 s** | **5.51 GB** | 2.1928 → **1.1055** |
133
136
  | 7B, PEFT | 8.96 s | 10.05 GB | 2.1914 → 1.1491 |
134
- | 14B, streamft (6 blocks) | **6.73 s** | **3.86 GB** | 2.3748 → 1.5312 (10 steps) |
135
- | 14B, PEFT | 18.56 s | 16.48 GB | 2.3748 (3 steps only) |
137
+ | 14B, streamft (6 blocks) | **5.86 s** | **3.86 GB** | 2.3748 → **0.9742** |
138
+ | 14B, PEFT | 18.56 s | 16.48 GB | 3 steps only |
139
+ | 32B, streamft (2 blocks, seq 1024) | **12.48 s** | **3.90 GB** | 1.8755 → **1.0008** |
140
+ | 32B, PEFT | fails before step 1 | — | — |
136
141
 
137
142
  At 7B the crossover has already happened: PEFT's 4.4 GB of weights plus activations no
138
- longer fit, so it spills (10.05 GB reserved) and pays 5.3× the step time. Note that
143
+ longer fit, so it spills (10.05 GB reserved) and pays ~5.8× the step time. Note that
139
144
  streamft's 7B peak (5.51 GB) is *higher* than its 14B peak (3.86 GB) because the 7B run
140
145
  kept all 28 blocks resident while the 14B run kept 6 — the window is a choice, and the
141
146
  14B number is the one to copy for a 6 GB card.
@@ -144,17 +149,34 @@ kept all 28 blocks resident while the 14B run kept 6 — the window is a choice,
144
149
  into shared memory (19.45 GB reserved on a 6 GiB card), and `prepare_model_for_kbit_training`
145
150
  then dies allocating the fp32 norm copy: *CUDA out of memory. Tried to allocate 2.90 GiB;
146
151
  18.00 GiB is already allocated.* The streaming engine runs the same checkpoint at
147
- **12.5 s/step and 3.90 GB** (64/64 layers, held-out 1.8755 to 0.9959).
152
+ **12.5 s/step and 3.90 GB** (64/64 layers).
148
153
 
149
154
  The 3B row is the honest answer to "is this faster?": **no** — on a model that fits, PEFT
150
- is 1.6× faster and uses 1.05 GB more. The 14B row is the design point: PEFT does not
151
- OOM on Windows, it **spills into shared system memory** (16.48 GB reserved on a 6.4 GB
152
- card) and pays 2.8× the step time; streamft stays at 3.86 GB. Over 30 steps and one seed
153
- the 3B quality difference is not meaningful.
155
+ is faster and uses 1.05 GB more. The 14B/32B rows are the design point: PEFT does not
156
+ OOM on Windows, it **spills into shared system memory** and pays 2.8–5.8× the step time.
157
+
158
+ ### Correction: masked gradient (25 Oct, after an external review)
159
+
160
+ `chunked_lm_loss_and_backward_masked` divided every chunk by **that chunk's** valid-token
161
+ count. The reported loss was still the correct mean, so the curves looked right, but the
162
+ accumulated gradient over-weighted chunks with few unmasked tokens — i.e. the prompt-heavy
163
+ start of an SFT row. Measured on a toy model with an uneven mask: **112–427% relative
164
+ gradient error** at seq 512/1024 with 60% of the row masked (chunk 128), while balanced
165
+ masks were unaffected.
166
+
167
+ The fix divides every chunk by the **global** valid count (computed once, no-grad), so the
168
+ gradient is exactly d(mean CE over valid tokens)/dh. Two tests pin it:
169
+ `test_masked_chunked_gradient_matches_full_with_uneven_masks` and
170
+ `test_masked_chunked_handles_fully_masked_chunks`.
171
+
172
+ All SFT runs above were re-run with the fix. Quality held: 3B −0.114 (was −0.119),
173
+ 7B −1.087 (was −1.083), 32B −0.875 (was −0.880); 14B is now 30 steps (−1.40) instead of
174
+ 10. The numbers in the tables are the post-fix ones.
154
175
 
155
176
  Reproduce: `python scripts/compare_peft.py --model checkpoints/qwen3b_bnb4bit --steps 30`
156
- and `... --model checkpoints/qwen14b_nf4 --steps 3`.
177
+ and `... --model checkpoints/qwen14b_nf4 --steps 30`.
157
178
 
179
+ ## What this does not do
158
180
  ## What this does not do
159
181
 
160
182
  - **2k context does not fit** on 6 GB. It is ~0.6 GB short: with `--activation-cache cpu`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamft"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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"
@@ -22,27 +22,39 @@ from .stream_train import _layer_out, chunked_lm_loss_and_backward
22
22
 
23
23
 
24
24
  def chunked_lm_loss_and_backward_masked(head, h, ys, chunk=128, ignore_index=-100):
25
- """Chunked CE with an ignore_index (for SFT prompt masking)."""
25
+ """Chunked CE with an ignore_index (for SFT prompt masking).
26
+
27
+ Every chunk contributes `loss_sum / valid_total` where valid_total is the number of
28
+ unmasked targets in the **whole sequence**, so the accumulated gradient is exactly
29
+ d(mean CE over valid tokens)/dh.
30
+
31
+ FIX (25.10.2026, reported by an external reviewer): the first version divided each
32
+ chunk by that chunk's own valid count. Losses were unaffected (the returned value
33
+ summed to the right mean) but the gradient over-weighted chunks with few valid
34
+ tokens, i.e. the prompt-heavy start of an SFT row. tests/test_streamft_smoke.py
35
+ pins the corrected behaviour with an uneven mask.
36
+ """
26
37
  B, T, _ = h.shape
38
+ tgt_all = ys.to(h.device)
39
+ valid_total = int((tgt_all != ignore_index).sum())
40
+ if valid_total == 0:
41
+ zero = torch.zeros((), device=h.device)
42
+ return zero, zero
27
43
  total = torch.zeros((), device=h.device, dtype=torch.float32)
28
- valid_total = 0
29
44
  for a in range(0, T, chunk):
30
45
  b = min(a + chunk, T)
31
- logits = head(h[:, a:b])
32
- tgt = ys[:, a:b].reshape(-1).to(h.device)
33
- valid = int((tgt != ignore_index).sum())
34
- if valid == 0:
46
+ tgt = tgt_all[:, a:b].reshape(-1)
47
+ if int((tgt != ignore_index).sum()) == 0:
35
48
  continue
49
+ logits = head(h[:, a:b])
36
50
  loss_sum = torch.nn.functional.cross_entropy(
37
51
  logits.reshape(-1, logits.shape[-1]).float(), tgt,
38
52
  reduction="sum", ignore_index=ignore_index,
39
53
  )
40
- (loss_sum / valid).backward()
54
+ (loss_sum / valid_total).backward()
41
55
  with torch.no_grad():
42
56
  total = total + loss_sum.detach()
43
- valid_total += valid
44
- denom = max(valid_total, 1)
45
- return total / denom, total / denom
57
+ return total / valid_total, total / valid_total
46
58
 
47
59
 
48
60
  def base_model_of(model):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamft
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -30,6 +30,8 @@ Dynamic: license-file
30
30
 
31
31
  # stream-finetune — fine-tune a 32B model on a 6 GB laptop GPU
32
32
 
33
+ [![PyPI](https://img.shields.io/pypi/v/streamft)](https://pypi.org/project/streamft/) [![try it](https://img.shields.io/badge/try-will%20it%20fit%3F-6ee7b7)](https://huggingface.co/spaces/byjameson/will-it-fit)
34
+
33
35
  A small training engine that fine-tunes models that **do not fit in VRAM**. The NF4
34
36
  weights are mmap'd from disk, one transformer block is materialised on the GPU at a
35
37
  time, and the backward recomputes through it. The base stays frozen; a LoRA adapter is
@@ -61,7 +63,7 @@ and the correction of an earlier wrong claim ("8.6× / 100 tok/s") are in
61
63
  ## Quick start
62
64
 
63
65
  ```powershell
64
- pip install -e . # or: pip install streamft (once it is on PyPI)
66
+ pip install streamft
65
67
 
66
68
  # 0) will it fit? memory arithmetic + the measured reference, no weights downloaded
67
69
  streamft fit --model Qwen/Qwen2.5-14B-Instruct --gpu 6 --seq 512
@@ -142,30 +144,33 @@ Raw output: `results/gradcheck.json` (git-ignored; the numbers above are copied
142
144
 
143
145
  ## Compared with PEFT (matched runs)
144
146
 
145
- Same data, same LoRA (r=8, α=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
147
+ Same data, same LoRA (r=8, alpha=16, q/k/v/o), same AdamW lr 1e-4, batch 1, same prompt
146
148
  masking, same token-weighted masked eval on 8 held-out rows. PEFT uses
147
149
  `prepare_model_for_kbit_training` with gradient checkpointing (the standard QLoRA setup).
150
+ Step times move by up to ~30% between runs on this laptop; quality is the stable number.
148
151
 
149
152
  **3B (fits entirely):**
150
153
 
151
154
  | | streamft (36 blocks resident) | PEFT / transformers |
152
155
  |---|---:|---:|
153
- | step time, median of 30 @ seq 512 | 0.938 s | **0.579 s** |
156
+ | step time, median of 30 @ seq 512 | 0.94–1.24 s | **0.58–0.88 s** |
154
157
  | peak VRAM | **3.85 GB** | 4.90 GB |
155
158
  | eval before | 1.42087 | 1.42087 |
156
- | eval after | **1.30154** (−0.1193) | 1.32722 (−0.0936) |
159
+ | eval after | **1.30720** (−0.1137) | 1.33002 (−0.0909) |
157
160
 
158
- **7B and 14B (do not fit comfortably):**
161
+ **7B / 14B / 32B (do not fit comfortably), after the masked-gradient fix:**
159
162
 
160
- | model | step time, median @ seq 512 | peak VRAM | eval (start → after) |
163
+ | model | step time, median @ seq 512 | peak VRAM | eval (start → after, 30 steps) |
161
164
  |---|---:|---:|---:|
162
- | 7B, streamft (28 blocks) | **1.70 s** | **5.51 GB** | 2.1928 → 1.1094 |
165
+ | 7B, streamft (28 blocks) | **1.55 s** | **5.51 GB** | 2.1928 → **1.1055** |
163
166
  | 7B, PEFT | 8.96 s | 10.05 GB | 2.1914 → 1.1491 |
164
- | 14B, streamft (6 blocks) | **6.73 s** | **3.86 GB** | 2.3748 → 1.5312 (10 steps) |
165
- | 14B, PEFT | 18.56 s | 16.48 GB | 2.3748 (3 steps only) |
167
+ | 14B, streamft (6 blocks) | **5.86 s** | **3.86 GB** | 2.3748 → **0.9742** |
168
+ | 14B, PEFT | 18.56 s | 16.48 GB | 3 steps only |
169
+ | 32B, streamft (2 blocks, seq 1024) | **12.48 s** | **3.90 GB** | 1.8755 → **1.0008** |
170
+ | 32B, PEFT | fails before step 1 | — | — |
166
171
 
167
172
  At 7B the crossover has already happened: PEFT's 4.4 GB of weights plus activations no
168
- longer fit, so it spills (10.05 GB reserved) and pays 5.3× the step time. Note that
173
+ longer fit, so it spills (10.05 GB reserved) and pays ~5.8× the step time. Note that
169
174
  streamft's 7B peak (5.51 GB) is *higher* than its 14B peak (3.86 GB) because the 7B run
170
175
  kept all 28 blocks resident while the 14B run kept 6 — the window is a choice, and the
171
176
  14B number is the one to copy for a 6 GB card.
@@ -174,17 +179,34 @@ kept all 28 blocks resident while the 14B run kept 6 — the window is a choice,
174
179
  into shared memory (19.45 GB reserved on a 6 GiB card), and `prepare_model_for_kbit_training`
175
180
  then dies allocating the fp32 norm copy: *CUDA out of memory. Tried to allocate 2.90 GiB;
176
181
  18.00 GiB is already allocated.* The streaming engine runs the same checkpoint at
177
- **12.5 s/step and 3.90 GB** (64/64 layers, held-out 1.8755 to 0.9959).
182
+ **12.5 s/step and 3.90 GB** (64/64 layers).
178
183
 
179
184
  The 3B row is the honest answer to "is this faster?": **no** — on a model that fits, PEFT
180
- is 1.6× faster and uses 1.05 GB more. The 14B row is the design point: PEFT does not
181
- OOM on Windows, it **spills into shared system memory** (16.48 GB reserved on a 6.4 GB
182
- card) and pays 2.8× the step time; streamft stays at 3.86 GB. Over 30 steps and one seed
183
- the 3B quality difference is not meaningful.
185
+ is faster and uses 1.05 GB more. The 14B/32B rows are the design point: PEFT does not
186
+ OOM on Windows, it **spills into shared system memory** and pays 2.8–5.8× the step time.
187
+
188
+ ### Correction: masked gradient (25 Oct, after an external review)
189
+
190
+ `chunked_lm_loss_and_backward_masked` divided every chunk by **that chunk's** valid-token
191
+ count. The reported loss was still the correct mean, so the curves looked right, but the
192
+ accumulated gradient over-weighted chunks with few unmasked tokens — i.e. the prompt-heavy
193
+ start of an SFT row. Measured on a toy model with an uneven mask: **112–427% relative
194
+ gradient error** at seq 512/1024 with 60% of the row masked (chunk 128), while balanced
195
+ masks were unaffected.
196
+
197
+ The fix divides every chunk by the **global** valid count (computed once, no-grad), so the
198
+ gradient is exactly d(mean CE over valid tokens)/dh. Two tests pin it:
199
+ `test_masked_chunked_gradient_matches_full_with_uneven_masks` and
200
+ `test_masked_chunked_handles_fully_masked_chunks`.
201
+
202
+ All SFT runs above were re-run with the fix. Quality held: 3B −0.114 (was −0.119),
203
+ 7B −1.087 (was −1.083), 32B −0.875 (was −0.880); 14B is now 30 steps (−1.40) instead of
204
+ 10. The numbers in the tables are the post-fix ones.
184
205
 
185
206
  Reproduce: `python scripts/compare_peft.py --model checkpoints/qwen3b_bnb4bit --steps 30`
186
- and `... --model checkpoints/qwen14b_nf4 --steps 3`.
207
+ and `... --model checkpoints/qwen14b_nf4 --steps 30`.
187
208
 
209
+ ## What this does not do
188
210
  ## What this does not do
189
211
 
190
212
  - **2k context does not fit** on 6 GB. It is ~0.6 GB short: with `--activation-cache cpu`
@@ -112,3 +112,53 @@ def test_chunked_ce_gradient_matches_full_mean():
112
112
 
113
113
  def test_cli_is_importable():
114
114
  assert callable(cli.main)
115
+
116
+
117
+ # ------------------------------------------- masked (SFT) chunked loss
118
+
119
+ def test_masked_chunked_gradient_matches_full_with_uneven_masks():
120
+ """The 25.10 fix: chunks with different valid counts must not be re-weighted.
121
+
122
+ With chunk=3 and a prompt mask over the first 4 positions, chunk 0 has zero valid
123
+ targets, chunk 1 has two, later chunks have three. The old implementation divided
124
+ each chunk by its own count, which inflated the sparse chunks' gradient.
125
+ """
126
+ from streamft.stream_quant_lora import chunked_lm_loss_and_backward_masked
127
+
128
+ torch.manual_seed(0)
129
+ d, T, V = 12, 10, 7
130
+ head = nn.Linear(d, V, bias=False)
131
+ ys = torch.randint(0, V, (1, T))
132
+ ys[0, :4] = -100 # prompt
133
+ ys[0, 7] = -100 # a masked middle position
134
+
135
+ h1 = torch.randn(1, T, d, requires_grad=True)
136
+ full = F.cross_entropy(head(h1).reshape(-1, V), ys.reshape(-1),
137
+ ignore_index=-100, reduction="mean")
138
+ full.backward()
139
+
140
+ h2 = torch.randn(1, T, d, requires_grad=True)
141
+ with torch.no_grad():
142
+ h2.copy_(h1.detach())
143
+ _, mean = chunked_lm_loss_and_backward_masked(head, h2, ys, chunk=3)
144
+
145
+ assert math.isclose(float(mean.detach()), float(full.detach()), rel_tol=1e-6)
146
+ assert torch.allclose(h1.grad, h2.grad, atol=1e-6)
147
+
148
+
149
+ def test_masked_chunked_handles_fully_masked_chunks():
150
+ """A chunk with no valid targets must contribute nothing at all."""
151
+ from streamft.stream_quant_lora import chunked_lm_loss_and_backward_masked
152
+
153
+ torch.manual_seed(0)
154
+ d, T, V = 8, 6, 5
155
+ head = nn.Linear(d, V, bias=False)
156
+ ys = torch.randint(0, V, (1, T))
157
+ ys[0, :3] = -100
158
+ h = torch.randn(1, T, d, requires_grad=True)
159
+ _, mean = chunked_lm_loss_and_backward_masked(head, h, ys, chunk=2)
160
+ logits = head(h).reshape(-1, V)
161
+ tgt = ys.reshape(-1)
162
+ mask = tgt != -100
163
+ ref = F.cross_entropy(logits[mask], tgt[mask], reduction="mean")
164
+ assert math.isclose(float(mean.detach()), float(ref.detach()), rel_tol=1e-6)
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