trainstream 0.1.1__tar.gz → 0.3.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 (43) hide show
  1. {trainstream-0.1.1/trainstream.egg-info → trainstream-0.3.0}/PKG-INFO +39 -8
  2. trainstream-0.3.0/README.md +118 -0
  3. {trainstream-0.1.1 → trainstream-0.3.0}/setup.py +1 -1
  4. trainstream-0.3.0/tests/test_distributed.py +82 -0
  5. trainstream-0.3.0/tests/test_features.py +279 -0
  6. {trainstream-0.1.1 → trainstream-0.3.0}/tests/test_huggingface.py +60 -7
  7. {trainstream-0.1.1 → trainstream-0.3.0}/tests/test_offload.py +18 -3
  8. {trainstream-0.1.1 → trainstream-0.3.0}/tests/test_optimizer.py +1 -1
  9. trainstream-0.3.0/tests/test_paging.py +259 -0
  10. trainstream-0.3.0/tests/test_planner.py +247 -0
  11. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/__init__.py +10 -4
  12. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/bindings.cpp +21 -5
  13. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/kernels.h +6 -3
  14. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.cpp +250 -55
  15. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.h +55 -7
  16. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/offload_adamw_kernel.cu +54 -17
  17. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/hooks.py +8 -6
  18. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/integrations/huggingface.py +85 -1
  19. trainstream-0.3.0/trainstream/losses.py +103 -0
  20. trainstream-0.3.0/trainstream/optimizer.py +674 -0
  21. trainstream-0.3.0/trainstream/paging.py +336 -0
  22. trainstream-0.3.0/trainstream/planner.py +208 -0
  23. trainstream-0.3.0/trainstream/wrapper.py +609 -0
  24. {trainstream-0.1.1 → trainstream-0.3.0/trainstream.egg-info}/PKG-INFO +39 -8
  25. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream.egg-info/SOURCES.txt +7 -0
  26. trainstream-0.1.1/README.md +0 -87
  27. trainstream-0.1.1/trainstream/optimizer.py +0 -333
  28. trainstream-0.1.1/trainstream/wrapper.py +0 -191
  29. {trainstream-0.1.1 → trainstream-0.3.0}/LICENSE +0 -0
  30. {trainstream-0.1.1 → trainstream-0.3.0}/MANIFEST.in +0 -0
  31. {trainstream-0.1.1 → trainstream-0.3.0}/NOTICE +0 -0
  32. {trainstream-0.1.1 → trainstream-0.3.0}/build.bat +0 -0
  33. {trainstream-0.1.1 → trainstream-0.3.0}/pyproject.toml +0 -0
  34. {trainstream-0.1.1 → trainstream-0.3.0}/setup.cfg +0 -0
  35. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/_build.py +0 -0
  36. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/memory_pinner.cpp +0 -0
  37. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/csrc/trainstream.h +0 -0
  38. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/integrations/__init__.py +0 -0
  39. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream/profiler.py +0 -0
  40. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream.egg-info/dependency_links.txt +0 -0
  41. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream.egg-info/not-zip-safe +0 -0
  42. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream.egg-info/requires.txt +0 -0
  43. {trainstream-0.1.1 → trainstream-0.3.0}/trainstream.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainstream
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Zero-dependency PCIe offloading engine for PyTorch training (optimizer, gradients, activations).
5
5
  Home-page: https://github.com/johnanishg/TrainStream
6
6
  Author: John Anish G
@@ -40,7 +40,7 @@ Dynamic: summary
40
40
 
41
41
  # TrainStream
42
42
 
43
- Train and fine-tune models larger than your GPU's memory by moving optimizer state, gradients and activations to system RAM over PCIe — with one line of code and no third-party dependencies.
43
+ Train and fine-tune models larger than your GPU's memory by moving optimizer state, gradients, activations and, when needed, the weights themselves to system RAM over PCIe. It takes one line of code and has no third-party dependencies.
44
44
 
