chorus-torch 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.
- chorus_torch-0.1.0/LICENSE +21 -0
- chorus_torch-0.1.0/PKG-INFO +432 -0
- chorus_torch-0.1.0/README.md +402 -0
- chorus_torch-0.1.0/pyproject.toml +130 -0
- chorus_torch-0.1.0/setup.cfg +4 -0
- chorus_torch-0.1.0/src/chorus/__init__.py +114 -0
- chorus_torch-0.1.0/src/chorus/bucketing.py +150 -0
- chorus_torch-0.1.0/src/chorus/functional.py +153 -0
- chorus_torch-0.1.0/src/chorus/hyperparams.py +67 -0
- chorus_torch-0.1.0/src/chorus/log.py +74 -0
- chorus_torch-0.1.0/src/chorus/methods.py +280 -0
- chorus_torch-0.1.0/src/chorus/norms.py +233 -0
- chorus_torch-0.1.0/src/chorus/objectives.py +282 -0
- chorus_torch-0.1.0/src/chorus/optim.py +530 -0
- chorus_torch-0.1.0/src/chorus/options.py +180 -0
- chorus_torch-0.1.0/src/chorus/py.typed +0 -0
- chorus_torch-0.1.0/src/chorus/report.py +205 -0
- chorus_torch-0.1.0/src/chorus/runtime.py +634 -0
- chorus_torch-0.1.0/src/chorus/seeding.py +40 -0
- chorus_torch-0.1.0/src/chorus/splitting.py +65 -0
- chorus_torch-0.1.0/src/chorus/stack.py +554 -0
- chorus_torch-0.1.0/src/chorus/trainer.py +704 -0
- chorus_torch-0.1.0/src/chorus_torch.egg-info/PKG-INFO +432 -0
- chorus_torch-0.1.0/src/chorus_torch.egg-info/SOURCES.txt +34 -0
- chorus_torch-0.1.0/src/chorus_torch.egg-info/dependency_links.txt +1 -0
- chorus_torch-0.1.0/src/chorus_torch.egg-info/requires.txt +8 -0
- chorus_torch-0.1.0/src/chorus_torch.egg-info/top_level.txt +1 -0
- chorus_torch-0.1.0/tests/test_bucketing.py +102 -0
- chorus_torch-0.1.0/tests/test_fidelity.py +125 -0
- chorus_torch-0.1.0/tests/test_functional.py +89 -0
- chorus_torch-0.1.0/tests/test_optim.py +182 -0
- chorus_torch-0.1.0/tests/test_options.py +361 -0
- chorus_torch-0.1.0/tests/test_runtime.py +289 -0
- chorus_torch-0.1.0/tests/test_splitting.py +53 -0
- chorus_torch-0.1.0/tests/test_stack.py +225 -0
- chorus_torch-0.1.0/tests/test_trainer.py +307 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joaquin Arroyo
|
|
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,432 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chorus-torch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Train many PyTorch models at once, on one GPU.
|
|
5
|
+
Author: Joaquin Arroyo
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/joaquinarroyo/chorus
|
|
8
|
+
Project-URL: Thesis, https://github.com/joaquinarroyo/thesis
|
|
9
|
+
Project-URL: Benchmarks, https://github.com/joaquinarroyo/fugue
|
|
10
|
+
Keywords: pytorch,ensemble,cross-validation,hyperparameter-search,gpu
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: torch>=2.5
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff==0.16.3; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy==1.19.1; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# chorus
|
|
32
|
+
|
|
33
|
+
Train many PyTorch models at once, on one GPU.
|
|
34
|
+
|
|
35
|
+
Cross-validation, ensembles and hyperparameter search all end up as the same
|
|
36
|
+
loop: *for model in models: train(model)*. One model at a time, each one leaving
|
|
37
|
+
most of the GPU idle. chorus runs them together — in one process, one CUDA
|
|
38
|
+
context, one copy of the dataset, and, where the models allow it, **one batched
|
|
39
|
+
kernel** instead of K sequential ones.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import chorus
|
|
43
|
+
|
|
44
|
+
report = chorus.ensemble(lambda: ResNet18(), train_data, n=8,
|
|
45
|
+
val_data=val_data, epochs=20, batch_size=64)
|
|
46
|
+
|
|
47
|
+
print(report.mean("val_score"))
|
|
48
|
+
best = report.best().model # a plain nn.Module, trained
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It is a library, not a framework: nothing here asks you to restructure your
|
|
52
|
+
code, subclass anything, or hand over your training loop unless you want to.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e . # from a clone; not on PyPI yet
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Only `torch` (>= 2.4) and `numpy`. No CUDA required to use it — everything
|
|
63
|
+
works on CPU, just without the parts that need a device.
|
|
64
|
+
|
|
65
|
+
**Linux and macOS.** Linux with an NVIDIA GPU is where the batched path pays
|
|
66
|
+
off; macOS runs the whole test suite on CPU, which is where this is developed.
|
|
67
|
+
Windows is not tested and not supported.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Three levels
|
|
72
|
+
|
|
73
|
+
### 1. The primitive: `Stack` is an `nn.Module`
|
|
74
|
+
|
|
75
|
+
K models, one module, one `(K, B, …)` output. Drop it into whatever loop you
|
|
76
|
+
already have:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
stack = chorus.Stack([make_model() for _ in range(8)]).cuda()
|
|
80
|
+
opt = torch.optim.AdamW(stack.parameters(), lr=1e-3, fused=True)
|
|
81
|
+
|
|
82
|
+
for x, y in loader: # your loader
|
|
83
|
+
loss = chorus.stacked_cross_entropy(stack(x), y) # one kernel, not eight
|
|
84
|
+
loss.backward(); opt.step(); opt.zero_grad()
|
|
85
|
+
|
|
86
|
+
stack.sync_() # your eight model objects are now trained
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`.to()`, `.train()`, `.eval()`, `state_dict()`, AMP and `torch.compile` behave
|
|
90
|
+
exactly as they do for any other module. `stack.unstack(i)` hands back member
|
|
91
|
+
*i* as an instance of your own class, and `stack.state_dicts()` hands back K
|
|
92
|
+
ordinary `state_dict`s — your key names, no leading K — for saving. (The
|
|
93
|
+
stack's own `state_dict()` is the stack's: right for reloading a stack, wrong
|
|
94
|
+
for `your_model.load_state_dict`.)
|
|
95
|
+
|
|
96
|
+
Different learning rate per member? `chorus.StackedAdam(stack, lr=[1e-3, 3e-4, …])`
|
|
97
|
+
is a `torch.optim.Optimizer`.
|
|
98
|
+
|
|
99
|
+
### 2. The trainer: heterogeneous workloads
|
|
100
|
+
|
|
101
|
+
When the models are not all alike — different architectures, batch sizes, epoch
|
|
102
|
+
budgets — `Trainer` works out what can be batched together and what merely
|
|
103
|
+
shares the device:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
t = chorus.Trainer()
|
|
107
|
+
t.add(resnet_a, train, val_data=val, epochs=10, batch_size=64)
|
|
108
|
+
t.add(resnet_b, train, val_data=val, epochs=10, batch_size=64)
|
|
109
|
+
t.add(transformer, other, epochs=5, batch_size=16, lr=3e-4)
|
|
110
|
+
|
|
111
|
+
print(t.plan()) # before spending anything
|
|
112
|
+
report = t.run()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
3 models → 2 bucket(s)
|
|
117
|
+
bucket 0 2 models vectorized 2 models interchangeable by structure (shared loader)
|
|
118
|
+
bucket 1 1 model eager single model, nothing to batch (shared loader)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 3. The workloads
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
chorus.ensemble(model_fn, data, n=10, epochs=20)
|
|
125
|
+
chorus.cross_validate(model_fn, data, k=5, epochs=20)
|
|
126
|
+
chorus.hpo(model_fn, data, {"lr": [1e-2, 1e-3, 1e-4]}, val_data=val)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each is a few lines over `Trainer`. They take a **factory** because chorus has
|
|
130
|
+
to build the K members itself — `vmap` needs K parameter sets of identical
|
|
131
|
+
structure, and one instance used K times would train one model K ways.
|
|
132
|
+
|
|
133
|
+
Mix them freely: `add_folds` and `add_grid` on the same trainer bucket together
|
|
134
|
+
whatever can be bucketed together.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## How it decides what to batch
|
|
139
|
+
|
|
140
|
+
Two models share a batched forward when they agree on:
|
|
141
|
+
|
|
142
|
+
| | why |
|
|
143
|
+
|---|---|
|
|
144
|
+
| **class** | `vmap` applies *one* forward to K parameter sets |
|
|
145
|
+
| **parameter shapes** | they have to stack |
|
|
146
|
+
| **batch size** | one dispatched shape per step |
|
|
147
|
+
| **objective, optimizer family, AMP, compile** | one loss, one update rule, one decision per step |
|
|
148
|
+
|
|
149
|
+
Everything else may differ, and that is exactly what the three workloads need:
|
|
150
|
+
data split, learning rate, weight decay, number of epochs.
|
|
151
|
+
|
|
152
|
+
**Same class and shapes is not enough**, and this is the failure the library is
|
|
153
|
+
most careful about. Two instances of one class can still compute different
|
|
154
|
+
things — `use_residual=False`, a different activation, a dropout probability
|
|
155
|
+
read from `self`. Neither the class nor the shapes show it, and `vmap` would
|
|
156
|
+
train both with the first one's logic and return weights that are wrong without
|
|
157
|
+
raising anything. So before committing a bucket, chorus runs one batch through
|
|
158
|
+
both routes and compares; if they disagree, that bucket falls back to eager. One
|
|
159
|
+
forward at startup, and a silent wrong answer becomes a slower right one.
|
|
160
|
+
|
|
161
|
+
That batch is taken from the first samples of your `Dataset`, which works when
|
|
162
|
+
it yields `(input, target)` and not otherwise. When it cannot be built the
|
|
163
|
+
bucket still vectorises, and **the plan says so** — `vectorized … unchecked (no
|
|
164
|
+
probe batch)`, never the same line as one that passed. Pass your own with
|
|
165
|
+
`t.add(model, data, example_input=batch)` to get the check back.
|
|
166
|
+
|
|
167
|
+
The rule worth remembering:
|
|
168
|
+
|
|
169
|
+
* hyperparameters that **preserve parameter shapes** (learning rate, weight
|
|
170
|
+
decay, momentum, seed, fold) → one bucket, fully batched.
|
|
171
|
+
* hyperparameters that **change the topology** (width, depth) → one bucket each,
|
|
172
|
+
no batching — still overlapped on the device, but that is a different and
|
|
173
|
+
smaller win.
|
|
174
|
+
|
|
175
|
+
`Trainer.plan()` tells you which one you have before you spend anything, and
|
|
176
|
+
`report.plan` tells you what actually happened — including how many samples the
|
|
177
|
+
tail drop costs each member per epoch, since a stacked step needs a constant
|
|
178
|
+
batch shape and the short last batch cannot be one.
|
|
179
|
+
|
|
180
|
+
All of that goes through `logging.getLogger("chorus")` when `verbose=True`, so
|
|
181
|
+
it can be silenced, reformatted or routed like any other library's output.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Turning the extensions on and off
|
|
186
|
+
|
|
187
|
+
Every extension is a keyword argument, and every report says which were on. The
|
|
188
|
+
defaults are **all on except compilation**.
|
|
189
|
+
|
|
190
|
+
| Flag | Default | What it does |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `vectorize` | on | one `vmap` kernel for a bucket of K interchangeable models |
|
|
193
|
+
| `streams` | on | one CUDA stream per bucket, so buckets overlap on the device |
|
|
194
|
+
| `fused_loss` | on | one loss kernel over the stacked batch instead of K |
|
|
195
|
+
| `compile` | **off** | `torch.compile` on the bucket's forward — `mode="reduce-overhead"` on CUDA, `"default"` on CPU |
|
|
196
|
+
| `verify` | on | the equivalence check above (a safety net, not an extension) |
|
|
197
|
+
| `verify_atol`, `verify_rtol` | `1e-4`, `1e-3` | the tolerances that check compares with |
|
|
198
|
+
| `verify_samples` | `2` | how many samples its probe batch carries |
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
chorus.Trainer(streams=False) # all eight live here
|
|
202
|
+
chorus.Stack(models, vectorize=False) # the five a stack can honour
|
|
203
|
+
chorus.ensemble(model_fn, data, n=8, compile=True)
|
|
204
|
+
|
|
205
|
+
cfg = {"vectorize": True, "streams": False, "fused_loss": False}
|
|
206
|
+
chorus.ensemble(model_fn, data, n=8, **cfg) # one config, several runs
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Where each one is accepted, and why it is not everywhere:
|
|
210
|
+
|
|
211
|
+
* **`Trainer`** takes all eight.
|
|
212
|
+
* **`Stack`** takes the five it can act on: `vectorize`, `streams`, `verify`,
|
|
213
|
+
`verify_atol`, `verify_rtol`. A stack computes no loss (`fused_loss`), does
|
|
214
|
+
not compile itself (the runtime does that to the bucket's forward), and
|
|
215
|
+
receives its probe batch already built (`verify_samples`).
|
|
216
|
+
* **`ensemble`, `cross_validate`, `hpo`** take all eight and hand them to the
|
|
217
|
+
trainer they build. Everything else — `epochs`, `batch_size`, `lr`,
|
|
218
|
+
`optimizer`, `amp`, `objective` — goes to `Trainer.add`. Pass a `trainer=`
|
|
219
|
+
you built yourself and the switches are refused rather than silently
|
|
220
|
+
discarded.
|
|
221
|
+
|
|
222
|
+
`compile` and `amp` are the two that can also be set **per member** —
|
|
223
|
+
`t.add(model, data, compile=True)`. That is not a convention: they are part of
|
|
224
|
+
the bucket key, so members that disagree land in different buckets. The rest
|
|
225
|
+
decide how a bucket dispatches its step, so one bucket cannot hold two answers,
|
|
226
|
+
and they stay run-wide.
|
|
227
|
+
|
|
228
|
+
Three things worth knowing before you tune these:
|
|
229
|
+
|
|
230
|
+
* **`compile` and `streams` are mutually exclusive**, and that is PyTorch's
|
|
231
|
+
constraint: CUDA Graph capture does not tolerate dynamic ambient streams. A
|
|
232
|
+
compiling bucket drops its streams. If you asked for both, chorus warns; if
|
|
233
|
+
streams was merely the default, it is silent about it.
|
|
234
|
+
* **`compile` works off the device too.** There are no CUDA Graphs to capture
|
|
235
|
+
on a CPU, so the mode changes and the win is smaller — inductor's fusion,
|
|
236
|
+
not the launch overhead it removes — but the bucket does compile. AMP is the
|
|
237
|
+
one that does not: chorus's mixed precision is autocast + `GradScaler`, which
|
|
238
|
+
is CUDA's, and asking for `amp=True` elsewhere warns and trains in full
|
|
239
|
+
precision rather than quietly substituting CPU bfloat16 autocast, which is a
|
|
240
|
+
different mechanism with a different numerical story.
|
|
241
|
+
* **`fused_loss` was measured below the noise floor** on its own, across all six
|
|
242
|
+
ablation configurations. It stays on by default for a different reason: it
|
|
243
|
+
keeps the dispatched shape constant as members finish, which is what CUDA
|
|
244
|
+
Graph capture requires. Do not expect it to be the flag that makes your run
|
|
245
|
+
fast, and do not go looking for the win it does not have.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## What to expect
|
|
250
|
+
|
|
251
|
+
Two mechanisms, and they are worth telling apart:
|
|
252
|
+
|
|
253
|
+
| Regime | Mechanism | Measured |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| **Heterogeneous** (buckets of one) | one CUDA stream per bucket, one process, one dataset | **1.47×** faster and **33 % less energy** |
|
|
256
|
+
| **Homogeneous** (buckets of K) | `vmap` over stacked parameters | **1.58×**, and **2.25×** with `torch.compile` |
|
|
257
|
+
|
|
258
|
+
Those numbers come from the thesis this code was extracted from (T4 and L4,
|
|
259
|
+
CIFAR-scale CNNs and transformers). Yours will differ. Two things they do say:
|
|
260
|
+
|
|
261
|
+
* **Stacking models that have nothing in common already pays**, before any
|
|
262
|
+
vectorisation. Most of that comes from not paying K times for the process, the
|
|
263
|
+
CUDA context and the dataset.
|
|
264
|
+
* **Overlap does not fix a bottleneck outside the GPU.** In a workload that was
|
|
265
|
+
data-loading-bound (30 % device utilisation), streams bought nothing. Check
|
|
266
|
+
where your time goes before expecting a speed-up here.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## What it does not do
|
|
271
|
+
|
|
272
|
+
* **No trial pruning.** Every point of an `hpo` grid runs to its last epoch.
|
|
273
|
+
For real searches, a scheduler that stops bad trials early (ASHA, Hyperband,
|
|
274
|
+
median stopping) usually beats fusion — in the thesis's own measurements,
|
|
275
|
+
pruning was worth ~30 % on its own, more than the entire difference between
|
|
276
|
+
execution strategies. The two compose (a pruned trial is just a member frozen
|
|
277
|
+
early, which the runtime already supports) and that is the top item on the
|
|
278
|
+
roadmap. Until then: if your search is long and your trials are separable,
|
|
279
|
+
Ray Tune will beat this.
|
|
280
|
+
* **One GPU.** No multi-device, no distributed training.
|
|
281
|
+
* **No memory estimation, and no promise about fitting.** chorus trains
|
|
282
|
+
everything you add, at once. It does not profile your models, does not guess
|
|
283
|
+
how many fit, and will not quietly train fewer than you asked for. If the
|
|
284
|
+
total exceeds your VRAM you get a CUDA OOM — set `max_parallel=N` and it runs
|
|
285
|
+
in chunks of N. The ceiling is yours to set, deliberately: the alternative is
|
|
286
|
+
a guess that refuses workloads which would have run.
|
|
287
|
+
* **No CPU offload** of finished members' weights, so peak VRAM is the sum of
|
|
288
|
+
everything co-resident for as long as the chunk lasts.
|
|
289
|
+
* **Round-robin only** between buckets. The original paper describes four
|
|
290
|
+
schedulers; this implements one.
|
|
291
|
+
* **`vmap`-able models only** for the fast path. Data-dependent control flow in
|
|
292
|
+
the forward falls back to eager, correctly and automatically.
|
|
293
|
+
* **The equivalence check compares outputs, not buffers.** It catches two models
|
|
294
|
+
that compute different things; it does not catch a layer whose *state* the
|
|
295
|
+
batching rule updates through a copy — the BatchNorm failure that
|
|
296
|
+
`norms.py` handles by hand for the layers it knows about. A model with an
|
|
297
|
+
unusual stateful layer is still a manual analysis. Closing that is item 2 of
|
|
298
|
+
the roadmap.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Roadmap
|
|
303
|
+
|
|
304
|
+
These are the open lines from the thesis's future-work chapter, restated for the
|
|
305
|
+
library and ordered by what they are worth. Each one shares a condition worth
|
|
306
|
+
saying once: implementing it is not the hard part, **measuring what it buys
|
|
307
|
+
is**.
|
|
308
|
+
|
|
309
|
+
**1. Trial pruning for hyperparameter search.** The gap the evaluation left most
|
|
310
|
+
exposed. Ray won all four HPO experiments without compilation, and not by
|
|
311
|
+
running faster but by running less — within Ray itself, ASHA took 134 s against
|
|
312
|
+
190 s for FIFO, so pruning was worth more than the entire difference between
|
|
313
|
+
concurrency strategies. It is orthogonal to fusion and the two compose: the
|
|
314
|
+
epoch mask that already freezes finished members works unchanged to retire a
|
|
315
|
+
trial at whatever step a scheduler decides, and the bucket keeps running
|
|
316
|
+
vectorised with whoever is left. Expect the gain to be multiplicative, not
|
|
317
|
+
additive — fusion over the trials that survive pruning.
|
|
318
|
+
*Already here:* `Bucket.finish_member`, the mask, and per-member learning rates.
|
|
319
|
+
*Missing:* a scheduler interface, and a per-epoch validation signal to feed it.
|
|
320
|
+
|
|
321
|
+
**2. Verification that compares buffers, not outputs.** `vmap` imposes a
|
|
322
|
+
condition on the code it transforms, and this port resolves it in a bounded way:
|
|
323
|
+
by hand, for the stateful layers of the models that were evaluated. A model with
|
|
324
|
+
a different stateful layer would need that analysis repeated, and nothing warns
|
|
325
|
+
you. The answer is not a static purity analyser — even a perfect one looks at
|
|
326
|
+
the wrong level, since the layer's source *is* impure and the thing that breaks
|
|
327
|
+
(the batching rule interposing a copy) happens below what static analysis sees.
|
|
328
|
+
It has to be dynamic, and it is cheap: on building a bucket, run one training
|
|
329
|
+
step down each route and compare **which buffers each one modified**, falling
|
|
330
|
+
back to eager when they differ. A module with no buffers has no state to lose,
|
|
331
|
+
which makes a cheap pre-filter.
|
|
332
|
+
The open question underneath: specialising a layer by hand costs a fixed
|
|
333
|
+
per-step overhead amortised across K members, while relying on the check costs
|
|
334
|
+
that bucket's whole vectorisation. Since fusion pays little at small K, there
|
|
335
|
+
should be a K below which falling back is nearly free — finding it says when a
|
|
336
|
+
new layer is worth specialising.
|
|
337
|
+
*Already here:* the output-level check in `vmap_matches_eager`.
|
|
338
|
+
|
|
339
|
+
**3. Where the ceiling actually is.** chorus deliberately does not estimate how
|
|
340
|
+
many models fit — but nobody knows how many *should* run at once either, because
|
|
341
|
+
co-resident work contends for GPU internals that no static model of VRAM and
|
|
342
|
+
cores can see. A sweep that raises the number of concurrent models until the
|
|
343
|
+
aggregate-throughput curve bends would measure how much of the expected margin
|
|
344
|
+
contention eats, and turn `max_parallel` from a guess into a measurement.
|
|
345
|
+
Measuring SM occupancy directly is not the way: profiler-level counters (`ncu`,
|
|
346
|
+
CUPTI) serialise launches and destroy the wall-clock number that matters.
|
|
347
|
+
|
|
348
|
+
**4. The shared loader, in both directions.** Within a bucket whose members read
|
|
349
|
+
the same data, one DataLoader feeds all K — a large part of why this wins on
|
|
350
|
+
data-bound workloads. It has a cost and a symmetry, and neither is explored.
|
|
351
|
+
*The cost:* the K members consume the same tensor, so they share the shuffle
|
|
352
|
+
**and** the augmentations, which correlates their errors and can only subtract
|
|
353
|
+
from a committee's quality. Recovering the augmentation diversity is cheap —
|
|
354
|
+
apply it per member on the already-loaded batch, so each gets its own crop of
|
|
355
|
+
the same images, with no second decode. Recovering the *ordering* diversity is
|
|
356
|
+
expensive: which samples land together is a property of sampling, and undoing it
|
|
357
|
+
needs one loader per member, which is what sharing avoids.
|
|
358
|
+
*The symmetry:* cross-validation folds do build K loaders, and with five folds
|
|
359
|
+
the training sets overlap by 80 %, so every sample crosses the transform
|
|
360
|
+
pipeline four times per epoch. Sharing the pipeline and selecting per member
|
|
361
|
+
would remove that. The tension: the direct way — one common batch from which
|
|
362
|
+
each member drops its own validation fold — makes the effective batch size
|
|
363
|
+
stochastic, and with it the equivalence to an independently run
|
|
364
|
+
cross-validation.
|
|
365
|
+
|
|
366
|
+
**5. The parts of the original design still missing.** When a sub-model
|
|
367
|
+
finishes, UnifiedNN copies its weights to CPU memory and releases its share of
|
|
368
|
+
VRAM progressively; that matters in heterogeneous workloads, where members
|
|
369
|
+
finish at very different times, and it is what would let freed memory admit
|
|
370
|
+
incoming work. In the same direction, the original defines four scheduling
|
|
371
|
+
policies between sub-models and this implements one — round-robin, which the
|
|
372
|
+
paper itself calls suboptimal when per-epoch durations differ widely. The two
|
|
373
|
+
belong together: they pay off in the same scenario.
|
|
374
|
+
|
|
375
|
+
**6. The cloud scenario.** The approach was designed for multiple users
|
|
376
|
+
submitting heterogeneous models and datasets, with dynamic schedulers by
|
|
377
|
+
priority or job size. chorus, like the thesis, evaluates the single-tenant case.
|
|
378
|
+
Adapting it would need continuous job arrivals, which changes the shape of the
|
|
379
|
+
runtime — and would allow comparing both approaches on the scenario UnifiedNN
|
|
380
|
+
was actually conceived for.
|
|
381
|
+
|
|
382
|
+
**7. Coordinated scheduling and memory between buckets.** Salus and Zico show
|
|
383
|
+
that switching jobs at iteration boundaries and sharing ephemeral memory sustains
|
|
384
|
+
more concurrent training than a static allocation admits — but both need an
|
|
385
|
+
intermediary runtime, because the processes they schedule cannot observe each
|
|
386
|
+
other. chorus has no such barrier: its members share a process, a context and an
|
|
387
|
+
allocator, so each one's phase (forward, backward, update) is directly
|
|
388
|
+
observable. Staggering buckets so that one's backward, which frees activations,
|
|
389
|
+
overlaps the other's forward, which reserves them, would cut peak VRAM without
|
|
390
|
+
touching the CUDA stack or the framework. Complementary to item 5: that one
|
|
391
|
+
attacks the persistent memory of finished members, this one the ephemeral memory
|
|
392
|
+
of active ones.
|
|
393
|
+
|
|
394
|
+
## Fidelity to the executor it was extracted from
|
|
395
|
+
|
|
396
|
+
chorus was pulled out of an execution strategy that had been measured over a
|
|
397
|
+
504-run campaign, and the plan is to run both side by side on the same
|
|
398
|
+
benchmark: is the port faithful, and where can it now be made faster. That only
|
|
399
|
+
works if every difference is written down, so they are — `docs/fidelity.md`
|
|
400
|
+
lists what was aligned back to the original, what is identical by construction,
|
|
401
|
+
and what differs on purpose (no memory estimation, the vectorisation check, no
|
|
402
|
+
inner stream for a bucket of one, and this library not touching
|
|
403
|
+
`cudnn.benchmark` or any other global of your process).
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Where this comes from
|
|
408
|
+
|
|
409
|
+
chorus implements the approach of Taki et al., *UnifiedNN: Efficient Neural
|
|
410
|
+
Network Training on the Cloud* (2024), whose authors published no code, plus the
|
|
411
|
+
extensions developed and measured in the thesis it was extracted from:
|
|
412
|
+
vectorised forward via `torch.vmap`, a fused loss over the stacked batch, and
|
|
413
|
+
per-member hyperparameters on the stacked optimizer — without which a
|
|
414
|
+
hyperparameter sweep silently collapses onto the first trial's values.
|
|
415
|
+
|
|
416
|
+
That thesis is *Desarrollo de estrategias para el entrenamiento paralelo de
|
|
417
|
+
modelos neuronales en GPUs individuales* (Joaquín Arroyo, [Licenciatura en
|
|
418
|
+
Ciencias de la Computación](https://dcc.fceia.unr.edu.ar), UNR), and it lives in
|
|
419
|
+
[joaquinarroyo/thesis](https://github.com/joaquinarroyo/thesis). The numbers
|
|
420
|
+
quoted throughout this README were produced by
|
|
421
|
+
[fugue](https://github.com/joaquinarroyo/fugue), the benchmark harness written
|
|
422
|
+
for it — chorus is the unified-model executor from there, pulled out and made
|
|
423
|
+
usable on its own.
|
|
424
|
+
|
|
425
|
+
The comments in `src/chorus/` are the interesting part of that history: every
|
|
426
|
+
long one marks something that produced plausible, wrong numbers before it was
|
|
427
|
+
understood. `norms.py` (BatchNorm statistics frozen at (0, 1) under `vmap` +
|
|
428
|
+
AMP), `optim.py` (the sweep that stopped sweeping), `runtime.py` (a stream race
|
|
429
|
+
that only hurt at scale, and momentum carrying a "finished" member for tens of
|
|
430
|
+
steps).
|
|
431
|
+
|
|
432
|
+
MIT licensed.
|