train-doctor 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.
Files changed (40) hide show
  1. train_doctor-0.1.0/.gitignore +11 -0
  2. train_doctor-0.1.0/CHANGELOG.md +13 -0
  3. train_doctor-0.1.0/LICENSE +21 -0
  4. train_doctor-0.1.0/PKG-INFO +163 -0
  5. train_doctor-0.1.0/README.md +144 -0
  6. train_doctor-0.1.0/examples/README.md +57 -0
  7. train_doctor-0.1.0/examples/cnn_images/train.py +165 -0
  8. train_doctor-0.1.0/examples/run_validation.sh +30 -0
  9. train_doctor-0.1.0/examples/tabular_mlp/train.py +195 -0
  10. train_doctor-0.1.0/pyproject.toml +57 -0
  11. train_doctor-0.1.0/skills/train-doctor/SKILL.md +82 -0
  12. train_doctor-0.1.0/src/train_doctor/__init__.py +36 -0
  13. train_doctor-0.1.0/src/train_doctor/__main__.py +3 -0
  14. train_doctor-0.1.0/src/train_doctor/_hook/sitecustomize.py +47 -0
  15. train_doctor-0.1.0/src/train_doctor/_hook/td_hook.py +922 -0
  16. train_doctor-0.1.0/src/train_doctor/accel.py +107 -0
  17. train_doctor-0.1.0/src/train_doctor/api.py +335 -0
  18. train_doctor-0.1.0/src/train_doctor/cli.py +247 -0
  19. train_doctor-0.1.0/src/train_doctor/evidence.py +421 -0
  20. train_doctor-0.1.0/src/train_doctor/lossdiff.py +106 -0
  21. train_doctor-0.1.0/src/train_doctor/machine.py +91 -0
  22. train_doctor-0.1.0/src/train_doctor/mcp_server.py +159 -0
  23. train_doctor-0.1.0/src/train_doctor/redact.py +22 -0
  24. train_doctor-0.1.0/src/train_doctor/report.py +319 -0
  25. train_doctor-0.1.0/src/train_doctor/rules.py +496 -0
  26. train_doctor-0.1.0/src/train_doctor/runner.py +293 -0
  27. train_doctor-0.1.0/src/train_doctor/setup_agents.py +173 -0
  28. train_doctor-0.1.0/src/train_doctor/stats.py +165 -0
  29. train_doctor-0.1.0/tests/conftest.py +43 -0
  30. train_doctor-0.1.0/tests/test_accel.py +27 -0
  31. train_doctor-0.1.0/tests/test_evidence.py +160 -0
  32. train_doctor-0.1.0/tests/test_hook_fake_cuda.py +96 -0
  33. train_doctor-0.1.0/tests/test_lossdiff.py +38 -0
  34. train_doctor-0.1.0/tests/test_mcp.py +36 -0
  35. train_doctor-0.1.0/tests/test_rules.py +195 -0
  36. train_doctor-0.1.0/tests/test_runs.py +181 -0
  37. train_doctor-0.1.0/tests/test_setup.py +80 -0
  38. train_doctor-0.1.0/tests/test_stats.py +58 -0
  39. train_doctor-0.1.0/tests/test_torch_hook.py +79 -0
  40. train_doctor-0.1.0/tests/test_writing.py +19 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .train-doctor/
