token-xray-ml 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.
- token_xray_ml-0.1.0/LICENSE +21 -0
- token_xray_ml-0.1.0/PKG-INFO +197 -0
- token_xray_ml-0.1.0/README.md +172 -0
- token_xray_ml-0.1.0/pyproject.toml +37 -0
- token_xray_ml-0.1.0/setup.cfg +4 -0
- token_xray_ml-0.1.0/src/token_xray/__init__.py +6 -0
- token_xray_ml-0.1.0/src/token_xray/api.py +66 -0
- token_xray_ml-0.1.0/src/token_xray/cli.py +51 -0
- token_xray_ml-0.1.0/src/token_xray/ddp.py +148 -0
- token_xray_ml-0.1.0/src/token_xray/ddp_trainer_entry.py +79 -0
- token_xray_ml-0.1.0/src/token_xray/intended.py +162 -0
- token_xray_ml-0.1.0/src/token_xray/leak.py +75 -0
- token_xray_ml-0.1.0/src/token_xray/probe.py +95 -0
- token_xray_ml-0.1.0/src/token_xray/readout.py +213 -0
- token_xray_ml-0.1.0/src/token_xray/runners.py +202 -0
- token_xray_ml-0.1.0/src/token_xray/scenarios.py +378 -0
- token_xray_ml-0.1.0/src/token_xray/trl_runners.py +335 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/PKG-INFO +197 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/SOURCES.txt +25 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/dependency_links.txt +1 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/entry_points.txt +2 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/requires.txt +9 -0
- token_xray_ml-0.1.0/src/token_xray_ml.egg-info/top_level.txt +1 -0
- token_xray_ml-0.1.0/tests/test_ddp.py +137 -0
- token_xray_ml-0.1.0/tests/test_legacy.py +32 -0
- token_xray_ml-0.1.0/tests/test_trl.py +293 -0
- token_xray_ml-0.1.0/tests/test_xray.py +203 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaushik
|
|
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,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: token-xray-ml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what your training stack actually does to every token: measured per-token gradient weights and cross-sequence gradient leaks, read from the library's own training loop.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://lineage-ml.pages.dev/
|
|
7
|
+
Project-URL: Source, https://github.com/kaushikdhola/lineage-ml/tree/main/xray
|
|
8
|
+
Project-URL: Issues, https://github.com/kaushikdhola/lineage-ml/issues
|
|
9
|
+
Keywords: llm,training,gradient,debugging,transformers,trl,grpo
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10.2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: torch>=2.3
|
|
18
|
+
Requires-Dist: transformers>=4.46
|
|
19
|
+
Requires-Dist: numpy>=1.26
|
|
20
|
+
Provides-Extra: trl
|
|
21
|
+
Requires-Dist: trl<2,>=1.0; extra == "trl"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# token-xray
|
|
27
|
+
|
|
28
|
+
**Measure the gradient weight your loss gives every token.**
|
|
29
|
+
|
|
30
|
+
Many silent training bugs, and several objective design choices, show up in one quantity: the gradient
|
|
31
|
+
weight each token receives. Others leak gradient from one sequence into another. Token X-ray measures
|
|
32
|
+
both by running your batching and loss code on a tracer model, and compares them with what the equation
|
|
33
|
+
says.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from token_xray import xray
|
|
37
|
+
|
|
38
|
+
def my_step(model, sequences):
|
|
39
|
+
# batch `sequences` exactly as your trainer does, then forward + backward
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
|
|
43
|
+
report.assert_token_mean() # every target token received weight 1/N
|
|
44
|
+
print(report.table())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install "git+https://github.com/kaushikdhola/lineage-ml#subdirectory=xray"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The package is not on PyPI yet; this installs it from the repository (https://github.com/kaushikdhola/lineage-ml).
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
A tiny LLaMA "probe" gives every token in the batch a unique id. In aggregation mode the attention
|
|
58
|
+
output is zeroed and frozen, so a position's loss depends only on its own embedding. The gradient the
|
|
59
|
+
stack produces at token _i_ is then `w_i · r_i`, where `r_i` is the token's unit-weight gradient
|
|
60
|
+
(computed on an identical probe), and the readout recovers:
|
|
61
|
+
|
|
62
|
+
- `w_i = ⟨g_i, r_i⟩ / ‖r_i‖²` — the weight the stack applied (×N, so a global token mean is 1.0)
|
|
63
|
+
- `cos(g_i, r_i)` — below 1 when the stack changed the gradient in a way that is not a rescaling
|
|
64
|
+
|
|
65
|
+
In leak mode attention is live. One backward per sequence, with only that sequence labelled; any
|
|
66
|
+
gradient reaching another sequence is a leak. On a correct stack it is exactly zero.
|
|
67
|
+
|
|
68
|
+
When one input token predicts several targets (the prompt's last token in preference and RL batches), the
|
|
69
|
+
weights are solved for by least squares on the targets' unit gradients; the condition number and the
|
|
70
|
+
unexplained remainder are reported, and the readout refuses systems it cannot identify (at most h − 1
|
|
71
|
+
targets per input for hidden size h). Under data parallelism the readout uses the embedding's parameter
|
|
72
|
+
gradient after the all-reduce. Gradient that reaches padding is collected, not dropped.
|
|
73
|
+
|
|
74
|
+
### What it cannot see
|
|
75
|
+
|
|
76
|
+
- **After backward.** The readout sees how the loss weights tokens, before gradient clipping and the
|
|
77
|
+
optimizer. Bugs that live in the optimizer step, such as the DeepSpeed ZeRO-1/2 CPU-offload gradient copy
|
|
78
|
+
emulated below, are outside its view.
|
|
79
|
+
- **Model state.** The probe is untrained, so its predictions are near-uniform. Anything that depends on
|
|
80
|
+
the model's state (label smoothing's cosine, z-loss, DPO's margin, GRPO's ratios and clipping, the size
|
|
81
|
+
of a leak) is measured for this probe, not for a trained model. The equations it checks hold either way.
|
|
82
|
+
|
|
83
|
+
### What each real run changes from the defaults
|
|
84
|
+
|
|
85
|
+
- the sample order is fixed with a sequential sampler (shuffled order is tested separately);
|
|
86
|
+
- gradient clipping is off (`max_grad_norm=0`) and bf16 is off (TRL's configs default to bf16);
|
|
87
|
+
- GRPO's completions are scripted: `model.generate` returns fixed tracer sequences, each ending with its
|
|
88
|
+
own EOS id, and a fixed reward function scores them;
|
|
89
|
+
- the training step (or `compute_loss`) is wrapped only to record gradients and the batches seen.
|
|
90
|
+
|
|
91
|
+
## What is checked today
|
|
92
|
+
|
|
93
|
+
| Check | Runs | Result |
|
|
94
|
+
| --------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
95
|
+
| Gradient accumulation 1×8 vs 4×2 | real `Trainer` | matches the token mean (≤ 2e-7) |
|
|
96
|
+
| Sample order (shuffled) | real `Trainer` | invariant |
|
|
97
|
+
| Logged loss equals the objective | real `Trainer` | equal |
|
|
98
|
+
| Gradient accumulation, transformers **4.45.2** | real `Trainer` (side venv) | **mean of micro-batch means**, as fixed in Oct 2024; matches the closed form |
|
|
99
|
+
| Left vs right padding | real `Trainer` | **differs**: with labels = input ids, the pad before each left-padded sequence predicts its first token, adding a target (labelling convention) |
|
|
100
|
+
| Label smoothing | real `Trainer` | flagged: cosine < 1 (declared configuration; tiny on the untrained probe) |
|
|
101
|
+
| pad = EOS with `DataCollatorForLanguageModeling` | real collator | EOS targets get zero weight |
|
|
102
|
+
| Mean of micro-batch means | emulation | detected; matches closed form |
|
|
103
|
+
| Later micro-batches dropped | emulation | detected; matches closed form |
|
|
104
|
+
| Data parallel, 2 ranks, `average_tokens_across_devices` default | real `Trainer`, torchrun | token mean |
|
|
105
|
+
| Data parallel, `average_tokens_across_devices=False` | real `Trainer`, torchrun | per-rank mean, matches closed form |
|
|
106
|
+
| World size 1 vs 2, equal and uneven tokens per rank | DDP (spawn, gloo) | identical weights |
|
|
107
|
+
| `DistributedSampler` padding | DDP (spawn, gloo) | the repeated sample counts twice |
|
|
108
|
+
| TRL SFT, `padding_free` and `packing` (bfd) | real `SFTTrainer` | token mean; packed documents do not leak |
|
|
109
|
+
| TRL DPO, sigmoid loss | real `DPOTrainer` | matches β·σ(−βΔ)/P per token |
|
|
110
|
+
| TRL GRPO, default (`dapo`), `grpo`, `dr_grpo`, two iterations | real `GRPOTrainer` | matches the clipped surrogate, including clipped tokens at 0 |
|
|
111
|
+
| Padded rows | real model | no leak |
|
|
112
|
+
| Packed, no boundaries | real model | leaks (later documents into earlier) |
|
|
113
|
+
| Packed, `position_ids` reset, `use_cache=False` | real model | no leak |
|
|
114
|
+
| Packed, `position_ids` reset, default `use_cache` | real model | **leaks**, no warning — known issue, see below |
|
|
115
|
+
|
|
116
|
+
Versions: transformers 5.22.2, TRL 1.17.1, torch 2.15 (the 4.45.2 row uses torch 2.4.1).
|
|
117
|
+
|
|
118
|
+
**Emulations** reproduce, in plain PyTorch, the documented mechanism of a bug in code that cannot be run
|
|
119
|
+
here. They are always labelled and never presented as the library itself.
|
|
120
|
+
|
|
121
|
+
### Gradient accumulation before the October 2024 fix (the hero plate)
|
|
122
|
+
|
|
123
|
+
The site's first plate is a real run of the transformers **4.45.2** `Trainer` with four accumulation
|
|
124
|
+
steps, in a separate environment (`.venv-hf445`, pinned in `requirements-hf445.txt`, created by
|
|
125
|
+
`scripts/setup.sh`). `token-xray legacy-ga` runs the probe, the unit gradients and the readout entirely
|
|
126
|
+
inside that environment, so nothing mixes library versions. The weights match the mean of micro-batch
|
|
127
|
+
means in closed form; the current release matches the token mean.
|
|
128
|
+
|
|
129
|
+
### Data parallel: relation R2
|
|
130
|
+
|
|
131
|
+
R2: a token's weight must not depend on how the batch is split across data-parallel ranks. Activation
|
|
132
|
+
gradients are local to a rank (DDP averages _parameter_ gradients in the all-reduce), so these runs read
|
|
133
|
+
the input embedding's parameter gradient after the all-reduce. Tracer rows are unique, so row _t_ holds
|
|
134
|
+
token _t_'s gradient; on one process this row readout equals the activation readout.
|
|
135
|
+
|
|
136
|
+
- `torch.multiprocessing.spawn`, gloo, DDP around the probe: with a global token mean the weights for world
|
|
137
|
+
size 1 and 2 are identical (equal and uneven tokens per rank); an average of per-rank token means breaks
|
|
138
|
+
R2 whenever ranks hold different token counts, and matches `intended.per_rank_mean`.
|
|
139
|
+
- The real `Trainer` under `torchrun` on two CPU processes: the default
|
|
140
|
+
`average_tokens_across_devices=True` gives the token mean; `False` gives the per-rank mean.
|
|
141
|
+
- **`DistributedSampler` padding.** With `drop_last=False` and a dataset that does not divide evenly, the
|
|
142
|
+
sampler repeats samples. The repeat carries the same tracer ids, so its rows hold the sum of both copies,
|
|
143
|
+
which is exactly what reaches the parameters: the repeated sample counts twice in that step
|
|
144
|
+
(`intended.with_multiplicity`). This is documented sampler behaviour, not a bug.
|
|
145
|
+
|
|
146
|
+
### TRL runs
|
|
147
|
+
|
|
148
|
+
Real runs of `SFTTrainer`, `DPOTrainer` and `GRPOTrainer` (TRL 1.17.1) on the probe:
|
|
149
|
+
|
|
150
|
+
- **SFT** with `padding_free=True` and with `packing=True` (bfd): a global token mean across accumulation;
|
|
151
|
+
the leak matrix built through the trainer (one run per document, labels on that document only) is
|
|
152
|
+
exactly zero, because SFT passes `use_cache=False`.
|
|
153
|
+
- **DPO**, sigmoid loss: every completion token gets β·σ(−βΔ)/P, positive on chosen and negative on
|
|
154
|
+
rejected answers (verified against autograd and measured through the trainer).
|
|
155
|
+
- **GRPO** with scripted completions (each ending with its own EOS id) and `num_iterations=2`: the default
|
|
156
|
+
`loss_type` is `dapo`; `dapo`, `grpo` and `dr_grpo` all match the clipped surrogate at both steps,
|
|
157
|
+
including the tokens the clip zeroes once ratios move. TRL's `dr_grpo` keeps `scale_rewards="group"`
|
|
158
|
+
unless set to `"none"`; the published Dr. GRPO drops it. Step-1 weights are checked against
|
|
159
|
+
`src/core/aggregation.ts`.
|
|
160
|
+
|
|
161
|
+
### Known issue: packed sequences and `use_cache`
|
|
162
|
+
|
|
163
|
+
transformers detects packed sequences from `position_ids` only when no `attention_mask` is passed and no
|
|
164
|
+
cache object exists. Most model configs default to `use_cache=True`, which creates a cache even in
|
|
165
|
+
training mode, so a hand-written loop that feeds packed `position_ids` without `use_cache=False` attends
|
|
166
|
+
across documents silently. Loops with gradient checkpointing are unaffected (it turns the cache off), and
|
|
167
|
+
so is the `Trainer`, which sets `model.config.use_cache` from `TrainingArguments.use_cache`.
|
|
168
|
+
|
|
169
|
+
This is already reported in [transformers#50419](https://github.com/huggingface/transformers/issues/50419).
|
|
170
|
+
A maintainer first replied that a cache should not be used during training and suggested documenting
|
|
171
|
+
that requirement, then noted that `use_cache` defaults to true and linked the issue to a proposed fix.
|
|
172
|
+
Fixes are proposed in [#50190](https://github.com/huggingface/transformers/pull/50190) and
|
|
173
|
+
[#50421](https://github.com/huggingface/transformers/pull/50421). Until one lands, pass `use_cache=False`
|
|
174
|
+
when you train on packed sequences. Reproduction: [`repro/packed_position_ids_use_cache.py`](repro/packed_position_ids_use_cache.py).
|
|
175
|
+
|
|
176
|
+
## Precision
|
|
177
|
+
|
|
178
|
+
LLaMA's RMSNorm and the HF loss compute in float32 inside a float64 model, so measured weights carry
|
|
179
|
+
up to ~2e-7 relative error. Tests use a tolerance of 1e-6. TRL's default bf16 mixed precision is tested
|
|
180
|
+
against bounds derived from bf16's unit roundoff u = 2⁻⁸: per token ≤ 2u, per sequence mean ≤ u (the mean
|
|
181
|
+
bound does not shrink with the number of targets, because the measured rounding errors within a sequence
|
|
182
|
+
are correlated). A 1% re-weighting exceeds it 2.6×, and a planted 1% normaliser fault fails the test. These
|
|
183
|
+
bounds replace a number that was first guessed and then raised after measuring; see
|
|
184
|
+
`docs/DECISIONS.md`. The `eager` attention path in float64 produces NaN
|
|
185
|
+
on fully padded rows; the probe uses `sdpa`, the default.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
pip install -e ".[dev]"
|
|
191
|
+
pytest
|
|
192
|
+
token-xray report
|
|
193
|
+
token-xray export ../public/xray/data.json # needs ../.venv-hf445 for the 4.45.2 run (scripts/setup.sh)
|
|
194
|
+
node ../scripts/py.mjs ../scripts/mutate.py # the mutation test, ~25 minutes
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every check is mutation-tested. `scripts/mutate.py` plants 43 faults, one at a time: 35 in the instrument itself (readout, recorder, probe, closed forms, leak harness, runners, data-parallel launchers) and 8 realistic bugs injected into transformers and TRL (`xray/tests/conftest.py`). Every fault is caught, and each of the 52 tests catches at least one. It runs in CI on pushes to `main` and nightly.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# token-xray
|
|
2
|
+
|
|
3
|
+
**Measure the gradient weight your loss gives every token.**
|
|
4
|
+
|
|
5
|
+
Many silent training bugs, and several objective design choices, show up in one quantity: the gradient
|
|
6
|
+
weight each token receives. Others leak gradient from one sequence into another. Token X-ray measures
|
|
7
|
+
both by running your batching and loss code on a tracer model, and compares them with what the equation
|
|
8
|
+
says.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from token_xray import xray
|
|
12
|
+
|
|
13
|
+
def my_step(model, sequences):
|
|
14
|
+
# batch `sequences` exactly as your trainer does, then forward + backward
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
|
|
18
|
+
report.assert_token_mean() # every target token received weight 1/N
|
|
19
|
+
print(report.table())
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "git+https://github.com/kaushikdhola/lineage-ml#subdirectory=xray"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The package is not on PyPI yet; this installs it from the repository (https://github.com/kaushikdhola/lineage-ml).
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
A tiny LLaMA "probe" gives every token in the batch a unique id. In aggregation mode the attention
|
|
33
|
+
output is zeroed and frozen, so a position's loss depends only on its own embedding. The gradient the
|
|
34
|
+
stack produces at token _i_ is then `w_i · r_i`, where `r_i` is the token's unit-weight gradient
|
|
35
|
+
(computed on an identical probe), and the readout recovers:
|
|
36
|
+
|
|
37
|
+
- `w_i = ⟨g_i, r_i⟩ / ‖r_i‖²` — the weight the stack applied (×N, so a global token mean is 1.0)
|
|
38
|
+
- `cos(g_i, r_i)` — below 1 when the stack changed the gradient in a way that is not a rescaling
|
|
39
|
+
|
|
40
|
+
In leak mode attention is live. One backward per sequence, with only that sequence labelled; any
|
|
41
|
+
gradient reaching another sequence is a leak. On a correct stack it is exactly zero.
|
|
42
|
+
|
|
43
|
+
When one input token predicts several targets (the prompt's last token in preference and RL batches), the
|
|
44
|
+
weights are solved for by least squares on the targets' unit gradients; the condition number and the
|
|
45
|
+
unexplained remainder are reported, and the readout refuses systems it cannot identify (at most h − 1
|
|
46
|
+
targets per input for hidden size h). Under data parallelism the readout uses the embedding's parameter
|
|
47
|
+
gradient after the all-reduce. Gradient that reaches padding is collected, not dropped.
|
|
48
|
+
|
|
49
|
+
### What it cannot see
|
|
50
|
+
|
|
51
|
+
- **After backward.** The readout sees how the loss weights tokens, before gradient clipping and the
|
|
52
|
+
optimizer. Bugs that live in the optimizer step, such as the DeepSpeed ZeRO-1/2 CPU-offload gradient copy
|
|
53
|
+
emulated below, are outside its view.
|
|
54
|
+
- **Model state.** The probe is untrained, so its predictions are near-uniform. Anything that depends on
|
|
55
|
+
the model's state (label smoothing's cosine, z-loss, DPO's margin, GRPO's ratios and clipping, the size
|
|
56
|
+
of a leak) is measured for this probe, not for a trained model. The equations it checks hold either way.
|
|
57
|
+
|
|
58
|
+
### What each real run changes from the defaults
|
|
59
|
+
|
|
60
|
+
- the sample order is fixed with a sequential sampler (shuffled order is tested separately);
|
|
61
|
+
- gradient clipping is off (`max_grad_norm=0`) and bf16 is off (TRL's configs default to bf16);
|
|
62
|
+
- GRPO's completions are scripted: `model.generate` returns fixed tracer sequences, each ending with its
|
|
63
|
+
own EOS id, and a fixed reward function scores them;
|
|
64
|
+
- the training step (or `compute_loss`) is wrapped only to record gradients and the batches seen.
|
|
65
|
+
|
|
66
|
+
## What is checked today
|
|
67
|
+
|
|
68
|
+
| Check | Runs | Result |
|
|
69
|
+
| --------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| Gradient accumulation 1×8 vs 4×2 | real `Trainer` | matches the token mean (≤ 2e-7) |
|
|
71
|
+
| Sample order (shuffled) | real `Trainer` | invariant |
|
|
72
|
+
| Logged loss equals the objective | real `Trainer` | equal |
|
|
73
|
+
| Gradient accumulation, transformers **4.45.2** | real `Trainer` (side venv) | **mean of micro-batch means**, as fixed in Oct 2024; matches the closed form |
|
|
74
|
+
| Left vs right padding | real `Trainer` | **differs**: with labels = input ids, the pad before each left-padded sequence predicts its first token, adding a target (labelling convention) |
|
|
75
|
+
| Label smoothing | real `Trainer` | flagged: cosine < 1 (declared configuration; tiny on the untrained probe) |
|
|
76
|
+
| pad = EOS with `DataCollatorForLanguageModeling` | real collator | EOS targets get zero weight |
|
|
77
|
+
| Mean of micro-batch means | emulation | detected; matches closed form |
|
|
78
|
+
| Later micro-batches dropped | emulation | detected; matches closed form |
|
|
79
|
+
| Data parallel, 2 ranks, `average_tokens_across_devices` default | real `Trainer`, torchrun | token mean |
|
|
80
|
+
| Data parallel, `average_tokens_across_devices=False` | real `Trainer`, torchrun | per-rank mean, matches closed form |
|
|
81
|
+
| World size 1 vs 2, equal and uneven tokens per rank | DDP (spawn, gloo) | identical weights |
|
|
82
|
+
| `DistributedSampler` padding | DDP (spawn, gloo) | the repeated sample counts twice |
|
|
83
|
+
| TRL SFT, `padding_free` and `packing` (bfd) | real `SFTTrainer` | token mean; packed documents do not leak |
|
|
84
|
+
| TRL DPO, sigmoid loss | real `DPOTrainer` | matches β·σ(−βΔ)/P per token |
|
|
85
|
+
| TRL GRPO, default (`dapo`), `grpo`, `dr_grpo`, two iterations | real `GRPOTrainer` | matches the clipped surrogate, including clipped tokens at 0 |
|
|
86
|
+
| Padded rows | real model | no leak |
|
|
87
|
+
| Packed, no boundaries | real model | leaks (later documents into earlier) |
|
|
88
|
+
| Packed, `position_ids` reset, `use_cache=False` | real model | no leak |
|
|
89
|
+
| Packed, `position_ids` reset, default `use_cache` | real model | **leaks**, no warning — known issue, see below |
|
|
90
|
+
|
|
91
|
+
Versions: transformers 5.22.2, TRL 1.17.1, torch 2.15 (the 4.45.2 row uses torch 2.4.1).
|
|
92
|
+
|
|
93
|
+
**Emulations** reproduce, in plain PyTorch, the documented mechanism of a bug in code that cannot be run
|
|
94
|
+
here. They are always labelled and never presented as the library itself.
|
|
95
|
+
|
|
96
|
+
### Gradient accumulation before the October 2024 fix (the hero plate)
|
|
97
|
+
|
|
98
|
+
The site's first plate is a real run of the transformers **4.45.2** `Trainer` with four accumulation
|
|
99
|
+
steps, in a separate environment (`.venv-hf445`, pinned in `requirements-hf445.txt`, created by
|
|
100
|
+
`scripts/setup.sh`). `token-xray legacy-ga` runs the probe, the unit gradients and the readout entirely
|
|
101
|
+
inside that environment, so nothing mixes library versions. The weights match the mean of micro-batch
|
|
102
|
+
means in closed form; the current release matches the token mean.
|
|
103
|
+
|
|
104
|
+
### Data parallel: relation R2
|
|
105
|
+
|
|
106
|
+
R2: a token's weight must not depend on how the batch is split across data-parallel ranks. Activation
|
|
107
|
+
gradients are local to a rank (DDP averages _parameter_ gradients in the all-reduce), so these runs read
|
|
108
|
+
the input embedding's parameter gradient after the all-reduce. Tracer rows are unique, so row _t_ holds
|
|
109
|
+
token _t_'s gradient; on one process this row readout equals the activation readout.
|
|
110
|
+
|
|
111
|
+
- `torch.multiprocessing.spawn`, gloo, DDP around the probe: with a global token mean the weights for world
|
|
112
|
+
size 1 and 2 are identical (equal and uneven tokens per rank); an average of per-rank token means breaks
|
|
113
|
+
R2 whenever ranks hold different token counts, and matches `intended.per_rank_mean`.
|
|
114
|
+
- The real `Trainer` under `torchrun` on two CPU processes: the default
|
|
115
|
+
`average_tokens_across_devices=True` gives the token mean; `False` gives the per-rank mean.
|
|
116
|
+
- **`DistributedSampler` padding.** With `drop_last=False` and a dataset that does not divide evenly, the
|
|
117
|
+
sampler repeats samples. The repeat carries the same tracer ids, so its rows hold the sum of both copies,
|
|
118
|
+
which is exactly what reaches the parameters: the repeated sample counts twice in that step
|
|
119
|
+
(`intended.with_multiplicity`). This is documented sampler behaviour, not a bug.
|
|
120
|
+
|
|
121
|
+
### TRL runs
|
|
122
|
+
|
|
123
|
+
Real runs of `SFTTrainer`, `DPOTrainer` and `GRPOTrainer` (TRL 1.17.1) on the probe:
|
|
124
|
+
|
|
125
|
+
- **SFT** with `padding_free=True` and with `packing=True` (bfd): a global token mean across accumulation;
|
|
126
|
+
the leak matrix built through the trainer (one run per document, labels on that document only) is
|
|
127
|
+
exactly zero, because SFT passes `use_cache=False`.
|
|
128
|
+
- **DPO**, sigmoid loss: every completion token gets β·σ(−βΔ)/P, positive on chosen and negative on
|
|
129
|
+
rejected answers (verified against autograd and measured through the trainer).
|
|
130
|
+
- **GRPO** with scripted completions (each ending with its own EOS id) and `num_iterations=2`: the default
|
|
131
|
+
`loss_type` is `dapo`; `dapo`, `grpo` and `dr_grpo` all match the clipped surrogate at both steps,
|
|
132
|
+
including the tokens the clip zeroes once ratios move. TRL's `dr_grpo` keeps `scale_rewards="group"`
|
|
133
|
+
unless set to `"none"`; the published Dr. GRPO drops it. Step-1 weights are checked against
|
|
134
|
+
`src/core/aggregation.ts`.
|
|
135
|
+
|
|
136
|
+
### Known issue: packed sequences and `use_cache`
|
|
137
|
+
|
|
138
|
+
transformers detects packed sequences from `position_ids` only when no `attention_mask` is passed and no
|
|
139
|
+
cache object exists. Most model configs default to `use_cache=True`, which creates a cache even in
|
|
140
|
+
training mode, so a hand-written loop that feeds packed `position_ids` without `use_cache=False` attends
|
|
141
|
+
across documents silently. Loops with gradient checkpointing are unaffected (it turns the cache off), and
|
|
142
|
+
so is the `Trainer`, which sets `model.config.use_cache` from `TrainingArguments.use_cache`.
|
|
143
|
+
|
|
144
|
+
This is already reported in [transformers#50419](https://github.com/huggingface/transformers/issues/50419).
|
|
145
|
+
A maintainer first replied that a cache should not be used during training and suggested documenting
|
|
146
|
+
that requirement, then noted that `use_cache` defaults to true and linked the issue to a proposed fix.
|
|
147
|
+
Fixes are proposed in [#50190](https://github.com/huggingface/transformers/pull/50190) and
|
|
148
|
+
[#50421](https://github.com/huggingface/transformers/pull/50421). Until one lands, pass `use_cache=False`
|
|
149
|
+
when you train on packed sequences. Reproduction: [`repro/packed_position_ids_use_cache.py`](repro/packed_position_ids_use_cache.py).
|
|
150
|
+
|
|
151
|
+
## Precision
|
|
152
|
+
|
|
153
|
+
LLaMA's RMSNorm and the HF loss compute in float32 inside a float64 model, so measured weights carry
|
|
154
|
+
up to ~2e-7 relative error. Tests use a tolerance of 1e-6. TRL's default bf16 mixed precision is tested
|
|
155
|
+
against bounds derived from bf16's unit roundoff u = 2⁻⁸: per token ≤ 2u, per sequence mean ≤ u (the mean
|
|
156
|
+
bound does not shrink with the number of targets, because the measured rounding errors within a sequence
|
|
157
|
+
are correlated). A 1% re-weighting exceeds it 2.6×, and a planted 1% normaliser fault fails the test. These
|
|
158
|
+
bounds replace a number that was first guessed and then raised after measuring; see
|
|
159
|
+
`docs/DECISIONS.md`. The `eager` attention path in float64 produces NaN
|
|
160
|
+
on fully padded rows; the probe uses `sdpa`, the default.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pip install -e ".[dev]"
|
|
166
|
+
pytest
|
|
167
|
+
token-xray report
|
|
168
|
+
token-xray export ../public/xray/data.json # needs ../.venv-hf445 for the 4.45.2 run (scripts/setup.sh)
|
|
169
|
+
node ../scripts/py.mjs ../scripts/mutate.py # the mutation test, ~25 minutes
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Every check is mutation-tested. `scripts/mutate.py` plants 43 faults, one at a time: 35 in the instrument itself (readout, recorder, probe, closed forms, leak harness, runners, data-parallel launchers) and 8 realistic bugs injected into transformers and TRL (`xray/tests/conftest.py`). Every fault is caught, and each of the 52 tests catches at least one. It runs in CI on pushes to `main` and nightly.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "token-xray-ml" # "tokenxray" is taken on PyPI; imports stay token_xray, the CLI stays token-xray
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "See what your training stack actually does to every token: measured per-token gradient weights and cross-sequence gradient leaks, read from the library's own training loop."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10.2"
|
|
12
|
+
dependencies = ["torch>=2.3", "transformers>=4.46", "numpy>=1.26"]
|
|
13
|
+
keywords = ["llm", "training", "gradient", "debugging", "transformers", "trl", "grpo"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://lineage-ml.pages.dev/"
|
|
23
|
+
Source = "https://github.com/kaushikdhola/lineage-ml/tree/main/xray"
|
|
24
|
+
Issues = "https://github.com/kaushikdhola/lineage-ml/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
trl = ["trl>=1.0,<2"]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
token-xray = "token_xray.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Token X-ray: measured per-token gradient weights and cross-sequence leaks through real training stacks."""
|
|
2
|
+
from .api import Report, xray
|
|
3
|
+
from .probe import build_probe, make_batch
|
|
4
|
+
|
|
5
|
+
__all__ = ["xray", "Report", "build_probe", "make_batch"]
|
|
6
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API: X-ray your own training step.
|
|
3
|
+
|
|
4
|
+
from token_xray import xray
|
|
5
|
+
|
|
6
|
+
def my_step(model, sequences):
|
|
7
|
+
# build batches exactly as your trainer does, run forward + backward (no optimizer step needed)
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
report = xray(my_step, lengths=[5, 9, 3, 12, 7, 4, 10, 6])
|
|
11
|
+
report.assert_token_mean() # every target token got weight 1/N
|
|
12
|
+
print(report.table())
|
|
13
|
+
|
|
14
|
+
The probe model is a tiny LLaMA whose token ids are unique tracers. `sequences` is a list of lists of
|
|
15
|
+
token ids; treat them like tokenized documents. Labels follow the usual convention (labels = input ids,
|
|
16
|
+
shifted inside the loss).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from . import intended
|
|
26
|
+
from .probe import build_probe, make_batch
|
|
27
|
+
from .readout import XRay, read, recording, unit_gradients
|
|
28
|
+
|
|
29
|
+
Step = Callable[[object, list[list[int]]], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Report:
|
|
34
|
+
xray: XRay
|
|
35
|
+
lengths: list[int]
|
|
36
|
+
|
|
37
|
+
def deviation_from(self, want: list[list[float]]) -> float:
|
|
38
|
+
got = self.xray.normalized
|
|
39
|
+
return max(abs(g - w) for gr, wr in zip(got, want) for g, w in zip(gr, wr))
|
|
40
|
+
|
|
41
|
+
def assert_token_mean(self, tol: float = 1e-6) -> None:
|
|
42
|
+
dev = self.deviation_from(intended.token_mean(self.lengths))
|
|
43
|
+
if dev > tol:
|
|
44
|
+
raise AssertionError(
|
|
45
|
+
f"token weights deviate from a global token mean by up to {dev:.3g} (tol {tol:g}).\n" + self.table()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def assert_pure_rescaling(self, tol: float = 1e-9) -> None:
|
|
49
|
+
if self.xray.min_cos < 1 - tol:
|
|
50
|
+
raise AssertionError(f"gradient is not a pure rescaling of each token's own gradient (min cos {self.xray.min_cos:.9f})")
|
|
51
|
+
|
|
52
|
+
def table(self) -> str:
|
|
53
|
+
lines = ["seq len mean(w*N) min(w*N) max(w*N)"]
|
|
54
|
+
for i, row in enumerate(self.xray.normalized):
|
|
55
|
+
a = np.array(row)
|
|
56
|
+
lines.append(f"{i:3d} {self.lengths[i]:4d} {a.mean():9.4f} {a.min():9.4f} {a.max():9.4f}")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def xray(step: Step, lengths: list[int], seed: int = 0) -> Report:
|
|
61
|
+
batch = make_batch(lengths)
|
|
62
|
+
unit = unit_gradients(build_probe(batch.vocab_size, seed=seed), batch)
|
|
63
|
+
model = build_probe(batch.vocab_size, seed=seed)
|
|
64
|
+
with recording(model) as rec:
|
|
65
|
+
step(model, [list(s) for s in batch.sequences])
|
|
66
|
+
return Report(xray=read(rec.grads, unit, batch), lengths=lengths)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""token-xray command line.
|
|
2
|
+
|
|
3
|
+
token-xray report print the frozen scenarios as a table
|
|
4
|
+
token-xray export public/xray.json write measured results for the website
|
|
5
|
+
token-xray legacy-ga out.json measure the gradient-accumulation step with the INSTALLED
|
|
6
|
+
transformers (run in .venv-hf445 for the pre-fix 4.45.2 Trainer)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
p = argparse.ArgumentParser(prog="token-xray")
|
|
17
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
18
|
+
sub.add_parser("report")
|
|
19
|
+
e = sub.add_parser("export")
|
|
20
|
+
e.add_argument("out")
|
|
21
|
+
g = sub.add_parser("legacy-ga")
|
|
22
|
+
g.add_argument("out")
|
|
23
|
+
a = p.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
if a.cmd == "legacy-ga":
|
|
26
|
+
from .scenarios import legacy_ga_panel
|
|
27
|
+
|
|
28
|
+
with open(a.out, "w") as f:
|
|
29
|
+
json.dump(legacy_ga_panel(), f)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
from .scenarios import export
|
|
33
|
+
|
|
34
|
+
data = export()
|
|
35
|
+
if a.cmd == "export":
|
|
36
|
+
with open(a.out, "w") as f:
|
|
37
|
+
json.dump(data, f, separators=(",", ":"))
|
|
38
|
+
print(f"wrote {a.out}")
|
|
39
|
+
return 0
|
|
40
|
+
print("versions:", data["versions"])
|
|
41
|
+
print("\nAggregation (max |w*N - intended|, min cosine)")
|
|
42
|
+
for pnl in data["aggregation"]["panels"]:
|
|
43
|
+
print(f" [{pnl['kind']:9s}] {pnl['title']:52s} {pnl['maxDeviation']:.2e} {pnl['minCos']:.6f}")
|
|
44
|
+
print("\nLeaks (max off-diagonal)")
|
|
45
|
+
for pnl in data["leak"]["panels"]:
|
|
46
|
+
print(f" [{pnl['kind']:9s}] {pnl['title']:52s} {pnl['maxLeak']:.2e}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|