trainstream 0.2.0__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 (40) hide show
  1. {trainstream-0.2.0/trainstream.egg-info → trainstream-0.3.0}/PKG-INFO +6 -5
  2. {trainstream-0.2.0 → trainstream-0.3.0}/README.md +5 -4
  3. {trainstream-0.2.0 → trainstream-0.3.0}/setup.py +1 -1
  4. trainstream-0.3.0/tests/test_planner.py +247 -0
  5. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/__init__.py +1 -1
  6. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/paging.py +145 -71
  7. trainstream-0.3.0/trainstream/planner.py +208 -0
  8. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/wrapper.py +154 -31
  9. {trainstream-0.2.0 → trainstream-0.3.0/trainstream.egg-info}/PKG-INFO +6 -5
  10. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/SOURCES.txt +2 -0
  11. {trainstream-0.2.0 → trainstream-0.3.0}/LICENSE +0 -0
  12. {trainstream-0.2.0 → trainstream-0.3.0}/MANIFEST.in +0 -0
  13. {trainstream-0.2.0 → trainstream-0.3.0}/NOTICE +0 -0
  14. {trainstream-0.2.0 → trainstream-0.3.0}/build.bat +0 -0
  15. {trainstream-0.2.0 → trainstream-0.3.0}/pyproject.toml +0 -0
  16. {trainstream-0.2.0 → trainstream-0.3.0}/setup.cfg +0 -0
  17. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_distributed.py +0 -0
  18. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_features.py +0 -0
  19. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_huggingface.py +0 -0
  20. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_offload.py +0 -0
  21. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_optimizer.py +0 -0
  22. {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_paging.py +0 -0
  23. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/_build.py +0 -0
  24. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/bindings.cpp +0 -0
  25. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/kernels.h +0 -0
  26. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/memory_pinner.cpp +0 -0
  27. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.cpp +0 -0
  28. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.h +0 -0
  29. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw_kernel.cu +0 -0
  30. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/trainstream.h +0 -0
  31. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/hooks.py +0 -0
  32. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/integrations/__init__.py +0 -0
  33. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/integrations/huggingface.py +0 -0
  34. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/losses.py +0 -0
  35. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/optimizer.py +0 -0
  36. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/profiler.py +0 -0
  37. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/dependency_links.txt +0 -0
  38. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/not-zip-safe +0 -0
  39. {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/requires.txt +0 -0
  40. {trainstream-0.2.0 → 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.2.0
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
@@ -58,7 +58,7 @@ TrainStream is a C++/CUDA extension for PyTorch. It keeps only the weights in VR
58
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
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
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 the Qwen2.5-14B architecture (random weights; 29.5 GB) | does not fit | 5.6 GB peak, 2.48 s/step, 36 GB of host RAM |
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
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 |
63
63
 
64
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).
@@ -67,7 +67,7 @@ Offloading costs throughput when a model already fits, which is why `offload="au
67
67
 
68
68
  - An NVIDIA GPU and a CUDA build of PyTorch
69
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)
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 78 tests pass on both, RTX 5090 Laptop GPU. Native Linux and other GPUs are 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.
71
71
 
72
72
  ## Install
73
73
 
@@ -118,9 +118,10 @@ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
118
118
  | `wrap()` option | What it does |
119
119
  |---|---|
120
120
  | `offload="auto"` | switches to stock fused AdamW after 2 steps if the model fits (`True` always offloads) |
121
- | `param_offload="auto"` | pages block weights to host RAM when they'd take more than half the GPU; `True`, a fraction, or block indices |
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
122
  | `activation_offload="auto"` | pages a profiled share of activations (`True`, a fraction, `False`) |
123
- | `moment_dtype="bfloat16"` | bf16 optimizer moments with stochastic rounding: half the host memory for them |
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 |
124
125
  | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
125
126
  | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
126
127
 
@@ -18,7 +18,7 @@ TrainStream is a C++/CUDA extension for PyTorch. It keeps only the weights in VR
18
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
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
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 the Qwen2.5-14B architecture (random weights; 29.5 GB) | does not fit | 5.6 GB peak, 2.48 s/step, 36 GB of host RAM |
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
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
23
 
24
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).
@@ -27,7 +27,7 @@ Offloading costs throughput when a model already fits, which is why `offload="au
27
27
 
28
28
  - An NVIDIA GPU and a CUDA build of PyTorch
29
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 78 tests pass on both, RTX 5090 Laptop GPU. Native Linux and other GPUs are untested.
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
31
 
32
32
  ## Install
33
33
 
@@ -78,9 +78,10 @@ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
78
78
  | `wrap()` option | What it does |
79
79
  |---|---|
80
80
  | `offload="auto"` | switches to stock fused AdamW after 2 steps if the model fits (`True` always offloads) |
81
- | `param_offload="auto"` | pages block weights to host RAM when they'd take more than half the GPU; `True`, a fraction, or block indices |
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
82
  | `activation_offload="auto"` | pages a profiled share of activations (`True`, a fraction, `False`) |
83
- | `moment_dtype="bfloat16"` | bf16 optimizer moments with stochastic rounding: half the host memory for them |
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 |
84
85
  | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
85
86
  | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
86
87
 
@@ -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.2.0",
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,247 @@
1
+ """The memory planner: decisions from the measured VRAM / RAM, and live re-planning."""
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import textwrap
9
+
10
+ import pytest
11
+ import torch
12
+ from torch import nn
13
+
14
+ import trainstream
15
+ from trainstream.planner import Budget, MemoryPlanner, spread
16
+
17
+ pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
18
+
19
+ GiB = 1 << 30
20
+ DEV = "cuda"
21
+ HP = dict(lr=2e-3, betas=(0.9, 0.95), weight_decay=0.05)
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ ROOT = os.path.dirname(HERE)
24
+
25
+
26
+ def budget(vram_gib, ram_gib=64):
27
+ return Budget(vram_total=int(vram_gib * GiB), vram_usable=int(vram_gib * GiB), ram_total=int(ram_gib * GiB),
28
+ ram_available=int(ram_gib * GiB), pin_budget=int(0.4 * ram_gib * GiB))
29
+
30
+
31
+ # --------------------------------------------------------------------------- decisions
32
+ def test_spread_is_even_and_exact():
33
+ assert spread(0, list(range(10))) == []
34
+ assert spread(10, list(range(10))) == list(range(10))
35
+ s = spread(4, list(range(16)))
36
+ assert len(s) == 4 and all(b - a == 4 for a, b in zip(s, s[1:]))
37
+ assert len(spread(7, list(range(48)))) == 7
38
+
39
+
40
+ @pytest.mark.parametrize("vram,expect_paged", [(80, 0), (24, None), (8, None), (4, None)])
41
+ def test_static_plan_follows_the_vram_budget(vram, expect_paged):
42
+ """48 blocks of 0.55 GiB (Qwen2.5-14B in bf16) and 3 GiB outside them."""
43
+ blocks = [int(0.55 * GiB)] * 48
44
+ p = MemoryPlanner(budget(vram), blocks, fixed_vram=3 * GiB, inflight_bytes=GiB // 2)
45
+ paged = p.initial_paged()
46
+ if expect_paged == 0:
47
+ assert paged == set() # an 80 GB card holds the whole model
48
+ return
49
+ resident = 48 - len(paged)
50
+ usable = 0.9 * vram * GiB
51
+ assert 3 * GiB + resident * 0.55 * GiB < usable # what stays resident fits
52
+ if vram >= 24:
53
+ assert resident >= 20 # most of a 24 GB card is used for weights
54
+ assert len(paged) == len(set(paged)) and all(0 <= i < 48 for i in paged)
55
+
56
+
57
+ def test_refinement_moves_blocks_in_with_headroom_and_out_when_full():
58
+ blocks = [GiB // 2] * 20
59
+ p = MemoryPlanner(budget(16), blocks, fixed_vram=2 * GiB, inflight_bytes=GiB // 4)
60
+ paged = set(range(0, 20, 2)) # 10 paged
61
+ grown = p.refine(peak=6 * GiB, paged=paged) # lots of headroom
62
+ assert grown is not None and grown < paged
63
+ shrunk = p.refine(peak=int(15.5 * GiB), paged=grown) # too close to the budget
64
+ assert shrunk is not None and shrunk > grown
65
+ p.refine(peak=int(13.5 * GiB), paged=shrunk)
66
+ p.refine(peak=int(13.5 * GiB), paged=shrunk)
67
+ assert p.done # two steady steps settle the plan
68
+
69
+
70
+ def test_wrap_refuses_what_cannot_fit_in_ram(monkeypatch):
71
+ import trainstream.planner as planner
72
+ monkeypatch.setattr(planner, "available_ram_bytes", lambda: 64 << 20) # 64 MiB "free"
73
+ model = nn.Sequential(*[nn.Linear(1024, 1024) for _ in range(8)]).to(DEV)
74
+ with pytest.raises(MemoryError, match="host RAM"):
75
+ trainstream.wrap(model, torch.optim.AdamW(model.parameters()), offload=True, activation_offload=False)
76
+ # the explicit opt-out still wraps
77
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters()), offload=True,
78
+ activation_offload=False, check_memory=False)
79
+ trainstream.unwrap(model)
80
+
81
+
82
+ def test_moment_precision_follows_available_ram(monkeypatch):
83
+ import trainstream.planner as planner
84
+ model = nn.Sequential(*[nn.Linear(1024, 1024) for _ in range(8)]).to(DEV)
85
+ numel = sum(p.numel() for p in model.parameters())
86
+ # host need: fp32 master 4 + staging 4 + moments 2x(4 or 2) B/param -> 16 (fp32) vs 12 (bf16); give 14
87
+ room = int(numel * 14 / 0.9)
88
+ monkeypatch.setattr(planner, "available_ram_bytes", lambda: room)
89
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters()), offload=True, activation_offload=False)
90
+ try:
91
+ assert opt.moment_dtype == "bfloat16"
92
+ finally:
93
+ trainstream.unwrap(model)
94
+ monkeypatch.setattr(planner, "available_ram_bytes", lambda: 64 << 30)
95
+ model = nn.Sequential(*[nn.Linear(1024, 1024) for _ in range(8)]).to(DEV)
96
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters()), offload=True, activation_offload=False)
97
+ try:
98
+ assert opt.moment_dtype == "float32"
99
+ finally:
100
+ trainstream.unwrap(model)
101
+
102
+
103
+ # --------------------------------------------------------------------------- live re-planning
104
+ class Block(nn.Module):
105
+ def __init__(self, d):
106
+ super().__init__()
107
+ self.ln = nn.LayerNorm(d)
108
+ self.fc1, self.fc2 = nn.Linear(d, 4 * d), nn.Linear(4 * d, d)
109
+
110
+ def forward(self, x):
111
+ return x + self.fc2(torch.nn.functional.gelu(self.fc1(self.ln(x))))
112
+
113
+
114
+ class Stack(nn.Module):
115
+ def __init__(self, d=256, n=6, vocab=128):
116
+ super().__init__()
117
+ self.emb = nn.Embedding(vocab, d)
118
+ self.blocks = nn.ModuleList(Block(d) for _ in range(n))
119
+ self.head = nn.Linear(d, vocab)
120
+
121
+ def forward(self, idx):
122
+ x = self.emb(idx)
123
+ for b in self.blocks:
124
+ x = b(x)
125
+ return self.head(x)
126
+
127
+
128
+ def lm_steps(model, opt, data, between=None):
129
+ out = []
130
+ for k, t in enumerate(data):
131
+ logits = model(t[:, :-1])
132
+ loss = nn.functional.cross_entropy(logits.float().reshape(-1, logits.size(-1)), t[:, 1:].reshape(-1))
133
+ loss.backward()
134
+ opt.step()
135
+ opt.zero_grad()
136
+ out.append(loss.item())
137
+ if between:
138
+ between(k)
139
+ return out
140
+
141
+
142
+ def tokens(n, seed=3, batch=8, seq=32, vocab=128):
143
+ g = torch.Generator().manual_seed(seed)
144
+ return [torch.randint(0, vocab, (batch, seq + 1), generator=g).to(DEV) for _ in range(n)]
145
+
146
+
147
+ def test_blocks_move_between_vram_and_ram_mid_training_without_changing_results():
148
+ torch.manual_seed(0)
149
+ ref = Stack().to(DEV)
150
+ mine = copy.deepcopy(ref)
151
+ opt_ref = torch.optim.AdamW(ref.parameters(), foreach=False, **HP)
152
+ mine, opt = trainstream.wrap(mine, torch.optim.AdamW(mine.parameters(), **HP), offload=True,
153
+ param_offload=[0, 2, 4], activation_offload=False)
154
+ pager = trainstream.wrapper.unwrap_session(mine).pager
155
+ moves = {1: ([0], [1, 5]), 3: ([1, 2, 4, 5], []), 5: ([], [0, 3])} # after step k: (unpage, page)
156
+
157
+ def between(k):
158
+ if k in moves:
159
+ unpage, page = moves[k]
160
+ for i in page:
161
+ pager.page(i)
162
+ for i in unpage:
163
+ pager.unpage(i)
164
+
165
+ try:
166
+ data = tokens(8)
167
+ a = lm_steps(ref, opt_ref, data)
168
+ b = lm_steps(mine, opt, data, between)
169
+ opt.synchronize()
170
+ assert b == pytest.approx(a, abs=1e-4)
171
+ assert pager.paged == {0, 3}
172
+ sizes = [sum(p.untyped_storage().nbytes() for p in blk.parameters()) for blk in mine.blocks]
173
+ assert all(sizes[i] > 0 for i in (1, 2, 4, 5)) and sizes[3] == 0 # resident blocks hold weights
174
+ sd, sd_ref = mine.state_dict(), ref.state_dict()
175
+ assert max((sd[k].float().cpu() - sd_ref[k].float().cpu()).abs().max().item() for k in sd_ref) < 2e-4
176
+ finally:
177
+ trainstream.unwrap(mine)
178
+
179
+
180
+ CAP_SCRIPT = textwrap.dedent(r"""
181
+ import json, sys, torch
182
+ sys.path[:0] = [ROOT, HERE]
183
+ import trainstream
184
+ from test_planner import Stack, tokens, lm_steps, HP
185
+ cap = CAP
186
+ torch.cuda.set_per_process_memory_fraction(cap / torch.cuda.get_device_properties(0).total_memory, 0)
187
+ torch.manual_seed(0)
188
+ model = Stack(d=1024, n=8) # 8 blocks of 50 MB
189
+ opt = torch.optim.AdamW(model.parameters(), **HP)
190
+ model, opt = trainstream.wrap(model, opt, device="cuda", offload=True)
191
+ s = trainstream.wrapper.unwrap_session(model)
192
+ first = sorted(s.pager.paged)
193
+ peaks = []
194
+ def between(k):
195
+ peaks.append(torch.cuda.max_memory_allocated())
196
+ losses = lm_steps(model, opt, tokens(14, batch=4, seq=64), between)
197
+ opt.synchronize()
198
+ print("@@" + json.dumps(dict(first=first, final=sorted(s.pager.paged), settled=s.planner.done, losses=losses,
199
+ events=s.events, moments=opt.moment_dtype, cap=cap)))
200
+ """)
201
+
202
+
203
+ def test_auto_plan_under_a_vram_cap_finds_the_split_and_trains_correctly():
204
+ cap = 450 << 20 # 8 blocks of 50 MB do not all fit next to the rest
205
+ code = CAP_SCRIPT.replace("ROOT", repr(ROOT)).replace("HERE", repr(HERE)).replace("CAP", str(cap))
206
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600)
207
+ assert out.returncode == 0, out.stdout + out.stderr
208
+ res = json.loads([l for l in out.stdout.splitlines() if l.startswith("@@")][-1][2:])
209
+ assert res["first"], "a 450 MB cap must start with some blocks paged"
210
+ assert res["settled"]
211
+ assert res["final"] != res["first"] # measured peaks changed the plan
212
+ assert 0 < len(res["final"]) < len(res["first"]) # measured headroom moved some blocks into VRAM
213
+ assert any("memory plan settled" in e for e in res["events"])
214
+ # Moving blocks between VRAM and RAM must not change the arithmetic at all: the
215
+ # capped, re-planned run equals an uncapped, unpaged TrainStream run exactly.
216
+ # (Against torch.optim.AdamW this lr is unstable enough to amplify last-bit
217
+ # differences after ~8 steps, so that comparison lives in the smaller tests.)
218
+ torch.manual_seed(0)
219
+ ref = Stack(d=1024, n=8)
220
+ ref, opt = trainstream.wrap(ref, torch.optim.AdamW(ref.parameters(), **HP), device=DEV, offload=True,
221
+ param_offload=False)
222
+ try:
223
+ a = lm_steps(ref, opt, tokens(14, batch=4, seq=64))
224
+ finally:
225
+ trainstream.unwrap(ref)
226
+ assert res["losses"] == a
227
+
228
+
229
+ def test_watchdog_pages_a_block_when_a_later_step_grows():
230
+ torch.manual_seed(0)
231
+ model = Stack().to(DEV)
232
+ model, opt = trainstream.wrap(model, torch.optim.AdamW(model.parameters(), **HP), offload=True,
233
+ param_offload=[0], activation_offload=False)
234
+ s = trainstream.wrapper.unwrap_session(model)
235
+ from trainstream.planner import Budget, MemoryPlanner
236
+ used = torch.cuda.memory_allocated()
237
+ s.planner = MemoryPlanner(Budget(vram_total=used + (64 << 20), vram_usable=used + (64 << 20), ram_total=1 << 36,
238
+ ram_available=1 << 35, pin_budget=1 << 34),
239
+ s.pager.block_bytes, fixed_vram=0, inflight_bytes=1 << 20)
240
+ s.planner.done = True # settled; now a step with a much larger batch arrives
241
+ try:
242
+ before = len(s.pager.paged)
243
+ lm_steps(model, opt, tokens(1, batch=64, seq=128))
244
+ assert len(s.pager.paged) == before + 1
245
+ assert any("memory watchdog" in e for e in s.events)
246
+ finally:
247
+ trainstream.unwrap(model)
@@ -33,7 +33,7 @@ from .paging import ParamPager
33
33
  from .profiler import HardwareProfile, profile_hardware
