trainstream 0.2.0__tar.gz → 0.4.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 (45) hide show
  1. {trainstream-0.2.0/trainstream.egg-info → trainstream-0.4.0}/PKG-INFO +33 -11
  2. {trainstream-0.2.0 → trainstream-0.4.0}/README.md +31 -9
  3. {trainstream-0.2.0 → trainstream-0.4.0}/setup.py +4 -2
  4. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_huggingface.py +96 -0
  5. trainstream-0.4.0/tests/test_planner.py +247 -0
  6. trainstream-0.4.0/tests/test_quant.py +279 -0
  7. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/__init__.py +5 -2
  8. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/_build.py +2 -1
  9. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/bindings.cpp +2 -0
  10. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/kernels.h +7 -0
  11. trainstream-0.4.0/trainstream/csrc/quant.cpp +71 -0
  12. trainstream-0.4.0/trainstream/csrc/quant_kernel.cu +124 -0
  13. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/trainstream.h +8 -0
  14. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/integrations/huggingface.py +118 -0
  15. trainstream-0.4.0/trainstream/paging.py +432 -0
  16. trainstream-0.4.0/trainstream/planner.py +210 -0
  17. trainstream-0.4.0/trainstream/quant.py +214 -0
  18. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/wrapper.py +247 -36
  19. {trainstream-0.2.0 → trainstream-0.4.0/trainstream.egg-info}/PKG-INFO +33 -11
  20. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream.egg-info/SOURCES.txt +6 -0
  21. trainstream-0.2.0/trainstream/paging.py +0 -262
  22. {trainstream-0.2.0 → trainstream-0.4.0}/LICENSE +0 -0
  23. {trainstream-0.2.0 → trainstream-0.4.0}/MANIFEST.in +0 -0
  24. {trainstream-0.2.0 → trainstream-0.4.0}/NOTICE +0 -0
  25. {trainstream-0.2.0 → trainstream-0.4.0}/build.bat +0 -0
  26. {trainstream-0.2.0 → trainstream-0.4.0}/pyproject.toml +0 -0
  27. {trainstream-0.2.0 → trainstream-0.4.0}/setup.cfg +0 -0
  28. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_distributed.py +0 -0
  29. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_features.py +0 -0
  30. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_offload.py +0 -0
  31. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_optimizer.py +0 -0
  32. {trainstream-0.2.0 → trainstream-0.4.0}/tests/test_paging.py +0 -0
  33. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/memory_pinner.cpp +0 -0
  34. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/offload_adamw.cpp +0 -0
  35. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/offload_adamw.h +0 -0
  36. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/csrc/offload_adamw_kernel.cu +0 -0
  37. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/hooks.py +0 -0
  38. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/integrations/__init__.py +0 -0
  39. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/losses.py +0 -0
  40. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/optimizer.py +0 -0
  41. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream/profiler.py +0 -0
  42. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream.egg-info/dependency_links.txt +0 -0
  43. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream.egg-info/not-zip-safe +0 -0
  44. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream.egg-info/requires.txt +0 -0
  45. {trainstream-0.2.0 → trainstream-0.4.0}/trainstream.egg-info/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trainstream
3
- Version: 0.2.0
4
- Summary: Zero-dependency PCIe offloading engine for PyTorch training (optimizer, gradients, activations).
3
+ Version: 0.4.0
4
+ Summary: Zero-dependency PyTorch training offload: optimizer, gradients, activations and weights to host RAM, with a memory planner and built-in int8/NF4 quantization.
5
5
  Home-page: https://github.com/johnanishg/TrainStream
6
6
  Author: John Anish G
7
7
  License: Apache-2.0
@@ -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, activations and, when needed, the weights themselves to system RAM over PCIe. It takes one line of code and has 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. Frozen weights (a LoRA base) can also be stored as int8 or 4-bit NF4. It takes one line of code and has no third-party dependencies.
44
44
 
45
45
  ```python
46
46
  import trainstream
@@ -58,7 +58,9 @@ 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
+ | same, NF4 base (`quantize="auto", prefer="speed"`, 0.4) | does not fit | 0.87 s/step: all 48 layers in VRAM as 4-bit (7.43 GB), 18.3 GB peak, 11.3 GB of process RAM |
63
+ | LoRA r=16 on the Qwen2.5-32B architecture (65.5 GB in bf16, more than the 64 GB of RAM) | does not fit | 2.53 s/step with `load_model(quantize="nf4")`: 21.3 GB peak, 31.4 GB of process RAM |
62
64
  | 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
65
 
64
66
  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 +69,7 @@ Offloading costs throughput when a model already fits, which is why `offload="au
