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.
Files changed (36) hide show
  1. chorus_torch-0.1.0/LICENSE +21 -0
  2. chorus_torch-0.1.0/PKG-INFO +432 -0
  3. chorus_torch-0.1.0/README.md +402 -0
  4. chorus_torch-0.1.0/pyproject.toml +130 -0
  5. chorus_torch-0.1.0/setup.cfg +4 -0
  6. chorus_torch-0.1.0/src/chorus/__init__.py +114 -0
  7. chorus_torch-0.1.0/src/chorus/bucketing.py +150 -0
  8. chorus_torch-0.1.0/src/chorus/functional.py +153 -0
  9. chorus_torch-0.1.0/src/chorus/hyperparams.py +67 -0
  10. chorus_torch-0.1.0/src/chorus/log.py +74 -0
  11. chorus_torch-0.1.0/src/chorus/methods.py +280 -0
  12. chorus_torch-0.1.0/src/chorus/norms.py +233 -0
  13. chorus_torch-0.1.0/src/chorus/objectives.py +282 -0
  14. chorus_torch-0.1.0/src/chorus/optim.py +530 -0
  15. chorus_torch-0.1.0/src/chorus/options.py +180 -0
  16. chorus_torch-0.1.0/src/chorus/py.typed +0 -0
  17. chorus_torch-0.1.0/src/chorus/report.py +205 -0
  18. chorus_torch-0.1.0/src/chorus/runtime.py +634 -0
  19. chorus_torch-0.1.0/src/chorus/seeding.py +40 -0
  20. chorus_torch-0.1.0/src/chorus/splitting.py +65 -0
  21. chorus_torch-0.1.0/src/chorus/stack.py +554 -0
  22. chorus_torch-0.1.0/src/chorus/trainer.py +704 -0
  23. chorus_torch-0.1.0/src/chorus_torch.egg-info/PKG-INFO +432 -0
  24. chorus_torch-0.1.0/src/chorus_torch.egg-info/SOURCES.txt +34 -0
  25. chorus_torch-0.1.0/src/chorus_torch.egg-info/dependency_links.txt +1 -0
  26. chorus_torch-0.1.0/src/chorus_torch.egg-info/requires.txt +8 -0
  27. chorus_torch-0.1.0/src/chorus_torch.egg-info/top_level.txt +1 -0
  28. chorus_torch-0.1.0/tests/test_bucketing.py +102 -0
  29. chorus_torch-0.1.0/tests/test_fidelity.py +125 -0
  30. chorus_torch-0.1.0/tests/test_functional.py +89 -0
  31. chorus_torch-0.1.0/tests/test_optim.py +182 -0
  32. chorus_torch-0.1.0/tests/test_options.py +361 -0
  33. chorus_torch-0.1.0/tests/test_runtime.py +289 -0
  34. chorus_torch-0.1.0/tests/test_splitting.py +53 -0
  35. chorus_torch-0.1.0/tests/test_stack.py +225 -0
  36. 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.