34
34
  from .wrapper import TrainStreamSession, memory_report, unwrap, wrap
35
35
 
36
- __version__ = "0.2.0"
36
+ __version__ = "0.3.0"
37
37
 
38
38
  __all__ = [
39
39
  "wrap",
@@ -6,16 +6,25 @@ block the VRAM storage of its weights is released (``storage.resize_(0)``, the
6
6
  same mechanism FSDP uses), and the pager streams the host copy back in on the
7
7
  H2D stream whenever the block is about to run:
8
8
 
9
- forward block i: load i (usually prefetched), prefetch i+1; release i
10
- after it ran (the last block stays, backward starts there)
11
- backward a hook on block i's output gradient loads i and prefetches i-1;
12
- blocks above i are released — their input gradients, the last
13
- computation that reads their weights, are already queued
14
-
15
- Frozen weights (LoRA base layers) get their own pinned host copy. Under
16
- torch.autocast the cast-weight cache is cleared after each paged block, or it
17
- would keep a bf16 copy of every paged weight alive until the region exits. Updates of
18
- paged parameters are never uploaded by the optimizer; the next load reads the
9
+ forward block i: load i if it is paged (usually prefetched), prefetch the
10
+ next paged block(s); release i after it ran (the last paged block
11
+ stays, backward starts there)
12
+ backward a hook on block i's output gradient loads i (if paged) and
13
+ prefetches the previous paged block(s); paged blocks above i are
14
+ released — their input gradients, the last computation that reads
15
+ their weights, are already queued
16
+
17
+ Which blocks are paged can change between steps (``page`` / ``unpage``): the
18
+ memory planner keeps as many blocks resident as VRAM allows. Hooks sit on every
19
+ block, so a resident block prefetches the next paged one and transfers overlap
20
+ with compute even when paged and resident blocks are interleaved.
21
+
22
+ Frozen weights (LoRA base layers) of a paged block get a host copy of their
23
+ own: pinned while that fits the page-lock budget, else ordinary RAM. It is
24
+ dropped again when the block becomes resident. Under torch.autocast the
25
+ cast-weight cache is cleared after each block, or it would keep a bf16 copy of
26
+ every weight alive until the autocast region exits. Updates of paged
27
+ parameters are never uploaded by the optimizer; the next load reads the
19
28
  updated host copy after the optimizer has finished with it, and the next
20
29
  update waits for that DMA to finish reading.
21
30
  """
@@ -69,65 +78,126 @@ def pageable_params(model: nn.Module, blocks: Sequence[nn.Module], indices: Iter
69
78
 
70
79
 
71
80
  class ParamPager:
72
- def __init__(self, model: nn.Module, blocks: Sequence[nn.Module], optimizer, paged: Dict[int, List[nn.Parameter]],
73
- *, frozen_init: Optional[Dict[int, torch.Tensor]] = None, prefetch_depth: int = 1):
81
+ def __init__(self, model: nn.Module, blocks: Sequence[nn.Module], optimizer, paged: Iterable[int] = (), *,
82
+ candidates: Optional[Dict[int, List[nn.Parameter]]] = None,
83
+ frozen_init: Optional[Dict[int, torch.Tensor]] = None, prefetch_depth: int = 1):
74
84
  self.model = model
75
85
  self.blocks = list(blocks)
76
86
  self.n = len(self.blocks)
77
87
  self.opt = optimizer
78
88
  self.depth = max(1, int(prefetch_depth))
79
- self.paged = {i: ps for i, ps in paged.items() if ps}
80
- self.last = max(self.paged) if self.paged else -1
81
- frozen_init = frozen_init if frozen_init is not None else {}
82
89
  self.device = optimizer.device
90
+ if candidates is None:
91
+ candidates = pageable_params(model, self.blocks, range(self.n))
83
92
  self._entries: Dict[int, list] = {}
93
+ for i in range(self.n):
94
+ self._entries[i] = [(p, optimizer._slot_of.get(id(p)), p.numel() * p.element_size())
95
+ for p in candidates.get(i, [])]
96
+ self.block_bytes = [sum(nb for _, _, nb in self._entries[i]) for i in range(self.n)]
84
97
  self._host_frozen: Dict[int, torch.Tensor] = {}
85
98
  ram = physical_ram_bytes()
86
- pin_budget = 0.40 * ram if ram else float("inf")
99
+ self.pin_budget = 0.40 * ram if ram else float("inf")
87
100
  self.frozen_pinned_bytes = 0
88
101
  self.frozen_pageable_bytes = 0
89
- for i, ps in self.paged.items():
90
- entries = []
91
- for p in ps:
92
- slot = optimizer._slot_of.get(id(p))
93
- if slot is None:
94
- # Frozen weight: pinned host copy while that fits the page-lock
95
- # budget, else ordinary RAM (the CPU tensor it was built in is
96
- # reused as is). Popped one by one, so the model never exists
97
- # twice in host memory.
98
- src = frozen_init.pop(id(p), None)
99
- nbytes = p.numel() * p.element_size()
100
- if _C.pinned_stats()["live_bytes"] + nbytes <= pin_budget:
101
- host = _C.pinned_empty([p.numel()], p.dtype, "param.frozen", False, False)
102
- host.copy_(src.reshape(-1) if src is not None else p.detach().reshape(-1))
103
- self.frozen_pinned_bytes += nbytes
104
- else:
105
- host = (src.reshape(-1) if src is not None else p.detach().reshape(-1).cpu())
106
- self.frozen_pageable_bytes += nbytes
107
- del src
108
- self._host_frozen[id(p)] = host
109
- entries.append((p, slot, p.numel() * p.element_size()))
110
- self._entries[i] = entries
111
- self.allocated = {i: True for i in self.paged}
112
- self.fresh = {i: False for i in self.paged}
102
+ self.paged: Set[int] = set()
103
+ self._sorted: List[int] = []
104
+ self.allocated = {i: True for i in range(self.n)}
105
+ self.fresh = {i: False for i in range(self.n)}
113
106
  self.events: Dict[int, object] = {}
114
- self.stats = dict(loads=0, load_bytes=0, releases=0)
115
- self.bytes_paged = sum(nb for es in self._entries.values() for _, _, nb in es)
116
- for i in self.paged:
117
- self.release(i)
107
+ self.stats = dict(loads=0, load_bytes=0, releases=0, pages=0, unpages=0)
108
+ frozen_init = frozen_init if frozen_init is not None else {}
109
+ for i in sorted(set(paged)):
110
+ self.page(i, frozen_init)
118
111
  self._handles = [model.register_forward_pre_hook(self._root_pre)]
119
- for i in self.paged:
120
- b = self.blocks[i]
112
+ for i, b in enumerate(self.blocks):
121
113
  self._handles.append(b.register_forward_pre_hook(self._make_pre(i)))
122
114
  self._handles.append(b.register_forward_hook(self._make_post(i), always_call=True))
123
115
  self._handles.append(b._register_state_dict_hook(self._make_state_dict_hook(i)))
124
116
  self._handles.append(b._register_load_state_dict_pre_hook(self._make_load_pre(i), with_module=True))
125
117
  self._handles.append(b.register_load_state_dict_post_hook(self._make_load_post(i)))
126
118
 
127
- # -- core operations ---------------------------------------------------------
119
+ # -- state ------------------------------------------------------------------
120
+ @property
121
+ def bytes_paged(self) -> int:
122
+ return sum(self.block_bytes[i] for i in self.paged)
123
+
124
+ @property
125
+ def last(self) -> int:
126
+ return self._sorted[-1] if self._sorted else -1
127
+
128
+ def _resort(self) -> None:
129
+ self._sorted = sorted(self.paged)
130
+
131
+ def _next_paged(self, i: int) -> List[int]:
132
+ return [j for j in self._sorted if j > i][: self.depth]
133
+
134
+ def _prev_paged(self, i: int) -> List[int]:
135
+ return [j for j in reversed(self._sorted) if j < i][: self.depth]
136
+
128
137
  def _source(self, p, slot) -> torch.Tensor:
129
138
  return self.opt.engine.host_param(slot) if slot is not None else self._host_frozen[id(p)]
130
139
 
140
+ # -- moving a block between VRAM and host RAM (between steps) ---------------
141
+ def page(self, i: int, frozen_init: Optional[Dict[int, torch.Tensor]] = None) -> None:
142
+ """Make block i paged: its weights leave VRAM."""
143
+ if i in self.paged or not self._entries[i]:
144
+ return
145
+ entries = self._entries[i]
146
+ slots = [s for _, s, _ in entries if s is not None]
147
+ if slots:
148
+ self.opt.engine.wait_ready(slots) # an in-flight upload into VRAM must land first
149
+ for p, slot, nbytes in entries:
150
+ if slot is not None:
151
+ self.opt.engine.set_paged(slot, True)
152
+ self.opt._paged_ids.add(id(p))
153
+ continue
154
+ # Frozen weight: pinned host copy while that fits the page-lock
155
+ # budget, else ordinary RAM (the CPU tensor a model was built in is
156
+ # reused as is). Taken one by one, so a model never exists twice.
157
+ src = frozen_init.pop(id(p), None) if frozen_init is not None else None
158
+ if _C.pinned_stats()["live_bytes"] + nbytes <= self.pin_budget:
159
+ host = _C.pinned_empty([p.numel()], p.dtype, "param.frozen", False, False)
160
+ host.copy_(src.reshape(-1) if src is not None else p.detach().reshape(-1))
161
+ self.frozen_pinned_bytes += nbytes
162
+ host._ts_pinned = True
163
+ else:
164
+ host = src.reshape(-1) if src is not None else p.detach().reshape(-1).cpu()
165
+ self.frozen_pageable_bytes += nbytes
166
+ host._ts_pinned = False
167
+ del src
168
+ self._host_frozen[id(p)] = host
169
+ self.paged.add(i)
170
+ self._resort()
171
+ self.allocated[i] = all(p.untyped_storage().nbytes() > 0 for p, _, _ in entries)
172
+ self.fresh[i] = False
173
+ self.release(i)
174
+ self.stats["pages"] += 1
175
+
176
+ def unpage(self, i: int) -> None:
177
+ """Make block i resident: load its weights and keep them in VRAM."""
178
+ if i not in self.paged:
179
+ return
180
+ self.fresh[i] = False
181
+ self.load(i)
182
+ self.wait(i)
183
+ torch.cuda.current_stream(self.device).synchronize() # host copies may go only after the copy
184
+ for p, slot, nbytes in self._entries[i]:
185
+ if slot is not None:
186
+ self.opt.engine.set_paged(slot, False) # updates go straight to VRAM again
187
+ self.opt._paged_ids.discard(id(p))
188
+ continue
189
+ host = self._host_frozen.pop(id(p))
190
+ if getattr(host, "_ts_pinned", False):
191
+ self.frozen_pinned_bytes -= nbytes
192
+ else:
193
+ self.frozen_pageable_bytes -= nbytes
194
+ del host
195
+ self.paged.discard(i)
196
+ self._resort()
197
+ self.allocated[i] = True
198
+ self.stats["unpages"] += 1
199
+
200
+ # -- streaming ------------------------------------------------------------------
131
201
  def load(self, i: int) -> None:
132
202
  """Queue the H2D copy of block i's weights (does not block the GPU)."""
133
203
  if i not in self.paged or self.fresh[i]:
@@ -167,31 +237,33 @@ class ParamPager:
167
237
  self.stats["releases"] += 1
168
238
 
169
239
  def materialize(self) -> None:
170
- """Load every paged block (e.g. before unwrap)."""
171
- for i in self.paged:
172
- self.fresh[i] = False
173
- self.load(i)
174
- self.wait(i)
175
- torch.cuda.current_stream(self.device).synchronize()
240
+ """Make every block resident (e.g. before unwrap)."""
241
+ for i in list(self._sorted):
242
+ self.unpage(i)
176
243
 
177
244
  # -- hooks ---------------------------------------------------------------------
178
245
  def _root_pre(self, module, args):
179
246
  if not _in_backward():
180
- for i in self.paged: # weights may have been updated since the last load
247
+ for i in self._sorted: # weights may have been updated since the last load
181
248
  self.fresh[i] = False
182
249
 
183
250
  def _make_pre(self, i: int):
184
251
  def hook(module, args):
185
- self.load(i)
186
- self.wait(i)
187
- for d in range(1, self.depth + 1):
188
- self.load(i + d)
252
+ if not self._sorted:
253
+ return
254
+ if i in self.paged:
255
+ self.load(i)
256
+ self.wait(i)
257
+ for j in self._next_paged(i):
258
+ self.load(j)
189
259
  return hook
190
260
 
191
261
  def _make_post(self, i: int):
192
262
  def hook(module, args, output):
263
+ if not self._sorted:
264
+ return
193
265
  # autocast keeps a low-precision copy of every weight it casts until the
194
- # autocast region exits, which would quietly hold all paged weights in VRAM
266
+ # autocast region exits, which would quietly hold paged weights in VRAM
195
267
  if torch.is_autocast_enabled("cuda"):
196
268
  torch.clear_autocast_cache()
197
269
  if _in_backward():
@@ -201,16 +273,17 @@ class ParamPager:
201
273
  if t.requires_grad:
202
274
  t.register_hook(lambda g, i=i: self._on_grad(i))
203
275
  if i == self.last:
204
- return # first block backward needs
276
+ return # backward starts with this one
205
277
  self.release(i)
206
278
  return hook
207
279
 
208
280
  def _on_grad(self, i: int):
209
- self.load(i)
210
- self.wait(i)
211
- for d in range(1, self.depth + 1):
212
- self.load(i - d)
213
- for j in self.paged:
281
+ if i in self.paged:
282
+ self.load(i)
283
+ self.wait(i)
284
+ for j in self._prev_paged(i):
285
+ self.load(j)
286
+ for j in self._sorted:
214
287
  if j > i:
215
288
  self.release(j)
216
289
 
@@ -226,6 +299,8 @@ class ParamPager:
226
299
 
227
300
  def _make_state_dict_hook(self, i: int):
228
301
  def hook(module, state_dict, prefix, local_metadata):
302
+ if i not in self.paged:
303
+ return
229
304
  slots = {id(p): s for p, s, _ in self._entries[i]}
230
305
  for name, p in self._named(i):
231
306
  key = prefix + name
@@ -235,12 +310,15 @@ class ParamPager:
235
310
 
236
311
  def _make_load_pre(self, i: int):
237
312
  def hook(module, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs):
238
- self.load(i) # real storage for the copy_ that follows
239
- self.wait(i)
313
+ if i in self.paged:
314
+ self.load(i) # real storage for the copy_ that follows
315
+ self.wait(i)
240
316
  return hook
241
317
 
242
318
  def _make_load_post(self, i: int):
243
319
  def hook(module, incompatible_keys):
320
+ if i not in self.paged:
321
+ return
244
322
  torch.cuda.current_stream(self.device).synchronize()
245
323
  for p, slot, _ in self._entries[i]:
246
324
  if slot is not None:
@@ -253,10 +331,6 @@ class ParamPager:
253
331
  def remove(self, materialize: bool = True) -> None:
254
332
  if materialize:
255
333
  self.materialize()
256
- for es in self._entries.values():
257
- for _, slot, _ in es:
258
- if slot is not None and self.opt.engine is not None:
259
- self.opt.engine.set_paged(slot, False)
260
334
  for h in self._handles:
261
335
  h.remove()
262
336
  self._handles.clear()