67
69
 
68
70
  - An NVIDIA GPU and a CUDA build of PyTorch
69
71
  - 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.
72
+ - 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 123 tests pass on Windows; on Linux 122 pass and 1 (the bitsandbytes comparison) is skipped without bitsandbytes installed, RTX 5090 Laptop GPU. Native Linux and other GPUs are untested.
71
73
 
72
74
  ## Install
73
75
 
@@ -113,14 +115,32 @@ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
113
115
  param_offload=True, moment_dtype="bfloat16")
114
116
  ```
115
117
 
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.
118
+ LoRA on a quantized base (QLoRA-style), with no extra package:
119
+
120
+ ```python
121
+ import peft
122
+ from trainstream.integrations.huggingface import TrainStreamTrainer, load_model
123
+
124
+ model = load_model("Qwen/Qwen2.5-14B", quantize="nf4") # "auto" | "none" | "int8" | "nf4"; quantized while read
125
+ model = peft.get_peft_model(model, peft.LoraConfig(r=16, lora_alpha=32, task_type="CAUSAL_LM",
126
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]))
127
+ trainer = TrainStreamTrainer(model=model, args=TrainingArguments(..., bf16=True), train_dataset=ds)
128
+ trainer.train()
129
+ ```
130
+
131
+ Or quantize inside `wrap()`: `trainstream.wrap(model, opt, device="cuda", quantize="auto", prefer="speed")`, or beforehand with `trainstream.quantize_model(model, "int8")`. Only frozen weights are quantized. A quantized run trains on the dequantized weights (as QLoRA does), so it isn't bit-exact with the bf16 base.
132
+
133
+ `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 7B–32B bases through `TrainStreamTrainer` (`--quantize auto|none|int8|nf4`, `--prefer exact|speed`).
117
134
 
118
135
  | `wrap()` option | What it does |
119
136
  |---|---|
120
137
  | `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 |
138
+ | `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
139
  | `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 |
140
+ | `quantize="auto"` | frozen weights only: `"auto"` quantizes when the run can't fit otherwise (int8 before NF4); `"none"`, `"int8"`, `"nf4"` fix it; `quant_block_size=64` |
141
+ | `prefer="exact"` | `"speed"` lets `quantize="auto"` pick NF4 when it keeps every layer in VRAM |
142
+ | `moment_dtype="auto"` | fp32 optimizer moments, or bf16 (stochastic rounding, half the memory) when fp32 wouldn't fit in RAM |
143
+ | `check_memory=True` | refuse at `wrap()` with a clear explanation when the run can't fit in RAM/VRAM |
124
144
  | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
125
145
  | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
126
146
 
@@ -141,12 +161,14 @@ trainer.train()
141
161
  - `examples/verify_ram_offload.py` — proves where training memory lives (VRAM vs system RAM) with a PASS/FAIL verdict
142
162
  - `examples/hf_finetune.py` — fine-tune a Hugging Face model, optionally side by side with the stock Trainer
143
163
  - `examples/train_bigger_than_vram.py` — weight paging on a model built on the CPU, under a VRAM cap
144
- - `examples/lora_large_model.py` — LoRA on 7B/14B-size bases larger than the GPU, optionally against the stock Trainer
145
- - `docs/index.html` — full documentation
164
+ - `examples/lora_large_model.py` — LoRA on 7B/14B/32B-size bases larger than the GPU, bf16 or quantized, optionally against the stock Trainer
165
+ - `TrainStreamDocs.html` — full documentation, including every option and the values it accepts (open it in a browser)
166
+ - `TrainStreamReport.html` — what TrainStream does, how it compares with other libraries, and every measured result
146
167
 
147
168
  ## Limitations
148
169
 
149
- - 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
170
+ - Host RAM is the ceiling: 8–12 bytes per trained parameter and 2–4 per frozen one (1.06 int8, 0.56 NF4), plus activations; measured on 64 GB: a 3.6B full fine-tune and LoRA on a 32.8B NF4 base
171
+ - Quantization covers frozen block weights only (not embeddings, the output layer or trained weights) and is not bit-exact; `load_model` reads `.safetensors` causal-LM checkpoints
150
172
  - Weight paging covers the repeated block stack and needs `optimizer_mode="cpu"`; each paged weight crosses PCIe twice per step
151
173
  - Data parallel only (no tensor/pipeline parallelism); tested with 2 processes on 1 GPU, not across several GPUs or machines
152
174
  - `TrainStreamTrainer` is single-process; fp16 + GradScaler and AMSGrad aren't supported; `torch.compile` hasn't been tested
@@ -1,6 +1,6 @@
1
1
  # TrainStream
2
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.
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. Frozen weights (a LoRA base) can also be stored as int8 or 4-bit NF4. It takes one line of code and has no third-party dependencies.
4
4
 
5
5
  ```python