45
45
  ```python
46
46
  import trainstream
@@ -55,15 +55,19 @@ TrainStream is a C++/CUDA extension for PyTorch. It keeps only the weights in VR
55
55
  |---|---|---|
56
56
  | 1.8B-parameter GPT, fp32, batch 8×512 | 34.1 GiB peak — exceeds VRAM, driver spills to RAM, 13.7 s/step | 20.6 GiB, 1.1 s/step (13.5 GiB with all activations paged) |
57
57
  | Qwen2.5-1.5B full fine-tune (Hugging Face Trainer) | 35.9 GiB peak (spill), 111.7 s / 10 steps | 18.7 GiB, 28.5 s / 10 steps, same losses |
58
- | 85M GPT (fits either way) | 2.5 GiB, ~94k tokens/s | 1.8 GiB, ~64k tokens/s |
58
+ | 1.8B GPT, weights paged to RAM (0.2) | — | 2.4 GiB peak, 2.1 s/step; 1.84 GiB inside a 4 GB VRAM cap with bf16 moments |
59
+ | 3.6B GPT full fine-tune (all parameters trained) | needs ~58 GB of VRAM | 3.1 GiB peak, 3.15 s/step, 30 GiB of host RAM |
60
+ | LoRA r=16 on the Qwen2.5-7B architecture (random weights) | 20.3 GB peak, 0.27 s/step | 4.6 GB peak, 1.25 s/step |
61
+ | LoRA r=16 on Qwen2.5-14B (29.5 GB of weights) | does not fit | 1.37 s/step: the memory planner keeps 28 of 48 layers in VRAM (19.9 GiB peak) and streams 20 from RAM |
62
+ | 85M GPT (fits either way) | 2.55 GiB, 43.7 ms/step | `offload="auto"` switches to stock fused AdamW: 2.56 GiB, 43.8 ms/step |
59
63
 
60
- When a model already fits, TrainStream trades throughput for memory. Its value is training what does not fit, or freeing VRAM for larger batches. All numbers come from a single machine (RTX 5090 Laptop, Windows 11).
64
+ Offloading costs throughput when a model already fits, which is why `offload="auto"` measures two steps and then switches to plain fused GPU AdamW if offloading isn't needed. TrainStream's value is training what does not fit, or freeing VRAM for larger batches. All numbers come from a single machine (RTX 5090 Laptop, Windows 11).
61
65
 
62
66
  ## Requirements
63
67
 
64
68
  - An NVIDIA GPU and a CUDA build of PyTorch
65
69
  - The CUDA toolkit matching PyTorch's CUDA major version, and a C++ compiler (Visual Studio 2022+ Build Tools with "Desktop development with C++" on Windows, GCC 11+ on Linux)
66
- - Tested on Windows 11, PyTorch 2.14.1+cu130, CUDA 13.4, Python 3.14. Linux uses the same build path but is untested.
70
+ - Tested on Windows 11 (PyTorch 2.14.1, CUDA 13.4, MSVC, Python 3.14) and Ubuntu 24.04 under WSL2 (PyTorch 2.14.1+cu130, CUDA 13.3, GCC 13.3, Python 3.12): all 89 tests pass on both, RTX 5090 Laptop GPU. Native Linux and other GPUs are untested.
67
71
 
68
72
  ## Install
69
73
 
@@ -100,6 +104,29 @@ for x, y in loader:
100
104
  optimizer.zero_grad()
101
105
  ```
102
106
 
107
+ Models bigger than the GPU: build on the CPU and let TrainStream page the transformer-block weights:
108
+
109
+ ```python
110
+ model = MyGPT(cfg) # stays on the CPU
111
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
112
+ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
113
+ param_offload=True, moment_dtype="bfloat16")
114
+ ```
115
+
116
+ `examples/train_bigger_than_vram.py` trains a 1.8B model (7.3 GB of fp32 weights) inside a 4 GB VRAM cap. `examples/lora_large_model.py` LoRA-fine-tunes a 14.8B-parameter base through `TrainStreamTrainer`: pass the model on the CPU and its layers stay in host RAM.
117
+
118
+ | `wrap()` option | What it does |
119
+ |---|---|
120
+ | `offload="auto"` | switches to stock fused AdamW after 2 steps if the model fits (`True` always offloads) |
121
+ | `param_offload="auto"` | memory planner: measures the VRAM and RAM of the machine it runs on, keeps as many transformer blocks resident as fit, pages the rest, and refines the split from the first steps' measured peaks; `True`, a fraction, or block indices fix it |
122
+ | `activation_offload="auto"` | pages a profiled share of activations (`True`, a fraction, `False`) |
123
+ | `moment_dtype="auto"` | fp32 optimizer moments, or bf16 (stochastic rounding, half the memory) when fp32 wouldn't fit in RAM |
124
+ | `check_memory=True` | refuse at `wrap()` with a clear explanation when the run can't fit in RAM/VRAM |
125
+ | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
126
+ | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
127
+
128
+ Also: `trainstream.chunked_cross_entropy(hidden, weight, targets)` computes the loss without materializing the logits. `.to()`, `.cuda()` and `.half()` work after `wrap()`, and reading a weight anywhere waits for its in-flight update.
129
+
103
130
  Hugging Face (`pip install transformers accelerate`):
104
131
 
105
132
  ```python
@@ -108,18 +135,22 @@ trainer = TrainStreamTrainer(model=model, args=TrainingArguments(..., bf16=True)
108
135
  trainer.train()
109
136
  ```
110
137
 
111
- Use `bf16=True` or fp32; fp16 with loss scaling is not supported.
138
+ `TrainStreamTrainer` computes the causal-LM loss in chunks, so the full logits tensor never exists. Pass extra `wrap()` options as `trainstream={...}`. Use `bf16=True` or fp32; fp16 with loss scaling is not supported.
112
139
 
113
140
  ## Tools in this repository
114
141
 
115
142
  - `examples/verify_ram_offload.py` — proves where training memory lives (VRAM vs system RAM) with a PASS/FAIL verdict