11
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First version.
6
+
7
+ - `profile`: runs a training command for a bounded window (warmup plus at least N steps and T seconds) with an in-process recorder injected through `sitecustomize`. Records step times, DataLoader wait, host/device sync calls, `torch.save` and log-write time, samples per step, loss, DataLoader and precision config, process CPU and memory, and GPU telemetry (nvidia-smi, or ioreg on Apple silicon). Optional torch.profiler steps after the window and py-spy sampling.
8
+ - `diagnose`: deterministic rules for DataLoader-bound runs, expensive per-sample preprocessing, pin_memory on CUDA, host syncs in the loop, small batches, missing mixed precision, missing torch.compile, checkpoints and logging in the hot loop, gradient accumulation (including DDP without no_sync), cuDNN autotuning, and CPU training with an accelerator available.
9
+ - `compare`: discarded warmup runs, K repeats per arm in randomized pair order, same seed, median and spread, bootstrap interval on the ratio of medians, a verdict that refuses to call changes inside the noise, and a loss-trajectory check.
10
+ - `report`: Markdown and JSON bundles with machine, versions, commands, per-repeat numbers, findings, verdict and limits.
11
+ - `setup`: registers the MCP server with Claude Code, Codex and Cursor and installs the skill, with a dry run, backups and idempotent re-runs.
12
+ - `--label` names run directories; unstartable commands give a clear error instead of a traceback.
13
+ - MCP server over stdio with `profile`, `diagnose`, `compare`, `report` and `version` tools.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abel Yagubyan
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.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: train-doctor
3
+ Version: 0.1.0
4
+ Summary: Give your agent a way to find out why a training run is slow, fix it, and prove the speedup.
5
+ Project-URL: Homepage, https://github.com/Abelo9996/train-doctor
6
+ Author: Abel Yagubyan
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,benchmark,mcp,profiling,pytorch,training
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: mcp>=1.2
17
+ Requires-Dist: psutil>=5.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # train-doctor
21
+
22
+ Give your agent a way to find out why your training run is slow, fix it, and prove the speedup.
23
+
24
+ ```bash
25
+ uvx train-doctor profile -- python train.py # where does step time go?
26
+ uvx train-doctor compare --baseline "python train.py" --candidate "python train.py --num-workers 4"
27
+ uvx train-doctor setup # give Claude Code, Codex and Cursor the MCP tools
28
+ ```
29
+
30
+ > Not on PyPI yet. Until the first release, run it straight from GitHub by replacing `uvx train-doctor` with
31
+ > `uvx --from git+https://github.com/Abelo9996/train-doctor train-doctor`. `setup` registers `uvx train-doctor mcp`, so it works once the
32
+ > package is on PyPI.
33
+
34
+ ## Example output
35
+
36
+ From a real run in this repo: [examples/cnn_images](examples/cnn_images), a small CNN written the way many
37
+ first scripts are (per-sample augmentation in `__getitem__`, `num_workers=0`, `.item()` and a print on every
38
+ step). Apple M4 (10 cores, 16 GB), torch 2.14.1 on MPS, with other jobs running on the machine (1-minute
39
+ load average between 4.6 and 10.4 across all runs).
40
+
41
+ Profile output (trimmed where marked `...`):
42
+
43
+ ```text
44
+ $ train-doctor profile -- python examples/cnn_images/train.py
45
+ source: hook, device: mps, steps measured: 87
46
+ step time: median 20.88 ms (p10 14.32, p90 32.84)
47
+ throughput: 2766.8 samples/s
48
+ split: data_wait 39.1%, host_sync 26.4%, checkpoint_save 0.0%, log_write 0.1%, other 34.4%
49
+ findings:
50
+ 1. [dataloader_bound] Training waits on the DataLoader (num_workers=0) (score 0.391, risk low)
51
+ 2. [cpu_bound_preprocessing] Per-sample preprocessing in SyntheticImages is expensive (score 0.319, risk low)
52
+ evidence: 39% of step time is data wait and 91% of that is inside SyntheticImages.__getitem__ (0.13 ms per sample).
53
+ 3. [host_sync_in_loop] Host waits on the device inside every step (score 0.132, risk none)
54
+ evidence: 2.0 blocking reads of mps tensors per step (...), 6.1 ms per step blocked in them (26% of step time).
55
+ 4. [no_mixed_precision] ... 5. [logging_in_hot_loop] ... 6. [no_torch_compile] ...
56
+ ```
57
+
58
+ Then one change at a time, each checked with `train-doctor compare` (5 measured repeats per side after one
59
+ discarded warmup run, randomized order, 95% bootstrap interval on the ratio of median samples/s):
60
+
61
+ | Change | Median samples/s, before to after | Ratio (95% interval) | Loss check | Decision |
62
+ |---|---|---|---|---|
63
+ | `num_workers=5` | 2,249 to 2,750 | 1.22x (0.66 to 1.68) | within tolerance | inconclusive, dropped |
64
+ | crop/flip/normalize per batch on the device | 2,232 to 2,855 | 1.28x (1.10 to 1.35) | within tolerance | keep |
65
+ | read metrics every 50 steps instead of every step | 2,420 to 3,029 | 1.25x (1.11 to 1.35) | identical | keep |
66
+ | autocast float16 | 2,648 to 2,842 | 1.07x (0.81 to 1.28) | within tolerance | inconclusive, dropped |
67
+ | original vs the two kept changes | 2,091 to 3,381 | 1.62x (1.37 to 3.48) | within tolerance | keep |
68
+
69
+ Two of the four candidates looked faster by their medians and were still not kept, because their intervals
70
+ included 1.0. In the second example ([examples/tabular_mlp](examples/tabular_mlp)), checkpointing less often
71
+ was inconclusive with 5 repeats (1.54x, interval 0.62 to 2.67) and clearly faster with 9 (2.01x, 1.54 to 2.52);
72
+ original vs final there is 3.05x (2.15 to 3.40) with an identical loss trajectory. Every report, per-repeat
73
+ number and raw trace is committed under `examples/*/runs/`, and
74
+ [examples/run_validation.sh](examples/run_validation.sh) is the exact command sequence.
75
+
76
+ ## How it works
77
+
78
+ A "2x faster" claim is only useful if you can trust it. train-doctor excludes warmup, brackets the
79
+ measured window with a device synchronize, runs baseline and candidate several times in randomized
80
+ interleaved order with the same seed, reports medians, spread and a bootstrap interval, and calls a
81
+ change "faster" only when the whole interval clears a threshold. It also checks that the loss
82
+ trajectory didn't move, so a speedup that broke training is rejected.
83
+
84
+ train-doctor runs your real training command as a child process and stops it after a bounded window
85
+ (warmup steps, then at least N steps and T seconds; defaults 5, 50 and 2 s). No code changes are needed
86
+ for PyTorch:
87
+
88
+ - **Step recorder.** A `sitecustomize.py` on the child's PYTHONPATH loads a small standard-library
89
+ recorder that patches torch right after it is imported. It marks a step at every `optimizer.step()`
90
+ (a global post-hook), times `DataLoader.__next__`, times blocking reads of GPU/MPS tensors
91
+ (`.item()`, `.cpu()`, `.tolist()`, `float()`, `bool()`, `synchronize()`), times `torch.save` and
92
+ stdout/stderr writes, counts `backward()` calls, and keeps the loss tensor passed to `backward()`
93
+ (converted to a number only at the end, so recording it adds no sync). It also notes DataLoader
94
+ settings, autocast, GradScaler, `torch.compile`, DDP, parameter count and dtype. Any other
95
+ `sitecustomize` you have still runs. Loops without an optimizer (or other frameworks) can call
96
+ `train_doctor.step(samples=..., loss=...)`, or just print a line containing `loss` each step.
97
+ - **torch.profiler** traces a few extra steps after the measured window (so it doesn't distort the
98
+ timings) and saves a Chrome trace you can open in Perfetto.
99
+ - **py-spy** (optional, `--py-spy`) samples Python stacks for any framework when it is installed.
100
+ - **System telemetry**: psutil for CPU and memory of the whole process tree (including DataLoader
101
+ workers), `nvidia-smi` for NVIDIA GPUs, and `ioreg` for Apple GPU utilization (no sudo needed).
102
+ - **Diagnosis** is a fixed set of rules over that evidence: DataLoader-bound, expensive per-sample
103
+ preprocessing, pin_memory off on CUDA, host syncs in the loop, small batch on an idle device, no
104
+ mixed precision on capable hardware, no torch.compile, checkpoints or logging in the hot loop,
105
+ gradient accumulation (including DDP without `no_sync()`), cuDNN autotuning off, and training on CPU
106
+ while an accelerator is available. Each finding carries the numbers it fired on, a suggested change,
107
+ the expected effect (an upper bound where one can be computed) and the risk to results.
108
+ - **Compare** runs one discarded warmup run per command, then K repeats per command (default 5) in
109
+ randomized pair order. Throughput is samples/s over the synchronized window. The verdict uses a 95%
110
+ percentile bootstrap interval on the ratio of medians: "faster" only if the whole interval is above
111
+ 1 + min-effect (default 2%), "slower" if it's below 1 - min-effect, otherwise "no clear difference".
112
+ The loss check aligns both loss curves on samples seen, smooths them, and requires the mean and final
113
+ relative difference to stay within a tolerance (default 5%). The decision is `keep` only when both pass.
114
+ - **Reports**: every run directory gets `report.md` and `report.json` with the machine, versions,
115
+ commands, file hashes, git commit, per-repeat numbers, findings, verdict and limits, plus the raw
116
+ files (per-step events, resource samples, stdout and stderr, profiler output).
117
+
118
+ The tool is deterministic and never calls an LLM. Your coding agent reads the findings, makes one
119
+ change, and uses `compare` to decide whether to keep it.
120
+
121
+ ## Setup for agents
122
+
123
+ ```bash
124
+ uvx train-doctor setup # shows what it would change
125
+ uvx train-doctor setup --yes # applies it
126
+ ```
127
+
128
+ `setup` detects Claude Code, Codex and Cursor. It runs `claude mcp add --scope user train-doctor -- uvx train-doctor mcp`,
129
+ adds `[mcp_servers.train-doctor]` to `~/.codex/config.toml`, adds the server to `~/.cursor/mcp.json`, and copies the
130
+ skill to `~/.claude/skills/train-doctor/` and `~/.codex/skills/train-doctor/`. It backs up every file it edits and
131
+ does nothing when an entry is already there. `--project DIR` also writes a project `.mcp.json`.
132
+
133
+ MCP tools: `profile`, `diagnose`, `compare`, `report`, `version`. The skill
134
+ ([skills/train-doctor/SKILL.md](skills/train-doctor/SKILL.md)) tells the agent the workflow: profile, read
135
+ the findings, change one thing, compare, keep only clear wins that don't move the loss, report.
136
+
137
+ CLI: `train-doctor profile|diagnose|compare|report|setup|mcp`, each with `--help` and `--json`.
138
+
139
+ ## What it can't do
140
+
141
+ - It doesn't edit your code. The agent (or you) makes the change; train-doctor measures it.
142
+ - Timings come from the host side. On CUDA and MPS, time inside a sync call includes waiting for device
143
+ work that has to happen anyway, so the "host sync" share is an upper bound on what removing syncs saves.
144
+ - Apple GPU utilization from `ioreg` is system-wide, not per process. There is no per-kernel timing on MPS.
145
+ - `__getitem__` time is measured only with `num_workers=0`; with workers it runs in other processes.
146
+ - py-spy needs root on macOS, so `--py-spy` usually fails there. It was only tested with a fake py-spy.
147
+ - CUDA support (nvidia-smi parsing, CUDA sync and memory paths, CUDA-specific rules) is implemented and
148
+ tested with fakes. It has not been run on an NVIDIA GPU yet. The real runs in this repo are on Apple MPS.
149
+ - Multi-GPU: with DDP it reads the process that recorded the most steps (usually rank 0); it doesn't merge ranks.
150
+ - The loss check covers the first steps only. It catches changes that break or shift training early; it
151
+ can't tell you about final accuracy. Batch size and learning-rate changes are flagged as high risk for that reason.
152
+ - Short windows on a busy machine are noisy. Reports record the load average and system CPU per run; when
153
+ the spread is high, compare says so and is more likely to answer "no clear difference".
154
+
155
+ ## Privacy and safety
156
+
157
+ Everything runs locally. train-doctor makes no network requests and sends nothing anywhere. It runs the
158
+ command you give it with your permissions, and writes to `.train-doctor/runs/` (or `--out`). Reports
159
+ replace your home directory with `~`. `setup` changes only the files it lists, and only after `--yes`.
160
+
161
+ ## License
162
+
163
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,144 @@
1
+ # train-doctor
2
+
3
+ Give your agent a way to find out why your training run is slow, fix it, and prove the speedup.
4
+
5
+ ```bash
6
+ uvx train-doctor profile -- python train.py # where does step time go?
7
+ uvx train-doctor compare --baseline "python train.py" --candidate "python train.py --num-workers 4"
8
+ uvx train-doctor setup # give Claude Code, Codex and Cursor the MCP tools
9
+ ```
10
+
11
+ > Not on PyPI yet. Until the first release, run it straight from GitHub by replacing `uvx train-doctor` with
12
+ > `uvx --from git+https://github.com/Abelo9996/train-doctor train-doctor`. `setup` registers `uvx train-doctor mcp`, so it works once the
13
+ > package is on PyPI.
14
+
15
+ ## Example output
16
+
17
+ From a real run in this repo: [examples/cnn_images](examples/cnn_images), a small CNN written the way many
18
+ first scripts are (per-sample augmentation in `__getitem__`, `num_workers=0`, `.item()` and a print on every
19
+ step). Apple M4 (10 cores, 16 GB), torch 2.14.1 on MPS, with other jobs running on the machine (1-minute
20
+ load average between 4.6 and 10.4 across all runs).
21
+
22
+ Profile output (trimmed where marked `...`):
23
+
24
+ ```text
25
+ $ train-doctor profile -- python examples/cnn_images/train.py
26
+ source: hook, device: mps, steps measured: 87
27
+ step time: median 20.88 ms (p10 14.32, p90 32.84)
28
+ throughput: 2766.8 samples/s
29
+ split: data_wait 39.1%, host_sync 26.4%, checkpoint_save 0.0%, log_write 0.1%, other 34.4%
30
+ findings:
31
+ 1. [dataloader_bound] Training waits on the DataLoader (num_workers=0) (score 0.391, risk low)
32
+ 2. [cpu_bound_preprocessing] Per-sample preprocessing in SyntheticImages is expensive (score 0.319, risk low)
33
+ evidence: 39% of step time is data wait and 91% of that is inside SyntheticImages.__getitem__ (0.13 ms per sample).
34
+ 3. [host_sync_in_loop] Host waits on the device inside every step (score 0.132, risk none)
35
+ evidence: 2.0 blocking reads of mps tensors per step (...), 6.1 ms per step blocked in them (26% of step time).
36
+ 4. [no_mixed_precision] ... 5. [logging_in_hot_loop] ... 6. [no_torch_compile] ...
37
+ ```
38
+
39
+ Then one change at a time, each checked with `train-doctor compare` (5 measured repeats per side after one
40
+ discarded warmup run, randomized order, 95% bootstrap interval on the ratio of median samples/s):
41
+
42
+ | Change | Median samples/s, before to after | Ratio (95% interval) | Loss check | Decision |
43
+ |---|---|---|---|---|
44
+ | `num_workers=5` | 2,249 to 2,750 | 1.22x (0.66 to 1.68) | within tolerance | inconclusive, dropped |
45
+ | crop/flip/normalize per batch on the device | 2,232 to 2,855 | 1.28x (1.10 to 1.35) | within tolerance | keep |
46
+ | read metrics every 50 steps instead of every step | 2,420 to 3,029 | 1.25x (1.11 to 1.35) | identical | keep |
47
+ | autocast float16 | 2,648 to 2,842 | 1.07x (0.81 to 1.28) | within tolerance | inconclusive, dropped |
48
+ | original vs the two kept changes | 2,091 to 3,381 | 1.62x (1.37 to 3.48) | within tolerance | keep |
49
+
50
+ Two of the four candidates looked faster by their medians and were still not kept, because their intervals
51
+ included 1.0. In the second example ([examples/tabular_mlp](examples/tabular_mlp)), checkpointing less often
52
+ was inconclusive with 5 repeats (1.54x, interval 0.62 to 2.67) and clearly faster with 9 (2.01x, 1.54 to 2.52);
53
+ original vs final there is 3.05x (2.15 to 3.40) with an identical loss trajectory. Every report, per-repeat
54
+ number and raw trace is committed under `examples/*/runs/`, and
55
+ [examples/run_validation.sh](examples/run_validation.sh) is the exact command sequence.
56
+
57
+ ## How it works
58
+
59
+ A "2x faster" claim is only useful if you can trust it. train-doctor excludes warmup, brackets the
60
+ measured window with a device synchronize, runs baseline and candidate several times in randomized
61
+ interleaved order with the same seed, reports medians, spread and a bootstrap interval, and calls a
62
+ change "faster" only when the whole interval clears a threshold. It also checks that the loss
63
+ trajectory didn't move, so a speedup that broke training is rejected.
64
+
65
+ train-doctor runs your real training command as a child process and stops it after a bounded window
66
+ (warmup steps, then at least N steps and T seconds; defaults 5, 50 and 2 s). No code changes are needed
67
+ for PyTorch:
68
+
69
+ - **Step recorder.** A `sitecustomize.py` on the child's PYTHONPATH loads a small standard-library
70
+ recorder that patches torch right after it is imported. It marks a step at every `optimizer.step()`
71
+ (a global post-hook), times `DataLoader.__next__`, times blocking reads of GPU/MPS tensors
72
+ (`.item()`, `.cpu()`, `.tolist()`, `float()`, `bool()`, `synchronize()`), times `torch.save` and
73
+ stdout/stderr writes, counts `backward()` calls, and keeps the loss tensor passed to `backward()`
74
+ (converted to a number only at the end, so recording it adds no sync). It also notes DataLoader
75
+ settings, autocast, GradScaler, `torch.compile`, DDP, parameter count and dtype. Any other
76
+ `sitecustomize` you have still runs. Loops without an optimizer (or other frameworks) can call
77
+ `train_doctor.step(samples=..., loss=...)`, or just print a line containing `loss` each step.
78
+ - **torch.profiler** traces a few extra steps after the measured window (so it doesn't distort the
79
+ timings) and saves a Chrome trace you can open in Perfetto.
80
+ - **py-spy** (optional, `--py-spy`) samples Python stacks for any framework when it is installed.
81
+ - **System telemetry**: psutil for CPU and memory of the whole process tree (including DataLoader
82
+ workers), `nvidia-smi` for NVIDIA GPUs, and `ioreg` for Apple GPU utilization (no sudo needed).
83
+ - **Diagnosis** is a fixed set of rules over that evidence: DataLoader-bound, expensive per-sample
84
+ preprocessing, pin_memory off on CUDA, host syncs in the loop, small batch on an idle device, no
85
+ mixed precision on capable hardware, no torch.compile, checkpoints or logging in the hot loop,
86
+ gradient accumulation (including DDP without `no_sync()`), cuDNN autotuning off, and training on CPU
87
+ while an accelerator is available. Each finding carries the numbers it fired on, a suggested change,
88
+ the expected effect (an upper bound where one can be computed) and the risk to results.
89
+ - **Compare** runs one discarded warmup run per command, then K repeats per command (default 5) in
90
+ randomized pair order. Throughput is samples/s over the synchronized window. The verdict uses a 95%
91
+ percentile bootstrap interval on the ratio of medians: "faster" only if the whole interval is above
92
+ 1 + min-effect (default 2%), "slower" if it's below 1 - min-effect, otherwise "no clear difference".
93
+ The loss check aligns both loss curves on samples seen, smooths them, and requires the mean and final
94
+ relative difference to stay within a tolerance (default 5%). The decision is `keep` only when both pass.
95
+ - **Reports**: every run directory gets `report.md` and `report.json` with the machine, versions,
96
+ commands, file hashes, git commit, per-repeat numbers, findings, verdict and limits, plus the raw
97
+ files (per-step events, resource samples, stdout and stderr, profiler output).
98
+
99
+ The tool is deterministic and never calls an LLM. Your coding agent reads the findings, makes one
100
+ change, and uses `compare` to decide whether to keep it.
101
+
102
+ ## Setup for agents
103
+
104
+ ```bash
105
+ uvx train-doctor setup # shows what it would change
106
+ uvx train-doctor setup --yes # applies it
107
+ ```
108
+
109
+ `setup` detects Claude Code, Codex and Cursor. It runs `claude mcp add --scope user train-doctor -- uvx train-doctor mcp`,
110
+ adds `[mcp_servers.train-doctor]` to `~/.codex/config.toml`, adds the server to `~/.cursor/mcp.json`, and copies the
111
+ skill to `~/.claude/skills/train-doctor/` and `~/.codex/skills/train-doctor/`. It backs up every file it edits and
112
+ does nothing when an entry is already there. `--project DIR` also writes a project `.mcp.json`.
113
+
114
+ MCP tools: `profile`, `diagnose`, `compare`, `report`, `version`. The skill
115
+ ([skills/train-doctor/SKILL.md](skills/train-doctor/SKILL.md)) tells the agent the workflow: profile, read
116
+ the findings, change one thing, compare, keep only clear wins that don't move the loss, report.
117
+
118
+ CLI: `train-doctor profile|diagnose|compare|report|setup|mcp`, each with `--help` and `--json`.
119
+
120
+ ## What it can't do
121
+
122
+ - It doesn't edit your code. The agent (or you) makes the change; train-doctor measures it.
123
+ - Timings come from the host side. On CUDA and MPS, time inside a sync call includes waiting for device
124
+ work that has to happen anyway, so the "host sync" share is an upper bound on what removing syncs saves.
125
+ - Apple GPU utilization from `ioreg` is system-wide, not per process. There is no per-kernel timing on MPS.
126
+ - `__getitem__` time is measured only with `num_workers=0`; with workers it runs in other processes.
127
+ - py-spy needs root on macOS, so `--py-spy` usually fails there. It was only tested with a fake py-spy.
128
+ - CUDA support (nvidia-smi parsing, CUDA sync and memory paths, CUDA-specific rules) is implemented and
129
+ tested with fakes. It has not been run on an NVIDIA GPU yet. The real runs in this repo are on Apple MPS.
130
+ - Multi-GPU: with DDP it reads the process that recorded the most steps (usually rank 0); it doesn't merge ranks.
131
+ - The loss check covers the first steps only. It catches changes that break or shift training early; it
132
+ can't tell you about final accuracy. Batch size and learning-rate changes are flagged as high risk for that reason.
133
+ - Short windows on a busy machine are noisy. Reports record the load average and system CPU per run; when
134
+ the spread is high, compare says so and is more likely to answer "no clear difference".
135
+
136
+ ## Privacy and safety
137
+
138
+ Everything runs locally. train-doctor makes no network requests and sends nothing anywhere. It runs the
139
+ command you give it with your permissions, and writes to `.train-doctor/runs/` (or `--out`). Reports
140
+ replace your home directory with `~`. `setup` changes only the files it lists, and only after `--yes`.
141
+
142
+ ## License
143
+
144
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,57 @@
1
+ # Examples
2
+
3
+ Two small PyTorch training scripts with realistic slow spots, and the real train-doctor runs on them.
4
+ Every directory under `*/runs/` is untouched tool output: `report.md` for reading, `report.json` and
5
+ `compare.json` for the numbers, and the raw per-step events, resource samples, logs and profiler output.
6
+
7
+ Machine: Apple M4 MacBook (10 cores, 16 GB), macOS 26 (Darwin 25.2), Python 3.12.13, torch 2.14.1, device MPS.
8
+ Other jobs were running on the machine during these runs (1-minute load average 4.6 to 10.4 at the start
9
+ of each run); the per-repeat tables in the compare reports list the load and system CPU of every run.
10
+
11
+ Each command-line flag on the scripts stands in for one code edit, so a single change can be compared
12
+ on its own. [run_validation.sh](run_validation.sh) is the exact sequence of commands.
13
+
14
+ ## cnn_images
15
+
16
+ A 3-layer CNN on 20,000 synthetic 32x32 images: per-sample crop, flip and normalize in `__getitem__`,
17
+ `num_workers=0`, and loss and accuracy read with `.item()` and printed every step.
18
+
19
+ | Run | What happened |
20
+ |---|---|
21
+ | `profile-baseline` | 39.1% of step time waiting on the DataLoader (91% of that in `__getitem__`), 26.4% blocked in 2 `.item()` calls per step. Top finding: `dataloader_bound`. |
22
+ | `compare-workers-5` | `--num-workers 5`: 1.22x by medians but the interval is 0.66 to 1.68, spread up to 33%. Inconclusive, dropped. |
23
+ | `compare-batch-augment` | Next finding, `cpu_bound_preprocessing`: augmentation moved to one vectorized call per batch on the device. 1.28x (1.10 to 1.35), loss within tolerance (1.7% mean difference). Kept. |
24
+ | `profile-batch-augment` | Data wait down to 6.0%; host sync now 37.1% of step time. Top finding: `host_sync_in_loop`. |
25
+ | `compare-log-every-50` | Metrics kept on the device and read every 50 steps. 1.25x (1.11 to 1.35), loss identical. Kept. |
26
+ | `profile-log-every-50` | Host sync 0.2%, compute and Python 95.8%. Top finding: `no_mixed_precision`. |
27
+ | `compare-amp` | `--amp` (autocast float16 on MPS): 1.07x (0.81 to 1.28). Inconclusive, dropped. |
28
+ | `compare-original-vs-final` | Original vs `--batch-augment --log-every 50`: 1.62x (1.37 to 3.48), loss within tolerance. |
29
+
30
+ ## tabular_mlp
31
+
32
+ A 4-layer MLP on 60,000 synthetic CSV-like rows, featurized in plain Python inside `__getitem__`
33
+ (parse, normalize, one-hot, hashed text buckets), with a `torch.save` checkpoint every 5 steps.
34
+
35
+ | Run | What happened |
36
+ |---|---|
37
+ | `profile-baseline` | 52.8% of step time in `torch.save`, 28.2% waiting on the DataLoader. Top finding: `checkpoint_in_hot_loop`. |
38
+ | `compare-ckpt-every-200` | Checkpoint every 200 steps: 1.54x by medians, interval 0.62 to 2.67 (spread up to 35%). Inconclusive. |
39
+ | `compare-ckpt-every-200-9-repeats` | Same change with 9 repeats: 2.01x (1.54 to 2.52), loss identical. Kept. |
40
+ | `profile-ckpt-every-200` | Data wait 34.3% with `num_workers=0`. Top finding: `dataloader_bound`. |
41
+ | `compare-workers-5` | `--num-workers 5`: 1.93x (1.54 to 2.86), loss identical. Kept. The candidate needs 6.1 s more to reach its first step (spawned workers import torch); the report states that separately. |
42
+ | `profile-workers-5` | Data wait 7.3%; the window caught one checkpoint (8.8%). Next findings: mixed precision, torch.compile. Stopped here. |
43
+ | `compare-original-vs-final` | Original vs `--ckpt-every 200 --num-workers 5`: 3.05x (2.15 to 3.40), loss identical. |
44
+
45
+ Throughput medians differ between compares of the same command (for example the CNN baseline at 2,091
46
+ to 2,249 samples/s) because each compare ran at a different time on a shared machine. That is why every
47
+ decision comes from baseline and candidate runs interleaved in the same compare, never from numbers
48
+ taken at different times.
49
+
50
+ ## Reproduce
51
+
52
+ ```bash
53
+ uv venv --python 3.12 && uv pip install -e . torch numpy
54
+ PATH="$PWD/.venv/bin:$PATH" sh examples/run_validation.sh
55
+ ```
56
+
57
+ Your numbers will differ on a different machine or under different load, and so can the decisions.
@@ -0,0 +1,165 @@
1
+ """Small CNN on a synthetic 10-class image dataset.
2
+
3
+ Written the way a lot of first training scripts are: per-sample augmentation
4
+ in __getitem__, num_workers=0, and the loss and accuracy read back with
5
+ .item() and printed on every step.
6
+
7
+ Each command-line flag stands in for one code edit, so train-doctor can
8
+ compare a single change at a time:
9
+
10
+ --num-workers N DataLoader workers (default 0)
11
+ --log-every N read metrics and print every N steps (default 1);
12
+ between logs, metrics stay on the device
13
+ --amp autocast to float16 on mps/cuda
14
+ --batch-augment do the crop, flip and normalize once per batch on the
15
+ device instead of per sample in __getitem__
16
+ --batch-size N default 64
17
+ """
18
+
19
+ import argparse
20
+ import time
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+ from torch.utils.data import DataLoader, Dataset
26
+
27
+ MEAN = torch.tensor([0.5, 0.5, 0.5]).view(3, 1, 1)
28
+ STD = torch.tensor([0.25, 0.25, 0.25]).view(3, 1, 1)
29
+
30
+
31
+ class SyntheticImages(Dataset):
32
+ """10 classes told apart by a faint colored square in noise. Stored as uint8 like decoded JPEGs."""
33
+
34
+ def __init__(self, n: int = 20000, seed: int = 0, augment: bool = True):
35
+ self.augment = augment
36
+ g = torch.Generator().manual_seed(seed)
37
+ self.labels = torch.randint(0, 10, (n,), generator=g)
38
+ base = torch.randint(0, 256, (n, 3, 32, 32), generator=g, dtype=torch.uint8)
39
+ # a faint class-colored square blended into the noise, plus 10% label noise
40
+ colors = torch.linspace(0, 255, 10)
41
+ for c in range(10):
42
+ idx = (self.labels == c).nonzero().squeeze(1)
43
+ patch = base[idx, c % 3, 8:24, 8:24].float()
44
+ base[idx, c % 3, 8:24, 8:24] = (0.75 * patch + 0.25 * colors[c]).to(torch.uint8)
45
+ flip = torch.rand(n, generator=g) < 0.1
46
+ self.labels[flip] = torch.randint(0, 10, (int(flip.sum()),), generator=g)
47
+ self.images = base
48
+
49
+ def __len__(self):
50
+ return len(self.labels)
51
+
52
+ def __getitem__(self, i):
53
+ if not self.augment:
54
+ return self.images[i], self.labels[i]
55
+ img = self.images[i].float() / 255.0
56
+ # random crop with 4 px padding
57
+ img = F.pad(img, (4, 4, 4, 4), mode="reflect")
58
+ x, y = torch.randint(0, 9, (2,)).tolist()
59
+ img = img[:, y : y + 32, x : x + 32]
60
+ if torch.rand(1).item() < 0.5:
61
+ img = torch.flip(img, dims=[2])
62
+ img = (img - MEAN) / STD
63
+ return img, self.labels[i]
64
+
65
+
66
+ def augment_batch(x: torch.Tensor) -> torch.Tensor:
67
+ """Same augmentation as SyntheticImages.__getitem__, vectorized over a uint8 batch on the device."""
68
+ b = x.shape[0]
69
+ x = F.pad(x.float() / 255.0, (4, 4, 4, 4), mode="reflect")
70
+ oy = torch.randint(0, 9, (b,), device=x.device)
71
+ ox = torch.randint(0, 9, (b,), device=x.device)
72
+ ar = torch.arange(32, device=x.device)
73
+ rows = (oy[:, None] + ar)[:, None, :, None].expand(b, 3, 32, 40)
74
+ x = torch.gather(x, 2, rows)
75
+ cols = (ox[:, None] + ar)[:, None, None, :].expand(b, 3, 32, 32)
76
+ x = torch.gather(x, 3, cols)
77
+ flip = torch.rand(b, device=x.device) < 0.5
78
+ x = torch.where(flip[:, None, None, None], x.flip(3), x)
79
+ return (x - MEAN.to(x.device)) / STD.to(x.device)
80
+
81
+
82
+ class Net(nn.Module):
83
+ def __init__(self):
84
+ super().__init__()
85
+ self.c1 = nn.Conv2d(3, 32, 3, padding=1)
86
+ self.c2 = nn.Conv2d(32, 64, 3, padding=1)
87
+ self.c3 = nn.Conv2d(64, 128, 3, padding=1)
88
+ self.bn1 = nn.BatchNorm2d(32)
89
+ self.bn2 = nn.BatchNorm2d(64)
90
+ self.bn3 = nn.BatchNorm2d(128)
91
+ self.fc = nn.Linear(128 * 4 * 4, 10)
92
+
93
+ def forward(self, x):
94
+ x = F.max_pool2d(F.relu(self.bn1(self.c1(x))), 2)
95
+ x = F.max_pool2d(F.relu(self.bn2(self.c2(x))), 2)
96
+ x = F.max_pool2d(F.relu(self.bn3(self.c3(x))), 2)
97
+ return self.fc(x.flatten(1))
98
+
99
+
100
+ def pick_device(name: str) -> torch.device:
101
+ if name != "auto":
102
+ return torch.device(name)
103
+ if torch.cuda.is_available():
104
+ return torch.device("cuda")
105
+ if torch.backends.mps.is_available():
106
+ return torch.device("mps")
107
+ return torch.device("cpu")
108
+
109
+
110
+ def main():
111
+ ap = argparse.ArgumentParser()
112
+ ap.add_argument("--device", default="auto")
113
+ ap.add_argument("--batch-size", type=int, default=64)
114
+ ap.add_argument("--num-workers", type=int, default=0)
115
+ ap.add_argument("--log-every", type=int, default=1)
116
+ ap.add_argument("--amp", action="store_true")
117
+ ap.add_argument("--batch-augment", action="store_true")
118
+ ap.add_argument("--max-steps", type=int, default=300)
119
+ ap.add_argument("--lr", type=float, default=0.002)
120
+ args = ap.parse_args()
121
+
122
+ torch.manual_seed(0)
123
+ device = pick_device(args.device)
124
+ ds = SyntheticImages(augment=not args.batch_augment)
125
+ loader = DataLoader(
126
+ ds,
127
+ batch_size=args.batch_size,
128
+ shuffle=True,
129
+ num_workers=args.num_workers,
130
+ persistent_workers=args.num_workers > 0,
131
+ drop_last=True,
132
+ )
133
+ model = Net().to(device)
134
+ opt = torch.optim.SGD(model.parameters(), lr=args.lr, momentum=0.9)
135
+
136
+ step = 0
137
+ run_loss = torch.zeros((), device=device)
138
+ run_correct = torch.zeros((), device=device)
139
+ t0 = time.time()
140
+ while step < args.max_steps:
141
+ for xb, yb in loader:
142
+ xb, yb = xb.to(device), yb.to(device)
143
+ if args.batch_augment:
144
+ xb = augment_batch(xb)
145
+ with torch.autocast(device.type, dtype=torch.float16, enabled=args.amp):
146
+ out = model(xb)
147
+ loss = F.cross_entropy(out, yb)
148
+ opt.zero_grad(set_to_none=True)
149
+ loss.backward()
150
+ opt.step()
151
+ step += 1
152
+ run_loss += loss.detach()
153
+ run_correct += (out.argmax(1) == yb).sum()
154
+ if step % args.log_every == 0:
155
+ n = args.log_every * args.batch_size
156
+ print(f"step {step} loss {run_loss.item() / args.log_every:.4f} acc {run_correct.item() / n:.3f}")
157
+ run_loss.zero_()
158
+ run_correct.zero_()
159
+ if step >= args.max_steps:
160
+ break
161
+ print(f"done: {step} steps in {time.time() - t0:.1f} s")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,30 @@
1
+ #!/bin/sh
2
+ # The exact sequence that produced examples/*/runs, in order.
3
+ # Run from the repo root with a venv that has torch and train-doctor installed:
4
+ # uv venv --python 3.12 && uv pip install -e . torch numpy
5
+ # PATH="$PWD/.venv/bin:$PATH" sh examples/run_validation.sh
6
+ # Each flag on the training scripts stands in for one code edit. Which edit
7
+ # came next was decided from the previous profile's top finding and the
8
+ # previous compare's decision (see examples/README.md).
9
+ set -e
10
+
11
+ CNN="python examples/cnn_images/train.py"
12
+ OUT=examples/cnn_images/runs
13
+ train-doctor profile --out $OUT --label baseline -- $CNN
14
+ train-doctor compare --out $OUT --label workers-5 --baseline "$CNN" --candidate "$CNN --num-workers 5"
15
+ train-doctor compare --seconds 4 --out $OUT --label batch-augment --baseline "$CNN" --candidate "$CNN --batch-augment"
16
+ train-doctor profile --out $OUT --label batch-augment -- $CNN --batch-augment
17
+ train-doctor compare --seconds 4 --out $OUT --label log-every-50 --baseline "$CNN --batch-augment" --candidate "$CNN --batch-augment --log-every 50"
18
+ train-doctor profile --out $OUT --label log-every-50 -- $CNN --batch-augment --log-every 50
19
+ train-doctor compare --seconds 4 --out $OUT --label amp --baseline "$CNN --batch-augment --log-every 50" --candidate "$CNN --batch-augment --log-every 50 --amp"
20
+ train-doctor compare --seconds 4 --out $OUT --label original-vs-final --baseline "$CNN" --candidate "$CNN --batch-augment --log-every 50"
21
+
22
+ MLP="python examples/tabular_mlp/train.py"
23
+ OUT=examples/tabular_mlp/runs
24
+ train-doctor profile --out $OUT --label baseline -- $MLP
25
+ train-doctor compare --seconds 4 --out $OUT --label ckpt-every-200 --baseline "$MLP" --candidate "$MLP --ckpt-every 200"
26
+ train-doctor compare --seconds 4 --repeats 9 --out $OUT --label ckpt-every-200-9-repeats --baseline "$MLP" --candidate "$MLP --ckpt-every 200"
27
+ train-doctor profile --out $OUT --label ckpt-every-200 -- $MLP --ckpt-every 200
28
+ train-doctor compare --seconds 4 --out $OUT --label workers-5 --baseline "$MLP --ckpt-every 200" --candidate "$MLP --ckpt-every 200 --num-workers 5"
29
+ train-doctor profile --out $OUT --label workers-5 -- $MLP --ckpt-every 200 --num-workers 5
30
+ train-doctor compare --seconds 4 --out $OUT --label original-vs-final --baseline "$MLP" --candidate "$MLP --ckpt-every 200 --num-workers 5"