6
6
  import trainstream
@@ -18,7 +18,9 @@ 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
+ | same, NF4 base (`quantize="auto", prefer="speed"`, 0.4) | does not fit | 0.87 s/step: all 48 layers in VRAM as 4-bit (7.43 GB), 18.3 GB peak, 11.3 GB of process RAM |
23
+ | LoRA r=16 on the Qwen2.5-32B architecture (65.5 GB in bf16, more than the 64 GB of RAM) | does not fit | 2.53 s/step with `load_model(quantize="nf4")`: 21.3 GB peak, 31.4 GB of process RAM |
22
24
  | 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
25
 
24
26
  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 +29,7 @@ Offloading costs throughput when a model already fits, which is why `offload="au
27
29
 
28
30
  - An NVIDIA GPU and a CUDA build of PyTorch
29
31
  - 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.
32
+ - 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 123 tests pass on Windows; on Linux 122 pass and 1 (the bitsandbytes comparison) is skipped without bitsandbytes installed, RTX 5090 Laptop GPU. Native Linux and other GPUs are untested.
31
33
 
32
34
  ## Install
33
35
 
@@ -73,14 +75,32 @@ model, optimizer = trainstream.wrap(model, optimizer, device="cuda",
73
75
  param_offload=True, moment_dtype="bfloat16")
74
76
  ```
75
77
 
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.
78
+ LoRA on a quantized base (QLoRA-style), with no extra package:
79
+
80
+ ```python
81
+ import peft
82
+ from trainstream.integrations.huggingface import TrainStreamTrainer, load_model
83
+
84
+ model = load_model("Qwen/Qwen2.5-14B", quantize="nf4") # "auto" | "none" | "int8" | "nf4"; quantized while read
85
+ model = peft.get_peft_model(model, peft.LoraConfig(r=16, lora_alpha=32, task_type="CAUSAL_LM",
86
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]))
87
+ trainer = TrainStreamTrainer(model=model, args=TrainingArguments(..., bf16=True), train_dataset=ds)
88
+ trainer.train()
89
+ ```
90
+
91
+ Or quantize inside `wrap()`: `trainstream.wrap(model, opt, device="cuda", quantize="auto", prefer="speed")`, or beforehand with `trainstream.quantize_model(model, "int8")`. Only frozen weights are quantized. A quantized run trains on the dequantized weights (as QLoRA does), so it isn't bit-exact with the bf16 base.
92
+
93
+ `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 7B–32B bases through `TrainStreamTrainer` (`--quantize auto|none|int8|nf4`, `--prefer exact|speed`).
77
94
 
78
95
  | `wrap()` option | What it does |
79
96
  |---|---|
80
97
  | `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 |
98
+ | `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
99
  | `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 |
100
+ | `quantize="auto"` | frozen weights only: `"auto"` quantizes when the run can't fit otherwise (int8 before NF4); `"none"`, `"int8"`, `"nf4"` fix it; `quant_block_size=64` |
101
+ | `prefer="exact"` | `"speed"` lets `quantize="auto"` pick NF4 when it keeps every layer in VRAM |
102
+ | `moment_dtype="auto"` | fp32 optimizer moments, or bf16 (stochastic rounding, half the memory) when fp32 wouldn't fit in RAM |
103
+ | `check_memory=True` | refuse at `wrap()` with a clear explanation when the run can't fit in RAM/VRAM |
84
104
  | `distributed` | data parallel when a `torch.distributed` group with more than 1 rank exists; gradients averaged on the host over gloo |
85
105
  | `max_grad_norm`, `accumulation_steps` | clipping and gradient accumulation, done inside the optimizer |
86
106
 
@@ -101,12 +121,14 @@ trainer.train()
101
121
  - `examples/verify_ram_offload.py` — proves where training memory lives (VRAM vs system RAM) with a PASS/FAIL verdict
102
122
  - `examples/hf_finetune.py` — fine-tune a Hugging Face model, optionally side by side with the stock Trainer
103
123
  - `examples/train_bigger_than_vram.py` — weight paging on a model built on the CPU, under a VRAM cap