116
143
  - `examples/hf_finetune.py` — fine-tune a Hugging Face model, optionally side by side with the stock Trainer
144
+ - `examples/train_bigger_than_vram.py` — weight paging on a model built on the CPU, under a VRAM cap
145
+ - `examples/lora_large_model.py` — LoRA on 7B/14B-size bases larger than the GPU, optionally against the stock Trainer
117
146
  - `docs/index.html` — full documentation
118
147
 
119
148
  ## Limitations
120
149
 
121
- - Weights stay in VRAM (no parameter offloading); large-vocabulary output logits are not offloaded
122
- - Single GPU per process
150
+ - Host RAM is the ceiling: 8–12 bytes per trained parameter and 2–4 per frozen one (LoRA base), plus activations; measured on 64 GB: a 3.6B full fine-tune and LoRA on a 14.8B base
151
+ - Weight paging covers the repeated block stack and needs `optimizer_mode="cpu"`; each paged weight crosses PCIe twice per step
152
+ - Data parallel only (no tensor/pipeline parallelism); tested with 2 processes on 1 GPU, not across several GPUs or machines
153
+ - `TrainStreamTrainer` is single-process; fp16 + GradScaler and AMSGrad aren't supported; `torch.compile` hasn't been tested
123
154
  - Host AdamW speed is bounded by system RAM bandwidth
124
155
 
125
156
  ## License
