silentbug 0.1.0__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.
- silentbug-0.1.0/LICENSE +21 -0
- silentbug-0.1.0/PKG-INFO +183 -0
- silentbug-0.1.0/README.md +162 -0
- silentbug-0.1.0/pyproject.toml +34 -0
- silentbug-0.1.0/setup.cfg +4 -0
- silentbug-0.1.0/src/silentbug/__init__.py +7 -0
- silentbug-0.1.0/src/silentbug/__main__.py +3 -0
- silentbug-0.1.0/src/silentbug/_where.py +100 -0
- silentbug-0.1.0/src/silentbug/cli.py +80 -0
- silentbug-0.1.0/src/silentbug/explain.py +225 -0
- silentbug-0.1.0/src/silentbug/report.py +105 -0
- silentbug-0.1.0/src/silentbug/session.py +794 -0
- silentbug-0.1.0/src/silentbug.egg-info/PKG-INFO +183 -0
- silentbug-0.1.0/src/silentbug.egg-info/SOURCES.txt +17 -0
- silentbug-0.1.0/src/silentbug.egg-info/dependency_links.txt +1 -0
- silentbug-0.1.0/src/silentbug.egg-info/entry_points.txt +2 -0
- silentbug-0.1.0/src/silentbug.egg-info/requires.txt +4 -0
- silentbug-0.1.0/src/silentbug.egg-info/top_level.txt +1 -0
- silentbug-0.1.0/tests/test_silentbug.py +369 -0
silentbug-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shushant Kumar Choudhary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
silentbug-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silentbug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find silent PyTorch training bugs: accidental broadcasting, parameters that never learn, and the line that caused them.
|
|
5
|
+
Author: Shushant Kumar Choudhary
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Shushant17711/silentbug
|
|
8
|
+
Project-URL: Issues, https://github.com/Shushant17711/silentbug/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Shushant17711/silentbug/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: pytorch,debugging,broadcasting,gradients,training
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: torch>=2.1
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# silentbug
|
|
23
|
+
|
|
24
|
+
Your model trains, the loss goes down a little, and nothing errors. But one tensor is
|
|
25
|
+
broadcast against another, half your parameters never get a gradient, or softmax runs
|
|
26
|
+
twice. silentbug watches a few training steps and tells you **which** silent bug it is,
|
|
27
|
+
**on which line**, **why** it matters and **how to fix it**.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ silentbug run train.py
|
|
31
|
+
silentbug: checked 3 backward pass(es), 3 optimizer step(s) on Net
|
|
32
|
+
2 likely bug(s), 0 thing(s) to check
|
|
33
|
+
|
|
34
|
+
[BUG ] 1. mse_loss got input (100, 1) and target (100,)
|
|
35
|
+
at train.py:13: loss = crit(yhat, y)
|
|
36
|
+
why: they broadcast to (100, 100), so each prediction is compared with many targets
|
|
37
|
+
instead of its own. The loss still goes down a little, which hides the bug.
|
|
38
|
+
fix: reshape one side so input and target have the same shape (often `.squeeze(-1)`)
|
|
39
|
+
|
|
40
|
+
[BUG ] 2. 2 parameters never get a gradient: cut by detach
|
|
41
|
+
at train.py:21: g = self.gate(h).detach()
|
|
42
|
+
parameters: gate.bias, gate.weight
|
|
43
|
+
fix: remove `.detach()` if these parameters should learn
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install git+https://github.com/Shushant17711/silentbug@v0.1.0 # Python 3.10+, PyTorch 2.1+
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
From a clone, for development:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/Shushant17711/silentbug && cd silentbug
|
|
56
|
+
pip install -e ".[test]" && pytest tests
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Use it
|
|
60
|
+
|
|
61
|
+
On a script, with no code changes. It runs the first N optimizer steps (default 3), then stops:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
silentbug run train.py --lr 3e-4 # your script's own arguments go after it
|
|
65
|
+
silentbug run --steps 10 train.py
|
|
66
|
+
silentbug run --json - train.py # JSON report on stdout
|
|
67
|
+
silentbug run -m mypackage.train # like python -m
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Exit codes: 0 nothing found, 1 likely bugs found, 2 only things to check.
|
|
71
|
+
|
|
72
|
+
Or around a block of code, for example in a test:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import silentbug
|
|
76
|
+
|
|
77
|
+
with silentbug.check() as report:
|
|
78
|
+
for step in range(3):
|
|
79
|
+
loss = loss_fn(model(x), y)
|
|
80
|
+
loss.backward()
|
|
81
|
+
opt.step(); opt.zero_grad()
|
|
82
|
+
|
|
83
|
+
assert not report.bugs, str(report)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Models and optimizers are found on their own. Pass them in (`silentbug.check(model,
|
|
87
|
+
optimizer=opt)`) only when the model is never called inside the block.
|
|
88
|
+
|
|
89
|
+
## What it finds
|
|
90
|
+
|
|
91
|
+
| Kind | Example | Level |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `broadcast` | `(N,1) - (N,)` gives an `(N,N)` loss, or `mse_loss(input (B,1), target (B,))` | BUG |
|
|
94
|
+
| `dead-param` | parameters with no gradient, with the cause: `.detach()`, `.data`, `.item()`/NumPy round trip, `torch.no_grad()`, argmax/topk indices, a module never called (LoRA bypassed), an output that never reaches the loss | BUG |
|
|
95
|
+
| `zero-grad` | gradients that exist but are always 0 (`round`, `sign`, `floor`, ...) | BUG |
|
|
96
|
+
| `not-in-optimizer` | layers created after the optimizer was built | BUG |
|
|
97
|
+
| `stale-optimizer` | the optimizer still updates parameters that were replaced | BUG |
|
|
98
|
+
| `double-activation` | softmax fed to `cross_entropy`/`nll_loss`, sigmoid fed to `BCEWithLogits`, softmax then `log_softmax` | BUG |
|
|
99
|
+
| `eval-mode` | the model is still in `eval()` while it trains | BUG |
|
|
100
|
+
| `grad-not-cleared` | `optimizer.step()` uses gradients left over from the previous step | BUG |
|
|
101
|
+
| `batchnorm-batch1` | batch norm in training mode with a batch of 1 | CHECK |
|
|
102
|
+
|
|
103
|
+
BUG means almost certainly wrong. CHECK means often wrong but sometimes intended (for example an
|
|
104
|
+
outer product written as a broadcast).
|
|
105
|
+
|
|
106
|
+
How it works: a `TorchFunctionMode` sees every torch call. It checks the shapes going into
|
|
107
|
+
broadcasting ops and losses, and tags each tensor with a bitmask of the trainable parameters it
|
|
108
|
+
depends on. When a tensor built from parameters comes out of an op without a gradient, that line is
|
|
109
|
+
recorded as a cut. Gradient hooks on every parameter record which ones received a gradient, and
|
|
110
|
+
global module and optimizer hooks find the models and optimizers. After the run, each parameter
|
|
111
|
+
that never got a gradient is matched to the cut or missing call that explains it.
|
|
112
|
+
|
|
113
|
+
## Tested on real bugs
|
|
114
|
+
|
|
115
|
+
`benchmarks/realworld/` has 15 reproducers of silent bugs from public issues (PyTorch, PEFT,
|
|
116
|
+
torchtitan, kornia, DeepTab, poolformer, Lightning Bolts, simpletransformers and others). Each one
|
|
117
|
+
is a short script that keeps the buggy code pattern from the issue. Each also has a fixed version
|
|
118
|
+
(`f*.py`) that must produce no findings.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd benchmarks/realworld && python score.py $(which silentbug)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Case | Issue | Bug | Found at the right line |
|
|
125
|
+
|---|---|---|---|
|
|
126
|
+
| r01 | pytorch/pytorch#16045 | `MSELoss` on `(N,1)` vs `(N,)` | yes |
|
|
127
|
+
| r02 | OpenTabular/DeepTab#441 | `(n,1)` target unsqueezed to `(B,1,1)` | yes |
|
|
128
|
+
| r03 | kornia/kornia#6157 | SSIM loss broadcasts a batch-1 target | yes |
|
|
129
|
+
| r04 | trnsci/trnsparse#67 | `y` broadcasts the spmv output to `(12,12)` | yes |
|
|
130
|
+
| r05 | huggingface/peft#1612 | weights read directly, LoRA wrapper never runs | yes |
|
|
131
|
+
| r06 | sail-sg/poolformer#16 | stage-0 output dropped, `norm0` never trains | yes |
|
|
132
|
+
| r07 | pytorch/torchtitan#4802 | top-k indices cut the indexer's gradient | yes |
|
|
133
|
+
| r08 | pytorch/pytorch#14467 | GRU created after the optimizer | yes |
|
|
134
|
+
| r09 | RAI-SCC/jigsaw#224 | reshard replaces parameters, optimizer goes stale | yes |
|
|
135
|
+
| r10 | gitabcworld/MatchingNetworks#5 | softmax, then `cross_entropy` | yes |
|
|
136
|
+
| r11 | cloudtrainerwork/chesstrader#49 | softmax output fed to `CrossEntropyLoss` | yes |
|
|
137
|
+
| r12 | Lightning-Universe/lightning-bolts#654 | `log_softmax(softmax(x))` | yes |
|
|
138
|
+
| r13 | YuanGongND/ssast#2 | sigmoid fed to `BCEWithLogitsLoss` | yes |
|
|
139
|
+
| r14 | ThilinaRajapakse/simpletransformers#941 | `model.eval()` never undone | yes |
|
|
140
|
+
| r15 | juweipku/CoCo#1 | no `optimizer.zero_grad()` | yes |
|
|
141
|
+
|
|
142
|
+
Result: 15 of 15 found at the right line, 15 of 15 fixed versions report nothing.
|
|
143
|
+
|
|
144
|
+
## Overhead
|
|
145
|
+
|
|
146
|
+
6-layer MLP, width 1024, batch 256, AdamW, CPU: 30.9 ms/step plain, 33.8 ms/step under
|
|
147
|
+
silentbug (1.09x). It is meant to run for a few steps, not for a whole training run.
|
|
148
|
+
|
|
149
|
+
## Limits
|
|
150
|
+
|
|
151
|
+
## Tested setups
|
|
152
|
+
|
|
153
|
+
* PyTorch 2.15 on CPU and PyTorch 2.14 with CUDA 13.0 on an RTX 4060: unit tests and all 15 benchmark
|
|
154
|
+
cases plus their 15 fixed versions, on both devices.
|
|
155
|
+
* Mixed precision (`torch.autocast` with fp16 + `GradScaler`, and bf16): bugs found, clean code stays quiet.
|
|
156
|
+
* `torch.compile` (a compiled model, and a compiled loss function): findings name the original module.
|
|
157
|
+
* DDP with 2 processes (`torchrun`, gloo): DDP's own internal detach is not reported. Only rank 0 prints.
|
|
158
|
+
* Real code, unchanged: karpathy/nanoGPT (CUDA, with and without `compile`, with gradient accumulation)
|
|
159
|
+
and pytorch/examples `word_language_model` (manual SGD with no optimizer). Neither gave a false alarm.
|
|
160
|
+
When `model.zero_grad()` was deleted from the second one, silentbug reported it at the update line.
|
|
161
|
+
|
|
162
|
+
## Limits
|
|
163
|
+
|
|
164
|
+
* Not tested with FSDP, tensor or pipeline parallelism, or on multiple GPUs at once.
|
|
165
|
+
* `torch.compile`: while silentbug watches, the compiler stops at silentbug's hooks and runs the
|
|
166
|
+
compiled code op by op. That is why findings inside a compiled function point at the exact line
|
|
167
|
+
there. It also means bugs that only happen in compiled graphs (compiler bugs) are not seen, and
|
|
168
|
+
the watched steps run at eager speed. Compilation goes back to normal once the check ends.
|
|
169
|
+
* It only sees what runs during the steps it watches. A branch that runs only every 100 steps is
|
|
170
|
+
not checked.
|
|
171
|
+
* `broadcast` checks the first three tensor arguments of each op.
|
|
172
|
+
|
|
173
|
+
## Related tools
|
|
174
|
+
|
|
175
|
+
* [nantrace](https://github.com/Optimus2007/nantrace) finds where NaN and Inf start. silentbug
|
|
176
|
+
finds the bugs that never produce a NaN.
|
|
177
|
+
* [jaxtyping](https://github.com/patrick-kidger/jaxtyping) checks shapes if you annotate them.
|
|
178
|
+
silentbug needs no annotations.
|
|
179
|
+
* `TORCH_DISTRIBUTED_DEBUG=DETAIL` names unused parameters under DDP, but not why they are unused.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# silentbug
|
|
2
|
+
|
|
3
|
+
Your model trains, the loss goes down a little, and nothing errors. But one tensor is
|
|
4
|
+
broadcast against another, half your parameters never get a gradient, or softmax runs
|
|
5
|
+
twice. silentbug watches a few training steps and tells you **which** silent bug it is,
|
|
6
|
+
**on which line**, **why** it matters and **how to fix it**.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
$ silentbug run train.py
|
|
10
|
+
silentbug: checked 3 backward pass(es), 3 optimizer step(s) on Net
|
|
11
|
+
2 likely bug(s), 0 thing(s) to check
|
|
12
|
+
|
|
13
|
+
[BUG ] 1. mse_loss got input (100, 1) and target (100,)
|
|
14
|
+
at train.py:13: loss = crit(yhat, y)
|
|
15
|
+
why: they broadcast to (100, 100), so each prediction is compared with many targets
|
|
16
|
+
instead of its own. The loss still goes down a little, which hides the bug.
|
|
17
|
+
fix: reshape one side so input and target have the same shape (often `.squeeze(-1)`)
|
|
18
|
+
|
|
19
|
+
[BUG ] 2. 2 parameters never get a gradient: cut by detach
|
|
20
|
+
at train.py:21: g = self.gate(h).detach()
|
|
21
|
+
parameters: gate.bias, gate.weight
|
|
22
|
+
fix: remove `.detach()` if these parameters should learn
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install git+https://github.com/Shushant17711/silentbug@v0.1.0 # Python 3.10+, PyTorch 2.1+
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a clone, for development:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/Shushant17711/silentbug && cd silentbug
|
|
35
|
+
pip install -e ".[test]" && pytest tests
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Use it
|
|
39
|
+
|
|
40
|
+
On a script, with no code changes. It runs the first N optimizer steps (default 3), then stops:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
silentbug run train.py --lr 3e-4 # your script's own arguments go after it
|
|
44
|
+
silentbug run --steps 10 train.py
|
|
45
|
+
silentbug run --json - train.py # JSON report on stdout
|
|
46
|
+
silentbug run -m mypackage.train # like python -m
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Exit codes: 0 nothing found, 1 likely bugs found, 2 only things to check.
|
|
50
|
+
|
|
51
|
+
Or around a block of code, for example in a test:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import silentbug
|
|
55
|
+
|
|
56
|
+
with silentbug.check() as report:
|
|
57
|
+
for step in range(3):
|
|
58
|
+
loss = loss_fn(model(x), y)
|
|
59
|
+
loss.backward()
|
|
60
|
+
opt.step(); opt.zero_grad()
|
|
61
|
+
|
|
62
|
+
assert not report.bugs, str(report)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Models and optimizers are found on their own. Pass them in (`silentbug.check(model,
|
|
66
|
+
optimizer=opt)`) only when the model is never called inside the block.
|
|
67
|
+
|
|
68
|
+
## What it finds
|
|
69
|
+
|
|
70
|
+
| Kind | Example | Level |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `broadcast` | `(N,1) - (N,)` gives an `(N,N)` loss, or `mse_loss(input (B,1), target (B,))` | BUG |
|
|
73
|
+
| `dead-param` | parameters with no gradient, with the cause: `.detach()`, `.data`, `.item()`/NumPy round trip, `torch.no_grad()`, argmax/topk indices, a module never called (LoRA bypassed), an output that never reaches the loss | BUG |
|
|
74
|
+
| `zero-grad` | gradients that exist but are always 0 (`round`, `sign`, `floor`, ...) | BUG |
|
|
75
|
+
| `not-in-optimizer` | layers created after the optimizer was built | BUG |
|
|
76
|
+
| `stale-optimizer` | the optimizer still updates parameters that were replaced | BUG |
|
|
77
|
+
| `double-activation` | softmax fed to `cross_entropy`/`nll_loss`, sigmoid fed to `BCEWithLogits`, softmax then `log_softmax` | BUG |
|
|
78
|
+
| `eval-mode` | the model is still in `eval()` while it trains | BUG |
|
|
79
|
+
| `grad-not-cleared` | `optimizer.step()` uses gradients left over from the previous step | BUG |
|
|
80
|
+
| `batchnorm-batch1` | batch norm in training mode with a batch of 1 | CHECK |
|
|
81
|
+
|
|
82
|
+
BUG means almost certainly wrong. CHECK means often wrong but sometimes intended (for example an
|
|
83
|
+
outer product written as a broadcast).
|
|
84
|
+
|
|
85
|
+
How it works: a `TorchFunctionMode` sees every torch call. It checks the shapes going into
|
|
86
|
+
broadcasting ops and losses, and tags each tensor with a bitmask of the trainable parameters it
|
|
87
|
+
depends on. When a tensor built from parameters comes out of an op without a gradient, that line is
|
|
88
|
+
recorded as a cut. Gradient hooks on every parameter record which ones received a gradient, and
|
|
89
|
+
global module and optimizer hooks find the models and optimizers. After the run, each parameter
|
|
90
|
+
that never got a gradient is matched to the cut or missing call that explains it.
|
|
91
|
+
|
|
92
|
+
## Tested on real bugs
|
|
93
|
+
|
|
94
|
+
`benchmarks/realworld/` has 15 reproducers of silent bugs from public issues (PyTorch, PEFT,
|
|
95
|
+
torchtitan, kornia, DeepTab, poolformer, Lightning Bolts, simpletransformers and others). Each one
|
|
96
|
+
is a short script that keeps the buggy code pattern from the issue. Each also has a fixed version
|
|
97
|
+
(`f*.py`) that must produce no findings.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd benchmarks/realworld && python score.py $(which silentbug)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Case | Issue | Bug | Found at the right line |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| r01 | pytorch/pytorch#16045 | `MSELoss` on `(N,1)` vs `(N,)` | yes |
|
|
106
|
+
| r02 | OpenTabular/DeepTab#441 | `(n,1)` target unsqueezed to `(B,1,1)` | yes |
|
|
107
|
+
| r03 | kornia/kornia#6157 | SSIM loss broadcasts a batch-1 target | yes |
|
|
108
|
+
| r04 | trnsci/trnsparse#67 | `y` broadcasts the spmv output to `(12,12)` | yes |
|
|
109
|
+
| r05 | huggingface/peft#1612 | weights read directly, LoRA wrapper never runs | yes |
|
|
110
|
+
| r06 | sail-sg/poolformer#16 | stage-0 output dropped, `norm0` never trains | yes |
|
|
111
|
+
| r07 | pytorch/torchtitan#4802 | top-k indices cut the indexer's gradient | yes |
|
|
112
|
+
| r08 | pytorch/pytorch#14467 | GRU created after the optimizer | yes |
|
|
113
|
+
| r09 | RAI-SCC/jigsaw#224 | reshard replaces parameters, optimizer goes stale | yes |
|
|
114
|
+
| r10 | gitabcworld/MatchingNetworks#5 | softmax, then `cross_entropy` | yes |
|
|
115
|
+
| r11 | cloudtrainerwork/chesstrader#49 | softmax output fed to `CrossEntropyLoss` | yes |
|
|
116
|
+
| r12 | Lightning-Universe/lightning-bolts#654 | `log_softmax(softmax(x))` | yes |
|
|
117
|
+
| r13 | YuanGongND/ssast#2 | sigmoid fed to `BCEWithLogitsLoss` | yes |
|
|
118
|
+
| r14 | ThilinaRajapakse/simpletransformers#941 | `model.eval()` never undone | yes |
|
|
119
|
+
| r15 | juweipku/CoCo#1 | no `optimizer.zero_grad()` | yes |
|
|
120
|
+
|
|
121
|
+
Result: 15 of 15 found at the right line, 15 of 15 fixed versions report nothing.
|
|
122
|
+
|
|
123
|
+
## Overhead
|
|
124
|
+
|
|
125
|
+
6-layer MLP, width 1024, batch 256, AdamW, CPU: 30.9 ms/step plain, 33.8 ms/step under
|
|
126
|
+
silentbug (1.09x). It is meant to run for a few steps, not for a whole training run.
|
|
127
|
+
|
|
128
|
+
## Limits
|
|
129
|
+
|
|
130
|
+
## Tested setups
|
|
131
|
+
|
|
132
|
+
* PyTorch 2.15 on CPU and PyTorch 2.14 with CUDA 13.0 on an RTX 4060: unit tests and all 15 benchmark
|
|
133
|
+
cases plus their 15 fixed versions, on both devices.
|
|
134
|
+
* Mixed precision (`torch.autocast` with fp16 + `GradScaler`, and bf16): bugs found, clean code stays quiet.
|
|
135
|
+
* `torch.compile` (a compiled model, and a compiled loss function): findings name the original module.
|
|
136
|
+
* DDP with 2 processes (`torchrun`, gloo): DDP's own internal detach is not reported. Only rank 0 prints.
|
|
137
|
+
* Real code, unchanged: karpathy/nanoGPT (CUDA, with and without `compile`, with gradient accumulation)
|
|
138
|
+
and pytorch/examples `word_language_model` (manual SGD with no optimizer). Neither gave a false alarm.
|
|
139
|
+
When `model.zero_grad()` was deleted from the second one, silentbug reported it at the update line.
|
|
140
|
+
|
|
141
|
+
## Limits
|
|
142
|
+
|
|
143
|
+
* Not tested with FSDP, tensor or pipeline parallelism, or on multiple GPUs at once.
|
|
144
|
+
* `torch.compile`: while silentbug watches, the compiler stops at silentbug's hooks and runs the
|
|
145
|
+
compiled code op by op. That is why findings inside a compiled function point at the exact line
|
|
146
|
+
there. It also means bugs that only happen in compiled graphs (compiler bugs) are not seen, and
|
|
147
|
+
the watched steps run at eager speed. Compilation goes back to normal once the check ends.
|
|
148
|
+
* It only sees what runs during the steps it watches. A branch that runs only every 100 steps is
|
|
149
|
+
not checked.
|
|
150
|
+
* `broadcast` checks the first three tensor arguments of each op.
|
|
151
|
+
|
|
152
|
+
## Related tools
|
|
153
|
+
|
|
154
|
+
* [nantrace](https://github.com/Optimus2007/nantrace) finds where NaN and Inf start. silentbug
|
|
155
|
+
finds the bugs that never produce a NaN.
|
|
156
|
+
* [jaxtyping](https://github.com/patrick-kidger/jaxtyping) checks shapes if you annotate them.
|
|
157
|
+
silentbug needs no annotations.
|
|
158
|
+
* `TORCH_DISTRIBUTED_DEBUG=DETAIL` names unused parameters under DDP, but not why they are unused.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "silentbug"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find silent PyTorch training bugs: accidental broadcasting, parameters that never learn, and the line that caused them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Shushant Kumar Choudhary" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = ["torch>=2.1"]
|
|
15
|
+
keywords = ["pytorch", "debugging", "broadcasting", "gradients", "training"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Shushant17711/silentbug"
|
|
24
|
+
Issues = "https://github.com/Shushant17711/silentbug/issues"
|
|
25
|
+
Changelog = "https://github.com/Shushant17711/silentbug/blob/main/CHANGELOG.md"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
silentbug = "silentbug.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Find the line of user code that called the current torch op."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import linecache
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import sysconfig
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
_TORCH_DIR = os.path.dirname(os.path.realpath(torch.__file__)) + os.sep
|
|
14
|
+
_OWN_DIR = os.path.dirname(os.path.realpath(__file__)) + os.sep
|
|
15
|
+
_LIB_DIRS = tuple(
|
|
16
|
+
{os.path.realpath(p) + os.sep for p in (sysconfig.get_paths().get("purelib"), sysconfig.get_paths().get("platlib")) if p}
|
|
17
|
+
)
|
|
18
|
+
_STDLIB = os.path.realpath(sysconfig.get_paths()["stdlib"]) + os.sep
|
|
19
|
+
|
|
20
|
+
_real_cache: dict[str, str] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _real(path: str) -> str:
|
|
24
|
+
r = _real_cache.get(path)
|
|
25
|
+
if r is None:
|
|
26
|
+
r = os.path.realpath(path) if not path.startswith("<") else path
|
|
27
|
+
_real_cache[path] = r
|
|
28
|
+
return r
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _skip(path: str) -> bool:
|
|
32
|
+
if path.startswith("<"):
|
|
33
|
+
return True
|
|
34
|
+
p = _real(path)
|
|
35
|
+
return p.startswith(_TORCH_DIR) or p.startswith(_OWN_DIR) or p.startswith(_STDLIB) and not any(
|
|
36
|
+
p.startswith(d) for d in _LIB_DIRS
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_lib(path: str) -> bool:
|
|
41
|
+
p = _real(path)
|
|
42
|
+
return any(p.startswith(d) for d in _LIB_DIRS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Where:
|
|
47
|
+
file: str
|
|
48
|
+
line: int
|
|
49
|
+
func: str
|
|
50
|
+
code: str
|
|
51
|
+
lib: bool = False # True if the line is inside an installed package
|
|
52
|
+
|
|
53
|
+
def short(self) -> str:
|
|
54
|
+
f = self.file
|
|
55
|
+
try:
|
|
56
|
+
rel = os.path.relpath(f)
|
|
57
|
+
if not rel.startswith(".."):
|
|
58
|
+
f = rel
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
return f"{f}:{self.line}"
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return {"file": self.file, "line": self.line, "function": self.func, "code": self.code, "library": self.lib}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# torch packages that copy, sync or update parameters as bookkeeping, not as part of the model
|
|
68
|
+
_INFRA = tuple(_TORCH_DIR + d + os.sep for d in (
|
|
69
|
+
"distributed", os.path.join("nn", "parallel"), "optim", os.path.join("nn", "utils"),
|
|
70
|
+
"amp", os.path.join("cuda", "amp")))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def torch_caller(skip: int = 2) -> str | None:
|
|
74
|
+
"""'infra' or 'torch' when the nearest frame outside silentbug is PyTorch's own code."""
|
|
75
|
+
f = sys._getframe(skip)
|
|
76
|
+
while f is not None:
|
|
77
|
+
p = _real(f.f_code.co_filename)
|
|
78
|
+
if not p.startswith(_OWN_DIR):
|
|
79
|
+
if p.startswith(_INFRA) or p.startswith(_TORCH_DIR + "_tensor_str"):
|
|
80
|
+
return "infra"
|
|
81
|
+
return "torch" if p.startswith(_TORCH_DIR) else None
|
|
82
|
+
f = f.f_back
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def where(skip: int = 2) -> tuple[Where | None, Where | None]:
|
|
87
|
+
"""Return (first frame outside torch, first frame outside installed packages)."""
|
|
88
|
+
f = sys._getframe(skip)
|
|
89
|
+
first = None
|
|
90
|
+
while f is not None:
|
|
91
|
+
path = f.f_code.co_filename
|
|
92
|
+
if not _skip(path):
|
|
93
|
+
lib = _is_lib(path)
|
|
94
|
+
w = Where(path, f.f_lineno, f.f_code.co_name, linecache.getline(path, f.f_lineno).strip(), lib)
|
|
95
|
+
if first is None:
|
|
96
|
+
first = w
|
|
97
|
+
if not lib:
|
|
98
|
+
return first, (w if w != first else None)
|
|
99
|
+
f = f.f_back
|
|
100
|
+
return first, None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command line: `silentbug run train.py [args...]`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import runpy
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .session import Session, _Done
|
|
11
|
+
|
|
12
|
+
USAGE = """silentbug run [--steps N] [--keep-going] [--json FILE] (script.py | -m module) [args...]
|
|
13
|
+
|
|
14
|
+
Runs your training script with silentbug watching the first N optimizer steps
|
|
15
|
+
(default 3), prints what it found, and stops the script. Exit code: 0 nothing
|
|
16
|
+
found, 1 likely bugs found, 2 only things to check."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse(argv: list[str]):
|
|
20
|
+
ap = argparse.ArgumentParser(prog="silentbug run", usage=USAGE)
|
|
21
|
+
ap.add_argument("--steps", type=int, default=3)
|
|
22
|
+
ap.add_argument("--keep-going", action="store_true", help="let the script keep running after the report")
|
|
23
|
+
ap.add_argument("--json", metavar="FILE", help="also write the report as JSON ('-' for stdout only JSON)")
|
|
24
|
+
ap.add_argument("-m", dest="module", action="store_true", help="run a module, like python -m")
|
|
25
|
+
ap.add_argument("target")
|
|
26
|
+
ap.add_argument("args", nargs=argparse.REMAINDER)
|
|
27
|
+
return ap.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(argv: list[str]) -> int:
|
|
31
|
+
a = _parse(argv)
|
|
32
|
+
s = Session(max_steps=a.steps, stop_at_limit=not a.keep_going)
|
|
33
|
+
old_argv = sys.argv[:]
|
|
34
|
+
sys.argv = [a.target, *a.args]
|
|
35
|
+
if not a.module:
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(a.target)))
|
|
37
|
+
error = None
|
|
38
|
+
s.start()
|
|
39
|
+
try:
|
|
40
|
+
if a.module:
|
|
41
|
+
runpy.run_module(a.target, run_name="__main__", alter_sys=True)
|
|
42
|
+
else:
|
|
43
|
+
runpy.run_path(a.target, run_name="__main__")
|
|
44
|
+
except _Done:
|
|
45
|
+
pass
|
|
46
|
+
except SystemExit:
|
|
47
|
+
pass
|
|
48
|
+
except BaseException as e: # the script crashed: still report what we saw
|
|
49
|
+
error = e
|
|
50
|
+
finally:
|
|
51
|
+
sys.argv = old_argv
|
|
52
|
+
rep = s.stop()
|
|
53
|
+
rank = os.environ.get("RANK", "0") # under torchrun every rank sees the same code
|
|
54
|
+
if rank != "0":
|
|
55
|
+
pass
|
|
56
|
+
elif a.json == "-":
|
|
57
|
+
print(rep.to_json())
|
|
58
|
+
else:
|
|
59
|
+
print(rep, file=sys.stderr if a.keep_going else sys.stdout)
|
|
60
|
+
if a.json:
|
|
61
|
+
with open(a.json, "w") as f:
|
|
62
|
+
f.write(rep.to_json())
|
|
63
|
+
if error is not None:
|
|
64
|
+
print(f"\n(your script raised {type(error).__name__}: {error})", file=sys.stderr)
|
|
65
|
+
return 1 if rep.bugs else (2 if rep.findings else 0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
70
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
71
|
+
print(USAGE)
|
|
72
|
+
return 0
|
|
73
|
+
if argv[0] == "run":
|
|
74
|
+
return run(argv[1:])
|
|
75
|
+
if argv[0] in ("--version", "version"):
|
|
76
|
+
from . import __version__
|
|
77
|
+
print(__version__)
|
|
78
|
+
return 0
|
|
79
|
+
print(f"unknown command {argv[0]!r}\n\n{USAGE}", file=sys.stderr)
|
|
80
|
+
return 64
|