104
- - `examples/lora_large_model.py` — LoRA on 7B/14B-size bases larger than the GPU, optionally against the stock Trainer
105
- - `docs/index.html` — full documentation
124
+ - `examples/lora_large_model.py` — LoRA on 7B/14B/32B-size bases larger than the GPU, bf16 or quantized, optionally against the stock Trainer
125
+ - `TrainStreamDocs.html` — full documentation, including every option and the values it accepts (open it in a browser)
126
+ - `TrainStreamReport.html` — what TrainStream does, how it compares with other libraries, and every measured result
106
127
 
107
128
  ## Limitations
108
129
 
109
- - 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
130
+ - Host RAM is the ceiling: 8–12 bytes per trained parameter and 2–4 per frozen one (1.06 int8, 0.56 NF4), plus activations; measured on 64 GB: a 3.6B full fine-tune and LoRA on a 32.8B NF4 base
131
+ - Quantization covers frozen block weights only (not embeddings, the output layer or trained weights) and is not bit-exact; `load_model` reads `.safetensors` causal-LM checkpoints
110
132
  - Weight paging covers the repeated block stack and needs `optimizer_mode="cpu"`; each paged weight crosses PCIe twice per step
111
133
  - Data parallel only (no tensor/pipeline parallelism); tested with 2 processes on 1 GPU, not across several GPUs or machines
112
134
  - `TrainStreamTrainer` is single-process; fp16 + GradScaler and AMSGrad aren't supported; `torch.compile` hasn't been tested
@@ -55,6 +55,8 @@ if torch is not None:
55
55
  "trainstream/csrc/memory_pinner.cpp",
56
56
  "trainstream/csrc/offload_adamw.cpp",
57
57
  "trainstream/csrc/offload_adamw_kernel.cu",
58
+ "trainstream/csrc/quant.cpp",
59
+ "trainstream/csrc/quant_kernel.cu",
58
60
  ],
59
61
  include_dirs=[os.path.join(HERE, "trainstream", "csrc")],
60
62
  extra_compile_args={"cxx": cxx_flags, "nvcc": nvcc_flags},
@@ -66,10 +68,10 @@ with open(os.path.join(HERE, "README.md"), encoding="utf-8") as fh:
66
68
 
