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.
- {trainstream-0.2.0/trainstream.egg-info → trainstream-0.3.0}/PKG-INFO +6 -5
- {trainstream-0.2.0 → trainstream-0.3.0}/README.md +5 -4
- {trainstream-0.2.0 → trainstream-0.3.0}/setup.py +1 -1
- trainstream-0.3.0/tests/test_planner.py +247 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/__init__.py +1 -1
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/paging.py +145 -71
- trainstream-0.3.0/trainstream/planner.py +208 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/wrapper.py +154 -31
- {trainstream-0.2.0 → trainstream-0.3.0/trainstream.egg-info}/PKG-INFO +6 -5
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/SOURCES.txt +2 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/LICENSE +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/MANIFEST.in +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/NOTICE +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/build.bat +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/pyproject.toml +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/setup.cfg +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_distributed.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_features.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_huggingface.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_offload.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_optimizer.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/tests/test_paging.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/_build.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/bindings.cpp +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/kernels.h +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/memory_pinner.cpp +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.cpp +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw.h +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/offload_adamw_kernel.cu +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/csrc/trainstream.h +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/hooks.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/integrations/__init__.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/integrations/huggingface.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/losses.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/optimizer.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream/profiler.py +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/dependency_links.txt +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/not-zip-safe +0 -0
- {trainstream-0.2.0 → trainstream-0.3.0}/trainstream.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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"` |
|
|
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="
|
|
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
|
|
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
|
|
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"` |
|
|
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="
|
|
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.
|
|
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)
|
|
@@ -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
|
|
10
|
-
after it ran (the last block
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
73
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
116
|
-
for i in
|
|
117
|
-
self.
|
|
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.
|
|
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
|
-
# --
|
|
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
|
-
"""
|
|
171
|
-
for i in self.
|
|
172
|
-
self.
|
|
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.
|
|
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.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
self.load(i
|
|
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
|
|
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 #
|
|
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.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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()
|