@@ -0,0 +1,118 @@
1
+ # TrainStream
2
+
3
+ Train and fine-tune models larger than your GPU's memory by moving optimizer state, gradients, activations and, when needed, the weights themselves to system RAM over PCIe. It takes one line of code and has no third-party dependencies.
4
+
5
+ ```python
6
+ import trainstream
7
+ model, optimizer = trainstream.wrap(model, optimizer)
8
+ ```
9
+
10
+ TrainStream is a C++/CUDA extension for PyTorch. It keeps only the weights in VRAM: gradients stream to pinned host memory as autograd produces them, AdamW runs on host threads while the backward pass continues, and a profiled share of activations is paged out and prefetched back block by block. Updates match `torch.optim.AdamW` (max parameter difference ≈3·10⁻⁷ after 20 steps); activation paging is bit-exact.
11
+
12
+ ## Results (RTX 5090 Laptop GPU, 24 GB)
13
+
14
+ | Workload | Stock PyTorch AdamW | TrainStream |
15
+ |---|---|---|
16
+ | 1.8B-parameter GPT, fp32, batch 8×512 | 34.1 GiB peak — exceeds VRAM, driver spills to RAM, 13.7 s/step | 20.6 GiB, 1.1 s/step (13.5 GiB with all activations paged) |
17
+ | Qwen2.5-1.5B full fine-tune (Hugging Face Trainer) | 35.9 GiB peak (spill), 111.7 s / 10 steps | 18.7 GiB, 28.5 s / 10 steps, same losses |
18
+ | 1.8B GPT, weights paged to RAM (0.2) | — | 2.4 GiB peak, 2.1 s/step; 1.84 GiB inside a 4 GB VRAM cap with bf16 moments |
19
+ | 3.6B GPT full fine-tune (all parameters trained) | needs ~58 GB of VRAM | 3.1 GiB peak, 3.15 s/step, 30 GiB of host RAM |
20
+ | LoRA r=16 on the Qwen2.5-7B architecture (random weights) | 20.3 GB peak, 0.27 s/step | 4.6 GB peak, 1.25 s/step |
21
+ | LoRA r=16 on Qwen2.5-14B (29.5 GB of weights) | does not fit | 1.37 s/step: the memory planner keeps 28 of 48 layers in VRAM (19.9 GiB peak) and streams 20 from RAM |
22
+ | 85M GPT (fits either way) | 2.55 GiB, 43.7 ms/step | `offload="auto"` switches to stock fused AdamW: 2.56 GiB, 43.8 ms/step |
23
+
24
+ Offloading costs throughput when a model already fits, which is why `offload="auto"` measures two steps and then switches to plain fused GPU AdamW if offloading isn't needed. TrainStream's value is training what does not fit, or freeing VRAM for larger batches. All numbers come from a single machine (RTX 5090 Laptop, Windows 11).
25
+
26
+ ## Requirements
27
+
28
+ - An NVIDIA GPU and a CUDA build of PyTorch
29
+ - The CUDA toolkit matching PyTorch's CUDA major version, and a C++ compiler (Visual Studio 2022+ Build Tools with "Desktop development with C++" on Windows, GCC 11+ on Linux)
30
+ - Tested on Windows 11 (PyTorch 2.14.1, CUDA 13.4, MSVC, Python 3.14) and Ubuntu 24.04 under WSL2 (PyTorch 2.14.1+cu130, CUDA 13.3, GCC 13.3, Python 3.12): all 89 tests pass on both, RTX 5090 Laptop GPU. Native Linux and other GPUs are untested.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install torch --index-url https://download.pytorch.org/whl/cu130 # if you don't have CUDA PyTorch yet
36
+ pip install trainstream
37
+ ```
38
+
39
+ The first `import trainstream` compiles the CUDA extension against your PyTorch (a few minutes, once) and caches it; every later import is instant. On Windows it finds Visual Studio and the CUDA toolkit itself, so any terminal works. The cache is keyed by PyTorch/CUDA/Python version and GPU, so upgrading PyTorch triggers one rebuild.
40
+
41
+ | Variable | Effect |
42
+ |---|---|
43
+ | `TRAINSTREAM_CACHE_DIR` | where builds are cached (default `%LOCALAPPDATA%\trainstream\build`, Linux `~/.cache/trainstream`) |
44
+ | `TRAINSTREAM_VERBOSE_BUILD=1` | show compiler output |
45
+ | `TORCH_CUDA_ARCH_LIST` | GPU architectures to compile for (default: the GPUs present) |
46
+ | `CUDA_HOME` | CUDA toolkit to use, if it isn't found automatically |
47
+
48
+ To compile ahead of time instead: `pip install trainstream --no-binary trainstream --no-build-isolation`. From a checkout: `build.bat` (Windows) or `python setup.py build_ext --inplace`.
49
+
50
+ ## Usage
51
+
52
+ ```python
53
+ import torch, trainstream
54
+
55
+ model = MyModel().cuda()
56
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
57
+ model, optimizer = trainstream.wrap(model, optimizer) # max_grad_norm=..., accumulation_steps=... if you use them
58
+
59
+ for x, y in loader:
60
+ with torch.autocast("cuda", dtype=torch.bfloat16):
61
+ loss = model(x.cuda(), y.cuda())
62
+ loss.backward()
63
+ optimizer.step()
64
+ optimizer.zero_grad()
65
+ ```
66
+
67
+ Models bigger than the GPU: build on the CPU and let TrainStream page the transformer-block weights:
68
+
69
+ ```python
70
+ model = MyGPT(cfg) # stays on the CPU
71
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
72
+ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
73
+ param_offload=True, moment_dtype="bfloat16")
74
+ ```
75
+
76
+ `examples/train_bigger_than_vram.py` trains a 1.8B model (7.3 GB of fp32 weights) inside a 4 GB VRAM cap. `examples/lora_large_model.py` LoRA-fine-tunes a 14.8B-parameter base through `TrainStreamTrainer`: pass the model on the CPU and its layers stay in host RAM.
77
+
78
+ | `wrap()` option | What it does |
79
+ |---|---|
80
+ | `offload="auto"` | switches to stock fused AdamW after 2 steps if the model fits (`True` always offloads) |
81
+ | `param_offload="auto"` | memory planner: measures the VRAM and RAM of the machine it runs on, keeps as many transformer blocks resident as fit, pages the rest, and refines the split from the first steps' measured peaks; `True`, a fraction, or block indices fix it |
82
+ | `activation_offload="auto"` | pages a profiled share of activations (`True`, a fraction, `False`) |
83
+ | `moment_dtype="auto"` | fp32 optimizer moments, or bf16 (stochastic rounding, half the memory) when fp32 wouldn't fit in RAM |
84
+ | `check_memory=True` | refuse at `wrap()` with a clear explanation when the run can't fit in RAM/VRAM |
85
+ | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
86
+ | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
87
+
88
+ Also: `trainstream.chunked_cross_entropy(hidden, weight, targets)` computes the loss without materializing the logits. `.to()`, `.cuda()` and `.half()` work after `wrap()`, and reading a weight anywhere waits for its in-flight update.
89
+
90
+ Hugging Face (`pip install transformers accelerate`):
91
+
92
+ ```python
93
+ from trainstream.integrations.huggingface import TrainStreamTrainer
94
+ trainer = TrainStreamTrainer(model=model, args=TrainingArguments(..., bf16=True), train_dataset=ds)
95
+ trainer.train()
96
+ ```
97
+
98
+ `TrainStreamTrainer` computes the causal-LM loss in chunks, so the full logits tensor never exists. Pass extra `wrap()` options as `trainstream={...}`. Use `bf16=True` or fp32; fp16 with loss scaling is not supported.
99
+
100
+ ## Tools in this repository
101
+
102
+ - `examples/verify_ram_offload.py` — proves where training memory lives (VRAM vs system RAM) with a PASS/FAIL verdict
103
+ - `examples/hf_finetune.py` — fine-tune a Hugging Face model, optionally side by side with the stock Trainer
104
+ - `examples/train_bigger_than_vram.py` — weight paging on a model built on the CPU, under a VRAM cap
105
+ - `examples/lora_large_model.py` — LoRA on 7B/14B-size bases larger than the GPU, optionally against the stock Trainer
106
+ - `docs/index.html` — full documentation
107
+
108
+ ## Limitations
109
+
110
+ - Host RAM is the ceiling: 8–12 bytes per trained parameter and 2–4 per frozen one (LoRA base), plus activations; measured on 64 GB: a 3.6B full fine-tune and LoRA on a 14.8B base
111
+ - Weight paging covers the repeated block stack and needs `optimizer_mode="cpu"`; each paged weight crosses PCIe twice per step
112
+ - Data parallel only (no tensor/pipeline parallelism); tested with 2 processes on 1 GPU, not across several GPUs or machines
113
+ - `TrainStreamTrainer` is single-process; fp16 + GradScaler and AMSGrad aren't supported; `torch.compile` hasn't been tested
114
+ - Host AdamW speed is bounded by system RAM bandwidth
115
+
116
+ ## License
117
+
118
+ Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Copyright 2026 John Anish G.
@@ -66,7 +66,7 @@ with open(os.path.join(HERE, "README.md"), encoding="utf-8") as fh:
66
66
 
67
67
  setup(
68
68
  name="trainstream",
69
- version="0.1.1",
69
+ version="0.3.0",
70
70
  author="John Anish G",
71
71
  license="Apache-2.0",
72
72
  description="Zero-dependency PCIe offloading engine for PyTorch training (optimizer, gradients, activations).",
@@ -0,0 +1,82 @@
1
+ """Data-parallel training: N processes, gradients averaged on the host.
2
+
3
+ Two ranks share one GPU here (gloo needs no second card). Each rank trains on
4
+ its own half of every batch; the result must equal one process training on the
5
+ whole batch with torch.optim.AdamW.
6
+ """
7
+
8
+ import copy
9
+ import os
10
+ import pathlib
11
+ import tempfile
12
+
13
+ import pytest
14
+ import torch
15
+ import torch.distributed as dist
16
+ import torch.multiprocessing as mp
17
+ from torch import nn
18
+ from torch.nn import functional as F
19
+
20
+ pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
21
+
22
+ HP = dict(lr=2e-3, betas=(0.9, 0.95), weight_decay=0.05)
23
+ WORLD = 2
24
+ STEPS = 6
25
+
26
+
27
+ def make_model(seed):
28
+ torch.manual_seed(seed)
29
+ return nn.Sequential(nn.Linear(32, 128), nn.GELU(), nn.Linear(128, 128), nn.GELU(), nn.Linear(128, 8))
30
+
31
+
32
+ def full_batches(accum):
33
+ g = torch.Generator().manual_seed(7)
34
+ return [[(torch.randn(16, 32, generator=g), torch.randint(0, 8, (16,), generator=g)) for _ in range(accum)]
35
+ for _ in range(STEPS)]
36
+
37
+
38
+ def _worker(rank, store, out, clip, accum, param_offload):
39
+ import trainstream
40
+ dist.init_process_group("gloo", init_method=pathlib.Path(store).as_uri(), rank=rank, world_size=WORLD)
41
+ model = make_model(seed=100 + rank).cuda() # different init per rank: wrap() broadcasts rank 0's
42
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), offload=True,
43
+ activation_offload=False, param_offload=param_offload, max_grad_norm=clip,
44
+ accumulation_steps=accum, blocks=[model[0], model[2]] if param_offload else None)
45
+ assert opt._dp is not None # detected from the initialized process group
46
+ for micro in full_batches(accum):
47
+ for x, y in micro:
48
+ xs, ys = x.chunk(WORLD)[rank].cuda(), y.chunk(WORLD)[rank].cuda()
49
+ (F.cross_entropy(model(xs), ys) / accum).backward()
50
+ opt.step()
51
+ opt.zero_grad()
52
+ opt.synchronize()
53
+ sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
54
+ torch.save(sd, os.path.join(out, f"rank{rank}.pt"))
55
+ trainstream.unwrap(model)
56
+ dist.destroy_process_group()
57
+
58
+
59
+ def reference(clip, accum):
60
+ model = make_model(seed=100).cuda()
61
+ opt = torch.optim.AdamW(model.parameters(), foreach=False, **HP)
62
+ for micro in full_batches(accum):
63
+ for x, y in micro:
64
+ (F.cross_entropy(model(x.cuda()), y.cuda()) / accum).backward()
65
+ if clip:
66
+ torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
67
+ opt.step()
68
+ opt.zero_grad()
69
+ return {k: v.detach().cpu() for k, v in model.state_dict().items()}
70
+
71
+
72
+ @pytest.mark.parametrize("clip,accum,param_offload", [(None, 1, False), (0.5, 1, False), (None, 2, False),
73
+ (None, 1, True)])
74
+ def test_data_parallel_matches_single_process(clip, accum, param_offload):
75
+ tmp = tempfile.mkdtemp()
76
+ store = os.path.join(tmp, "store")
77
+ mp.spawn(_worker, args=(store, tmp, clip, accum, param_offload), nprocs=WORLD, join=True)
78
+ ranks = [torch.load(os.path.join(tmp, f"rank{r}.pt")) for r in range(WORLD)]
79
+ ref = reference(clip, accum)
80
+ for k in ref:
81
+ assert torch.equal(ranks[0][k], ranks[1][k]), k # replicas stay identical
82
+ assert (ranks[0][k] - ref[k]).abs().max().item() < 1e-4, k
@@ -0,0 +1,279 @@
1
+ """bf16 moments, chunked cross-entropy, the parameter guard, .to() after wrap,
2
+ and the automatic switch to a resident optimizer."""
3
+
4
+ import copy
5
+
6
+ import pytest
7
+ import torch
8
+ from torch import nn
9
+ from torch.nn import functional as F
10
+
11
+ import trainstream
12
+ from trainstream import OffloadAdamW, chunked_cross_entropy
13
+
14
+ pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
15
+
16
+ DEV = "cuda"
17
+ HP = dict(lr=2e-3, betas=(0.9, 0.95), weight_decay=0.05)
18
+
19
+
20
+ def mlp(seed=0, width=256):
21
+ torch.manual_seed(seed)
22
+ return nn.Sequential(nn.Linear(64, width), nn.GELU(), nn.Linear(width, width), nn.GELU(),
23
+ nn.Linear(width, 10)).to(DEV)
24
+
25
+
26
+ def batches(n, seed=1):
27
+ g = torch.Generator().manual_seed(seed)
28
+ return [(torch.randn(32, 64, generator=g).to(DEV), torch.randint(0, 10, (32,), generator=g).to(DEV))
29
+ for _ in range(n)]
30
+
31
+
32
+ def run(model, opt, data):
33
+ out = []
34
+ for x, y in data:
35
+ loss = F.cross_entropy(model(x), y)
36
+ loss.backward()
37
+ opt.step()
38
+ opt.zero_grad()
39
+ out.append(loss.item())
40
+ return out
41
+
42
+
43
+ def max_param_diff(a, b):
44
+ return max((p - q).abs().max().item() for p, q in zip(a.parameters(), b.parameters()))
45
+
46
+
47
+ # --------------------------------------------------------------------------- bf16 moments
48
+ @pytest.mark.parametrize("mode", ["cpu", "gpu"])
49
+ def test_bf16_moments_track_fp32_moments(mode):
50
+ ref, mine = mlp(), mlp()
51
+ opt_ref = OffloadAdamW(ref.parameters(), mode=mode, **HP)
52
+ opt = OffloadAdamW(mine.parameters(), mode=mode, moment_dtype="bfloat16", **HP)
53
+ data = batches(4) * 50 # a fixed set, so there is something to learn
54
+ la, lb = run(ref, opt_ref, data), run(mine, opt, data)
55
+ assert lb[-1] < lb[0] * 0.5
56
+ assert abs(la[-1] - lb[-1]) < 0.05 * la[-1] + 0.02
57
+ # second moments: stochastic rounding keeps them unbiased relative to fp32
58
+ for slot in range(len(list(mine.parameters()))):
59
+ _, _, v_ref = opt_ref.engine.state(slot)
60
+ _, _, v = opt.engine.state(slot)
61
+ assert v.dtype == torch.bfloat16
62
+ rel = (v.float().sum() - v_ref.sum()).abs() / v_ref.sum()
63
+ assert rel < 0.03, (slot, rel.item())
64
+ s, s_ref = opt.stats(), opt_ref.stats()
65
+ numel = sum(p.numel() for p in mine.parameters())
66
+ assert s_ref["host_state_bytes"] - s["host_state_bytes"] == numel * 4 # two moments, 2 bytes saved each
67
+ sd = opt.state_dict() # exported as fp32, torch-compatible
68
+ assert sd["state"][0]["exp_avg"].dtype == torch.float32
69
+
70
+
71
+ # --------------------------------------------------------------------------- chunked loss
72
+ @pytest.mark.parametrize("bias", [False, True])
73
+ def test_chunked_cross_entropy_matches_dense(bias):
74
+ torch.manual_seed(0)
75
+ h = torch.randn(3, 37, 64, device=DEV, requires_grad=True)
76
+ w = torch.randn(1000, 64, device=DEV, requires_grad=True)
77
+ b = torch.randn(1000, device=DEV, requires_grad=True) if bias else None
78
+ t = torch.randint(0, 1000, (3, 37), device=DEV)
79
+ t[0, :5] = -100
80
+ dense = F.cross_entropy(F.linear(h, w, b).reshape(-1, 1000), t.reshape(-1), ignore_index=-100)
81
+ gd = torch.autograd.grad(dense, [h, w] + ([b] if bias else []))
82
+ ch = chunked_cross_entropy(h, w, t, b, chunk_size=16)
83
+ gc = torch.autograd.grad(ch, [h, w] + ([b] if bias else []))
84
+ assert ch.item() == pytest.approx(dense.item(), rel=1e-5)
85
+ for x, y in zip(gc, gd):
86
+ assert (x - y).abs().max().item() < 1e-5
87
+ # sum reduction and Hugging Face's num_items normalisation
88
+ s = chunked_cross_entropy(h, w, t, b, reduction="sum")
89
+ assert s.item() == pytest.approx(dense.item() * int((t != -100).sum()), rel=1e-5)
90
+ assert chunked_cross_entropy(h, w, t, b, num_items=50).item() == pytest.approx(s.item() / 50, rel=1e-5)
91
+
92
+
93
+ def test_chunked_cross_entropy_under_bf16_autocast():
94
+ torch.manual_seed(0)
95
+ h = torch.randn(512, 128, device=DEV, requires_grad=True)
96
+ w = torch.randn(4000, 128, device=DEV, requires_grad=True)
97
+ t = torch.randint(0, 4000, (512,), device=DEV)
98
+ with torch.autocast("cuda", dtype=torch.bfloat16):
99
+ dense = F.cross_entropy(F.linear(h, w).float(), t)
100
+ ch = chunked_cross_entropy(h, w, t, chunk_size=100)
101
+ gd = torch.autograd.grad(dense, [h, w])
102
+ gc = torch.autograd.grad(ch, [h, w])
103
+ assert ch.item() == pytest.approx(dense.item(), rel=1e-3)
104
+ for x, y in zip(gc, gd):
105
+ assert ((x.float() - y.float()).norm() / y.float().norm()).item() < 2e-2
106
+
107
+
108
+ def test_chunked_cross_entropy_never_materializes_the_logits():
109
+ rows, vocab, d = 4096, 50000, 256
110
+ h = torch.randn(rows, d, device=DEV, requires_grad=True)
111
+ w = torch.randn(vocab, d, device=DEV, requires_grad=True)
112
+ t = torch.randint(0, vocab, (rows,), device=DEV)
113
+
114
+ def peak(fn):
115
+ torch.cuda.synchronize()
116
+ torch.cuda.reset_peak_memory_stats()
117
+ base = torch.cuda.memory_allocated()
118
+ loss = fn()
119
+ loss.backward()
120
+ torch.cuda.synchronize()
121
+ h.grad = w.grad = None
122
+ return torch.cuda.max_memory_allocated() - base
123
+
124
+ dense = peak(lambda: F.cross_entropy(F.linear(h, w), t))
125
+ chunked = peak(lambda: chunked_cross_entropy(h, w, t, chunk_size=512))
126
+ logits_bytes = rows * vocab * 4
127
+ assert dense > logits_bytes
128
+ print(f"dense {dense / 2**20:.0f} MiB, chunked {chunked / 2**20:.0f} MiB")
129
+ assert chunked < 0.25 * dense # one 512-row slice at a time (plus the weight gradient)
130
+
131
+
132
+ # --------------------------------------------------------------------------- parameter guard
133
+ class EarlyUse(nn.Module):
134
+ """Reads its output layer's (large) weight at the very start of forward,
135
+ long before that layer's own pre-hook would wait for its update."""
136
+
137
+ def __init__(self, d=512, vocab=32768):
138
+ super().__init__()
139
+ self.emb = nn.Embedding(vocab, d)
140
+ self.mid = nn.Linear(d, d)
141
+ self.head = nn.Linear(d, vocab)
142
+
143
+ def forward(self, idx):
144
+ gate = torch.sigmoid(self.head.weight.mean()) * 2
145
+ x = self.emb(idx) * gate
146
+ return self.head(torch.tanh(self.mid(x)))
147
+
148
+
149
+ def test_guard_waits_for_weights_used_outside_their_module():
150
+ torch.manual_seed(0)
151
+ ref = EarlyUse().to(DEV)
152
+ mine = copy.deepcopy(ref)
153
+ opt_ref = torch.optim.AdamW(ref.parameters(), foreach=False, **HP)
154
+ mine, opt = trainstream.wrap(mine, torch.optim.AdamW(mine.parameters(), **HP), offload=True,
155
+ activation_offload=False, param_offload=False)
156
+ g = torch.Generator().manual_seed(5)
157
+ data = [torch.randint(0, 32768, (8, 32), generator=g).to(DEV) for _ in range(6)]
158
+ try:
159
+ for idx in data:
160
+ for m, o in ((ref, opt_ref), (mine, opt)):
161
+ F.cross_entropy(m(idx).reshape(-1, 32768).float(), idx.reshape(-1)).backward()
162
+ o.step()
163
+ o.zero_grad()
164
+ opt.synchronize()
165
+ assert opt.stats()["guard_waits"] > 0
166
+ assert max_param_diff(ref, mine) < 1e-4
167
+ finally:
168
+ trainstream.unwrap(mine)
169
+
170
+
171
+ def test_guard_ignores_metadata_and_grad_access():
172
+ model = mlp()
173
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), offload=True,
174
+ activation_offload=False)
175
+ try:
176
+ x, y = batches(1)[0]
177
+ F.cross_entropy(model(x), y).backward()
178
+ opt.step()
179
+ before = opt.stats()["guard_waits"]
180
+ for p in model.parameters(): # what zero_grad, schedulers and loggers touch
181
+ _ = p.grad, p.shape, p.dtype, p.requires_grad, p.numel(), p.size()
182
+ opt.zero_grad()
183
+ assert opt.stats()["guard_waits"] == before
184
+ _ = model[0].weight.sum() # a real read waits
185
+ assert opt.stats()["guard_waits"] == before + 1
186
+ finally:
187
+ trainstream.unwrap(model)
188
+
189
+
190
+ # --------------------------------------------------------------------------- .to() after wrap
191
+ def test_round_trip_to_cpu_and_back_keeps_training_exact():
192
+ ref, mine = mlp(), mlp()
193
+ opt_ref = torch.optim.AdamW(ref.parameters(), foreach=False, **HP)
194
+ mine, opt = trainstream.wrap(mine, torch.optim.AdamW(mine.parameters(), **HP), offload=True,
195
+ activation_offload=False)
196
+ try:
197
+ data = batches(8)
198
+ a = run(ref, opt_ref, data[:4])
199
+ b = run(mine, opt, data[:4])
200
+ mine.to("cpu")
201
+ assert next(mine.parameters()).device.type == "cpu"
202
+ with pytest.raises(RuntimeError, match="moved off the GPU"):
203
+ opt.step()
204
+ mine.cuda()
205
+ a += run(ref, opt_ref, data[4:])
206
+ b += run(mine, opt, data[4:])
207
+ opt.synchronize()
208
+ assert b == pytest.approx(a, abs=1e-4)
209
+ assert max_param_diff(ref, mine) < 1e-4
210
+ assert any("engine rebuilt" in e for e in trainstream.memory_report(mine)["events"])
211
+ finally:
212
+ trainstream.unwrap(mine)
213
+
214
+
215
+ def test_cast_after_wrap_keeps_fp32_master():
216
+ model = mlp()
217
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), offload=True,
218
+ activation_offload=False)
219
+ try:
220
+ data = batches(6)
221
+ run(model, opt, data[:3])
222
+ master_before = [e["master"].clone() for e in opt.state_dict()["state"].values()]
223
+ model.to(torch.bfloat16)
224
+ assert next(model.parameters()).dtype == torch.bfloat16
225
+ master_after = [e["master"] for e in opt.state_dict()["state"].values()]
226
+ assert all(torch.equal(x, y) for x, y in zip(master_before, master_after)) # not rounded to bf16
227
+ with torch.autocast("cuda", dtype=torch.bfloat16):
228
+ losses = run(model, opt, [(x.bfloat16(), y) for x, y in data[3:]])
229
+ assert all(l == l for l in losses)
230
+ finally:
231
+ trainstream.unwrap(model)
232
+
233
+
234
+ # --------------------------------------------------------------------------- resident switch
235
+ def test_small_model_switches_to_resident_and_stays_exact():
236
+ ref, mine = mlp(), mlp()
237
+ opt_ref = torch.optim.AdamW(ref.parameters(), foreach=False, **HP)
238
+ mine, opt = trainstream.wrap(mine, torch.optim.AdamW(mine.parameters(), **HP), activation_offload=False)
239
+ try:
240
+ data = batches(10)
241
+ a, b = run(ref, opt_ref, data), run(mine, opt, data)
242
+ assert opt.resident
243
+ assert b == pytest.approx(a, abs=1e-4)
244
+ assert max_param_diff(ref, mine) < 1e-4
245
+ assert opt.stats()["mode"] == "resident"
246
+ finally:
247
+ trainstream.unwrap(mine)
248
+
249
+
250
+ def test_offload_true_never_goes_resident():
251
+ model = mlp()
252
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), offload=True,
253
+ activation_offload=False)
254
+ try:
255
+ run(model, opt, batches(4))
256
+ assert not opt.resident
257
+ finally:
258
+ trainstream.unwrap(model)
259
+
260
+
261
+ def test_resident_optimizer_is_fused_and_follows_lr_schedulers():
262
+ model = mlp()
263
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), activation_offload=False)
264
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda step: 0.5 ** step)
265
+ try:
266
+ for x, y in batches(4):
267
+ F.cross_entropy(model(x), y).backward()
268
+ opt.step()
269
+ opt.zero_grad()
270
+ sched.step()
271
+ inner = opt._resident
272
+ assert inner is not None and inner.param_groups[0] is opt.param_groups[0]
273
+ assert inner.param_groups[0]["fused"] is True
274
+ assert inner.param_groups[0]["lr"] == pytest.approx(HP["lr"] * 0.5 ** 4)
275
+ assert all(st["step"].is_cuda for st in inner.state.values())
276
+ opt.load_state_dict(opt.state_dict()) # torch's loader replaces group dicts; we relink them
277
+ assert inner.param_groups[0] is opt.param_groups[0] and inner.param_groups[0]["fused"] is True
278
+ finally:
279
+ trainstream.unwrap(model)