67
69
  setup(
68
70
  name="trainstream",
69
- version="0.2.0",
71
+ version="0.4.0",
70
72
  author="John Anish G",
71
73
  license="Apache-2.0",
72
- description="Zero-dependency PCIe offloading engine for PyTorch training (optimizer, gradients, activations).",
74
+ description="Zero-dependency PyTorch training offload: optimizer, gradients, activations and weights to host RAM, with a memory planner and built-in int8/NF4 quantization.",
73
75
  long_description=long_description,
74
76
  long_description_content_type="text/markdown",
75
77
  url="https://github.com/johnanishg/TrainStream",
@@ -227,3 +227,99 @@ def test_trainer_takes_a_cpu_model_and_pages_its_layers(tmp_path):
227
227
  assert session.pager is not None and len(session.pager.paged) == 4
228
228
  assert session.pager.frozen_pinned_bytes + session.pager.frozen_pageable_bytes > 0
229
229
  assert losses(ts) == pytest.approx(losses(ref), abs=2e-4)
230
+
231
+
232
+ def saved_llama(tmp_path, tie=False, dtype=torch.float32):
233
+ torch.manual_seed(0)
234
+ cfg = LlamaConfig(vocab_size=VOCAB, hidden_size=128, intermediate_size=256, num_hidden_layers=4,
235
+ num_attention_heads=4, num_key_value_heads=2, max_position_embeddings=128,
236
+ tie_word_embeddings=tie)
237
+ LlamaForCausalLM(cfg).to(dtype).save_pretrained(str(tmp_path / "ckpt"))
238
+ return str(tmp_path / "ckpt")
239
+
240
+
241
+ @pytest.mark.parametrize("tie", [False, True])
242
+ def test_load_model_equals_from_pretrained(tmp_path, tie):
243
+ from trainstream.integrations.huggingface import load_model
244
+ d = saved_llama(tmp_path, tie=tie, dtype=torch.bfloat16)
245
+ ref = LlamaForCausalLM.from_pretrained(d, dtype=torch.bfloat16)
246
+ model = load_model(d, quantize="none")
247
+ assert model._ts_load_info["quantize"] is None
248
+ assert (model.lm_head.weight is model.model.embed_tokens.weight) == tie
249
+ ids = torch.randint(0, VOCAB, (2, 16))
250
+ with torch.no_grad():
251
+ assert torch.equal(ref(ids).logits, model(ids).logits)
252
+
253
+
254
+ @pytest.mark.parametrize("kind", ["int8", "nf4"])
255
+ def test_load_model_quantizes_while_reading(tmp_path, kind):
256
+ from trainstream import quant as Q
257
+ from trainstream.integrations.huggingface import load_model
258
+ d = saved_llama(tmp_path, dtype=torch.bfloat16)
259
+ ref = LlamaForCausalLM.from_pretrained(d, dtype=torch.bfloat16)
260
+ model = load_model(d, quantize=kind)
261
+ assert model._ts_load_info["quantized_tensors"] == 4 * 7 # q k v o gate up down per layer
262
+ for name, p in model.named_parameters():
263
+ qw = Q.quantized_of(p)
264
+ if qw is None:
265
+ assert torch.equal(p, ref.get_parameter(name)), name
266
+ continue
267
+ assert p.untyped_storage().nbytes() == 0 and not p.requires_grad and qw.kind == kind
268
+ q, a = Q.quantize_tensor(ref.get_parameter(name), kind, 64)
269
+ want = Q.dequantize_reference(q, a, kind, 64, p.numel(), torch.bfloat16).view(p.shape).cpu()
270
+ assert torch.equal(qw.dequantize().cpu(), want), name
271
+
272
+
273
+ def test_load_model_auto_picks_the_most_precise_format_that_fits(tmp_path, monkeypatch):
274
+ import trainstream.planner as planner
275
+ from trainstream.integrations.huggingface import load_model
276
+ d = saved_llama(tmp_path, dtype=torch.bfloat16)
277
+ numel = sum(p.numel() for p in LlamaForCausalLM.from_pretrained(d).parameters())
278
+ monkeypatch.setattr(planner, "local_processes", lambda: 1)
279
+ for ram_factor, want in ((1.1, None), (0.7, "int8"), (0.45, "nf4")): # RAM as a multiple of the bf16 bytes
280
+ monkeypatch.setattr(planner, "available_ram_bytes", lambda f=ram_factor: int(f * 2 * numel))
281
+ model = load_model(d, ram_fraction=1.0)
282
+ assert model._ts_load_info["quantize"] == want, ram_factor
283
+ with pytest.raises(ValueError):
284
+ load_model(d, quantize="fp4")
285
+
286
+
287
+ def test_load_model_random_weights_and_overrides(tmp_path):
288
+ from trainstream.integrations.huggingface import load_model
289
+ d = saved_llama(tmp_path)
290
+ model = load_model(d, random_weights=True, quantize="nf4", num_hidden_layers=2)
291
+ assert len(model.model.layers) == 2 and model._ts_load_info["quantized_tensors"] == 2 * 7
292
+
293
+
294
+ @pytest.mark.parametrize("kind", ["int8", "nf4"])
295
+ def test_trainer_qlora_on_loaded_model_matches_dequantized_reference(tmp_path, kind):
296
+ """load_model(quantize=...) + LoRA + TrainStreamTrainer equals the stock
297
+ Trainer on a model holding the dequantized base weights."""
298
+ peft = pytest.importorskip("peft")
299
+ from trainstream import quant as Q
300
+ from trainstream.integrations.huggingface import load_model
301
+ d = saved_llama(tmp_path)
302
+ model = load_model(d, quantize=kind, dtype=torch.float32)
303
+ ref_base = LlamaForCausalLM.from_pretrained(d, dtype=torch.float32)
304
+ for name, p in model.named_parameters():
305
+ qw = Q.quantized_of(p)
306
+ if qw is not None:
307
+ ref_base.get_parameter(name).data.copy_(qw.dequantize().cpu())
308
+ lora = dict(r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"], lora_dropout=0.0, task_type="CAUSAL_LM")
309
+ torch.manual_seed(1)
310
+ ref_model = peft.get_peft_model(ref_base.cuda(), peft.LoraConfig(**lora))
311
+ model = peft.get_peft_model(model, peft.LoraConfig(**lora))
312
+ ref_lora = dict(ref_model.named_parameters())
313
+ with torch.no_grad():
314
+ for n, p in model.named_parameters():
315
+ if p.requires_grad:
316
+ p.copy_(ref_lora[n].cpu())
317
+ ds = TokenDataset()
318
+ ref = Trainer(model=ref_model, args=args(tmp_path / "ref", max_steps=6), train_dataset=ds)
319
+ ref.train()
320
+ ts = TrainStreamTrainer(model=model, args=args(tmp_path / "ts", max_steps=6), train_dataset=ds,
321
+ trainstream={"offload": True, "activation_offload": False})
322
+ ts.train()
323
+ session = trainstream.wrapper.unwrap_session(model)
324
+ assert session.pager is not None and session.pager.quantized_bytes > 0
325
+ assert losses(ts) == pytest.approx(losses(ref), abs=2e-4)
@@ -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)