trainstream 0.4.1__tar.gz → 0.6.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 (67) hide show
  1. trainstream-0.6.0/PKG-INFO +598 -0
  2. trainstream-0.6.0/README.md +558 -0
  3. {trainstream-0.4.1 → trainstream-0.6.0}/setup.py +4 -2
  4. trainstream-0.6.0/tests/test_arena.py +148 -0
  5. trainstream-0.6.0/tests/test_attention.py +132 -0
  6. trainstream-0.6.0/tests/test_dpo.py +150 -0
  7. trainstream-0.6.0/tests/test_kvcache.py +98 -0
  8. trainstream-0.6.0/tests/test_lowp.py +202 -0
  9. trainstream-0.6.0/tests/test_multigpu.py +114 -0
  10. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_offload.py +4 -0
  11. trainstream-0.6.0/tests/test_outliers.py +156 -0
  12. trainstream-0.6.0/tests/test_peer.py +158 -0
  13. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_quant.py +6 -5
  14. trainstream-0.6.0/tests/test_recompute.py +153 -0
  15. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/__init__.py +16 -2
  16. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/_build.py +1 -1
  17. trainstream-0.6.0/trainstream/attention.py +194 -0
  18. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/bindings.cpp +37 -0
  19. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/kernels.h +29 -1
  20. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/memory_pinner.cpp +340 -7
  21. trainstream-0.6.0/trainstream/csrc/peer.cpp +175 -0
  22. trainstream-0.6.0/trainstream/csrc/quant.cpp +218 -0
  23. trainstream-0.6.0/trainstream/csrc/quant_kernel.cu +448 -0
  24. trainstream-0.6.0/trainstream/csrc/shm.cpp +221 -0
  25. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/trainstream.h +61 -1
  26. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/hooks.py +310 -7
  27. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/integrations/huggingface.py +144 -6
  28. trainstream-0.6.0/trainstream/kvcache.py +282 -0
  29. trainstream-0.6.0/trainstream/losses.py +202 -0
  30. trainstream-0.6.0/trainstream/lowp.py +222 -0
  31. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/optimizer.py +14 -2
  32. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/paging.py +171 -16
  33. trainstream-0.6.0/trainstream/peer.py +219 -0
  34. trainstream-0.6.0/trainstream/quant.py +336 -0
  35. trainstream-0.6.0/trainstream/shared.py +97 -0
  36. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/wrapper.py +250 -22
  37. trainstream-0.6.0/trainstream.egg-info/PKG-INFO +598 -0
  38. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream.egg-info/SOURCES.txt +16 -0
  39. trainstream-0.4.1/PKG-INFO +0 -325
  40. trainstream-0.4.1/README.md +0 -285
  41. trainstream-0.4.1/trainstream/csrc/quant.cpp +0 -71
  42. trainstream-0.4.1/trainstream/csrc/quant_kernel.cu +0 -124
  43. trainstream-0.4.1/trainstream/losses.py +0 -103
  44. trainstream-0.4.1/trainstream/quant.py +0 -214
  45. trainstream-0.4.1/trainstream.egg-info/PKG-INFO +0 -325
  46. {trainstream-0.4.1 → trainstream-0.6.0}/LICENSE +0 -0
  47. {trainstream-0.4.1 → trainstream-0.6.0}/MANIFEST.in +0 -0
  48. {trainstream-0.4.1 → trainstream-0.6.0}/NOTICE +0 -0
  49. {trainstream-0.4.1 → trainstream-0.6.0}/build.bat +0 -0
  50. {trainstream-0.4.1 → trainstream-0.6.0}/pyproject.toml +0 -0
  51. {trainstream-0.4.1 → trainstream-0.6.0}/setup.cfg +0 -0
  52. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_distributed.py +0 -0
  53. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_features.py +0 -0
  54. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_huggingface.py +0 -0
  55. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_optimizer.py +0 -0
  56. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_paging.py +0 -0
  57. {trainstream-0.4.1 → trainstream-0.6.0}/tests/test_planner.py +0 -0
  58. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/offload_adamw.cpp +0 -0
  59. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/offload_adamw.h +0 -0
  60. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/csrc/offload_adamw_kernel.cu +0 -0
  61. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/integrations/__init__.py +0 -0
  62. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/planner.py +0 -0
  63. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream/profiler.py +0 -0
  64. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream.egg-info/dependency_links.txt +0 -0
  65. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream.egg-info/not-zip-safe +0 -0
  66. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream.egg-info/requires.txt +0 -0
  67. {trainstream-0.4.1 → trainstream-0.6.0}/trainstream.egg-info/top_level.txt +0 -0
@@ -0,0 +1,598 @@
1
+ Metadata-Version: 2.4
2
+ Name: trainstream
3
+ Version: 0.6.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/FP8/NVFP4 quantization.
5
+ Home-page: https://github.com/johnanishg/TrainStream
6
+ Author: John Anish G
7
+ License: Apache-2.0
8
+ Project-URL: Source, https://github.com/johnanishg/TrainStream
9
+ Project-URL: Issues, https://github.com/johnanishg/TrainStream/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: C++
15
+ Classifier: Environment :: GPU :: NVIDIA CUDA
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ License-File: NOTICE
23
+ Requires-Dist: ninja
24
+ Provides-Extra: hf
25
+ Requires-Dist: transformers; extra == "hf"
26
+ Requires-Dist: accelerate; extra == "hf"
27
+ Requires-Dist: peft; extra == "hf"
28
+ Dynamic: author
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: license-file
35
+ Dynamic: project-url
36
+ Dynamic: provides-extra
37
+ Dynamic: requires-dist
38
+ Dynamic: requires-python
39
+ Dynamic: summary
40
+
41
+ # TrainStream
42
+
43
+ Train and fine-tune models larger than your GPU's memory. TrainStream moves optimizer state, gradients, activations and, when needed, the weights themselves into system RAM, and streams them back over PCIe exactly when the GPU needs them. Frozen weights (a LoRA base) can also be stored as int8, NF4, FP8 or NVFP4, the last two multiplied directly by Blackwell tensor cores. It takes one line of code and has no third-party dependencies.
44
+
45
+ ```python
46
+ import trainstream
47
+ model, optimizer = trainstream.wrap(model, optimizer)
48
+ ```
49
+
50
+ TrainStream is a C++/CUDA extension for PyTorch. It uses only the C++ standard library, the CUDA runtime and PyTorch's ATen: no DeepSpeed, no bitsandbytes. Updates match `torch.optim.AdamW` (max parameter difference ≈3·10⁻⁷ after 20 steps), and activation and weight paging are bit-exact.
51
+
52
+ ## What's new in 0.6
53
+
54
+ - **Outlier channels in bf16 for NVFP4** (`outliers=`). A few hidden dimensions of an LLM's activations are far larger than the rest. In NVFP4, one of them sets the scale of its 16-value block and rounds the other 15 to zero. With `outliers=0.01`, each matmul picks its largest 1% of input channels on the GPU (no host sync). It quantizes them as zero and multiplies them in bf16 against the same weight columns: `y = scaled_mm(nvfp4(x without them), W) + x[:, idx] @ W[:, idx].T`. The input gradient is unchanged. This removes the activation part of NVFP4's error, not the weight part: the weights stay 4-bit. `benchmarks/quality.py --formats nvfp4-w nvfp4 nvfp4-o` measures the split (`nvfp4-w` = 4-bit weights only). Measured on held-out text (table below): on Qwen2.5-14B NVFP4 costs +5.20 % perplexity, +2.65 % from the 4-bit weights and the rest from the activations; `outliers=0.01` brings it to +4.44 %, but after 20 LoRA steps it is no better (+4.64 % against +4.48 %). On Qwen2.5-1.5B it helps before and after fine-tuning (+10.96 → +9.21 %, +10.02 → +8.54 %). With that mixed result it stays off by default.
55
+ - **DPO without a second model or four logit tensors.**
56
+ - `sequence_logprobs()` gives per-sequence log-probabilities from the hidden states in chunks, and `dpo_loss()` combines them.
57
+ - `reference_logprobs()` (Hugging Face) runs the reference as the same paged or quantized base with the LoRA adapters switched off, once before training.
58
+ - See `examples/dpo_lora.py`.
59
+ - **Paged KV cache for generation during training** (online DPO, GRPO, PPO).
60
+ - `PagedKVCache` keeps as many layers' keys and values in VRAM as a budget allows. The others go to pinned host pages, which are copied back into a device slot on TrainStream's H2D stream while the earlier layers run.
61
+ - `TrainStreamCache` plugs it into `model.generate()`.
62
+ - VRAM holds the resident layers plus two or three one-layer slots.
63
+ - **Pinned host arena** (`host_arena="auto"`).
64
+ - Page-locked memory is reserved in slabs at `wrap()` and carved into blocks, so a training step never page-locks memory, which stalls on Windows (WDDM).
65
+ - Frozen weights that don't fit the page-lock budget no longer go through the driver's synchronous pageable copy. All CPU cores stage them into a ring of pinned slots, which are then copied asynchronously.
66
+ - **Peer GPUs** (`peer="auto"`, data parallel on one machine).
67
+ - When the GPUs can read each other's memory (NVLink, or PCIe P2P where the driver allows it), the frozen blocks every rank streams are split among the GPUs' VRAM. Each rank maps the others' pools through CUDA IPC and copies GPU to GPU instead of from host RAM.
68
+ - Where peer access or CUDA IPC isn't available it stays off, and the events say why. Two GeForce cards in one machine usually report no P2P.
69
+ - Tested with two processes sharing one GPU on Windows 11 and WSL2: the pools mapped through CUDA IPC, and training matched host streaming bit for bit. Copies between two real GPUs are untested.
70
+ - **Activation tuning, corrected and tighter.**
71
+ - Activations that fit in VRAM now stay there. Before, `"auto"` still paged some to free VRAM that nothing used: the 1.5B full fine-tune spent 12 slower tuning steps, then settled on 4 paged blocks at 1,078 ms/step against 1,020 ms with none.
72
+ - When host RAM can't hold every block's activations, the trials now apply the setting they measured fastest. Before, the forced recompute shifted it: 14B NF4 at 2,048 tokens ran 44 recomputed + 3 paged blocks (never measured) instead of the measured 47 recomputed; 14B int8 at 2,048 ran 47 recomputed (3.25 s) instead of the measured 36 (3.17 s).
73
+ - Once the trials settle, the VRAM left under the target keeps the latest blocks' activations instead of recomputing or paging them, measured on a clean step.
74
+ - Measured with the same harness in WSL2, before → after: 1.5B full fine-tune 1.03 → 0.99 s/step; 14B LoRA at 512 tokens with `"auto"` 0.90 → 0.84 s; at 2,048 tokens NVFP4 2.14 → 2.06 s, NF4 3.18 → 3.02 s, `"auto"` 3.28 → 3.20 s. Single runs: differences under about 5 % are within run-to-run noise. The faster 2,048-token runs use more of the VRAM under the target (NF4: 17.8 → 20.4 GB peak).
75
+ - **Benchmarks on both systems, every format.** The comparison below now covers WSL2 and Windows 11, with every engine run to the end, and the perplexity table covers every format on Qwen2.5-14B and 1.5B, before and after fine-tuning. `benchmarks/quality.py --format F` runs one format in its own process, and `--live` streams its progress as JSON lines.
76
+
77
+ ## What's new in 0.5
78
+
79
+ - **FP8 and NVFP4 on tensor cores.** `quantize="fp8"` or `"nvfp4"` store frozen weights block-scaled. On Blackwell GPUs (RTX 50-series, B200) the forward matmuls run on 8- and 4-bit tensor cores through PyTorch's own `scaled_mm`: 0.188 ms for a 512×5120×13824 FP4 matmul against 1.215 ms in bf16. Real Qwen2.5-14B LoRA (Windows, same session): 0.586 s/step with NVFP4 against 0.642 s with NF4 at the same memory. NVFP4 also quantizes activations, so it costs more quality (perplexity +5.2 % on 14B, +11 % on 1.5B, against +3.1 % / +5.9 % for NF4). It stays opt-in: `prefer="speed"` still picks NF4.
80
+ - **Recompute instead of stream** (`recompute="auto"`). Under memory pressure, activations are recomputed in backward where measured trials show that's faster than sending them over PCIe. Paged bf16 14B LoRA: 1.39 → 0.98 s/step. 14B 4-bit LoRA at 2,048 tokens: 5.10 → 1.96 s/step, together with the next item.
81
+ - **Attention backend selection** (`attention="auto"`). It times PyTorch's attention kernels once on your model's shape and uses the fastest. On an RTX 5090 that is usually cuDNN, 1.5–2.8× faster than PyTorch's default pick on the shapes measured. Under `torch.use_deterministic_algorithms(True)` the non-deterministic kernels (cuDNN, flash) are left out; with `warn_only=True` the memory-efficient kernel is left out too, because PyTorch then keeps its non-deterministic backward. The choice is re-read on every training forward, so switching deterministic mode after `wrap()` reaches recomputed blocks as well.
82
+ - **It says what it decided.** `memory_report(model)["events"]` now also logs the activation tuner (calibration, how many blocks it pages, each slowdown it measured and where it settled), and `model._trainstream_session.adapting` is `True` while TrainStream is still tuning itself, so you can tell when step times are steady.
83
+ - **Several GPUs in one machine.** Data-parallel ranks share one host copy of the frozen base and reduce gradients through shared memory instead of loopback sockets. Tested with two processes on one GPU, not yet on a multi-GPU machine.
84
+ - **Benchmarks against other libraries**: `benchmarks/compare.py` (stock PyTorch, bitsandbytes QLoRA, Unsloth, DeepSpeed) and `benchmarks/quality.py` (perplexity per format). Results are below. The harness keeps the comparison fair:
85
+ - every engine in a scenario uses the same learning rate (2e-4 for LoRA, 2e-5 for full fine-tuning)
86
+ - engines that only train adapters on a 4-bit base are skipped in the full fine-tune
87
+ - every engine's step time is the median of the same number of steps, taken after its warm-up; for TrainStream the warm-up runs until it has finished tuning itself
88
+ - peak VRAM is the peak over the whole run, even when an engine resets PyTorch's peak counter
89
+ - `--live` streams every step and memory sample as JSON lines
90
+ - **Fixes:**
91
+ - `load_model` with quantization kept every checkpoint shard mapped in memory (40.5 GB instead of 14.2 GB for 14B).
92
+ - Activation calibration could overcommit VRAM at long sequences. A probe step now measures one block's activations first, recomputing the others, so it is safe for host RAM too.
93
+ - LoRA adapters on a quantized base stay in VRAM instead of being paged with the block (14B NVFP4 LoRA: 0.60 → 0.52 s/step).
94
+
95
+ ## What's new in 0.4
96
+
97
+ - **0.4.1:** install instructions for uv; clearer messages when the installed torch is the CPU-only build.
98
+ - **0.4.0:** built-in int8 / NF4 weight quantization (`quantize=`), `load_model()` (quantize while reading a checkpoint), `quantize_model()`. Real Qwen2.5-14B LoRA 1.37 → 0.87 s/step; a 32B-shaped base trains on 64 GB of RAM.
99
+
100
+ ## Against other libraries (same laptop: WSL2 with 31 GB RAM, then Windows 11 with 64 GB)
101
+
102
+ > TrainStream 0.6.0, one run per engine, 10 steps each (TrainStream: 10 steps after it finished tuning itself), median of the steps after the warm-up. Every engine ran all its steps; none was stopped at a time limit. Some engines were still getting a few percent faster at step 10, so read differences under about 5 % as noise.
103
+
104
+ **LoRA r=16, Qwen2.5-14B, batch 1 × 512 (WSL2)**
105
+
106
+ | Engine | Step time | Peak VRAM | Outcome |
107
+ |---|---|---|---|
108
+ | TrainStream, NVFP4 base (FP4 tensor cores) | 0.56 s | 19.1 GB | fastest |
109
+ | bitsandbytes QLoRA (NF4) + PEFT | 0.63 s | 17.4 GB | 1.13× the fastest |
110
+ | TrainStream, NF4 base | 0.67 s | 19.1 GB | 1.20× the fastest |
111
+ | Unsloth (4-bit) | 0.75 s | 11.5 GB | 1.34× the fastest |
112
+ | TrainStream (auto) | 0.84 s | 20.6 GB | 1.51× the fastest |
113
+ | bitsandbytes QLoRA + gradient checkpointing | 0.90 s | 12.7 GB | 1.61× the fastest |
114
+ | DeepSpeed ZeRO-2, optimizer offload | 42.38 s | 35.9 GB | 75.93× the fastest (spills past VRAM) |
115
+ | Stock PyTorch (+ PEFT) | 50.63 s | 37.6 GB | 90.70× the fastest (spills past VRAM) |
116
+
117
+ **LoRA r=16, Qwen2.5-14B, batch 1 × 2048 (WSL2)**
118
+
119
+ | Engine | Step time | Peak VRAM | Outcome |
120
+ |---|---|---|---|
121
+ | TrainStream, NVFP4 base (FP4 tensor cores) | 2.06 s | 19.8 GB | fastest |
122
+ | Unsloth (4-bit) | 2.55 s | 11.7 GB | 1.24× the fastest |
123
+ | bitsandbytes QLoRA + gradient checkpointing | 2.89 s | 16.3 GB | 1.40× the fastest |
124
+ | TrainStream, NF4 base | 3.02 s | 20.4 GB | 1.47× the fastest |
125
+ | TrainStream (auto) | 3.20 s | 22.0 GB | 1.55× the fastest |
126
+ | bitsandbytes QLoRA (NF4) + PEFT | 158.36 s | 34.7 GB | 76.84× the fastest (spills past VRAM) |
127
+ | DeepSpeed ZeRO-2, optimizer offload | 224.17 s | 53.0 GB | 108.77× the fastest (spills past VRAM) |
128
+ | Stock PyTorch (+ PEFT) | 424.05 s | 57.9 GB | 205.75× the fastest (spills past VRAM) |
129
+
130
+ **Full fine-tune, Qwen2.5-1.5B, batch 4 × 512 (WSL2)**
131
+
132
+ | Engine | Step time | Peak VRAM | Outcome |
133
+ |---|---|---|---|
134
+ | TrainStream (auto) | 0.99 s | 20.0 GB | fastest |
135
+ | Stock PyTorch (+ PEFT) | 1.93 s | 32.4 GB | 1.95× the fastest (spills past VRAM) |
136
+ | DeepSpeed ZeRO-2, optimizer offload | — | — | killed: out of host RAM (31 GB in WSL) |
137
+
138
+ **The same on Windows 11 (64 GB RAM; Unsloth and DeepSpeed aren't installed there)**
139
+
140
+ | Engine | LoRA 14B, 1 × 512 | LoRA 14B, 1 × 2048 | Full fine-tune 1.5B, 4 × 512 |
141
+ |---|---|---|---|
142
+ | TrainStream, NVFP4 base | **0.60 s** | **1.92 s** | — |
143
+ | TrainStream, NF4 base | 0.68 s | 2.57 s | — |
144
+ | TrainStream (auto) | 1.06 s | 3.25 s | **1.06 s** |
145
+ | bitsandbytes QLoRA (NF4) + PEFT | 0.65 s | 162.72 s (spills) | — |
146
+ | bitsandbytes QLoRA + gradient checkpointing | 0.95 s | 3.16 s | — |
147
+ | Stock PyTorch (+ PEFT) | 53.11 s (spills) | 267.78 s (spills) | 1.67 s (spills) |
148
+
149
+ - With an NVFP4 base, TrainStream had the fastest step in every 14B LoRA run, in WSL and on Windows: 0.56 / 0.60 s at 512 tokens, 2.06 / 1.92 s at 2,048. It holds more in VRAM than Unsloth (19–20 GB against 11.5–11.7), and NVFP4 costs quality (see the perplexity table below).
150
+ - With an NF4 base, the format bitsandbytes and Unsloth use, TrainStream was 7 % slower than bitsandbytes at 512 tokens in WSL (5 % on Windows). At 2,048 tokens it was behind Unsloth and bitsandbytes with checkpointing in WSL, where host RAM held only 25 of 47 layers' activations and it recomputed the rest; on Windows host RAM held 43, it paged 35, and it was ahead of bitsandbytes with checkpointing (2.57 s against 3.16 s).
151
+ - `"auto"` picks differently per machine: in WSL (31 GB of RAM, too little for the bf16 base) it quantized the 14B base to int8 and kept every layer in VRAM (0.84 s at 512 tokens); on Windows (64 GB) it stayed exact in bf16 and streamed 21 of 48 layers from RAM (1.06 s).
152
+ - The full fine-tune: TrainStream keeps its activations in VRAM and stays at 20.0 GB, 1.95× faster than stock PyTorch in WSL and 1.58× on Windows. Stock PyTorch peaks at 32.4 GB, more than the GPU's 24 GB, so its step time depends on how the driver spills into system RAM (1.33–1.94 s across runs).
153
+ - Stock PyTorch, bitsandbytes without checkpointing at 2,048 tokens, and DeepSpeed did not fit in 24 GB of VRAM: the driver spilled into system RAM (42–424 s per step), or the run was killed.
154
+
155
+ **Perplexity against bf16** (`benchmarks/quality.py`, held-out Python standard-library source; lower is better)
156
+
157
+ | Format | Qwen2.5-14B | after 20 LoRA steps | Qwen2.5-1.5B | after 40 LoRA steps |
158
+ |---|---|---|---|---|
159
+ | int8 | +0.02 % | +0.05 % | +0.04 % | −0.01 % |
160
+ | FP8 (tensor cores) | +0.84 % | +0.79 % | +1.78 % | +1.43 % |
161
+ | NF4 | +3.09 % | +2.85 % | +5.91 % | +5.00 % |
162
+ | NVFP4, weights only (bf16 matmuls) | +2.65 % | +2.26 % | +5.43 % | +5.04 % |
163
+ | NVFP4 (tensor cores) | +5.20 % | +4.48 % | +10.96 % | +10.02 % |
164
+ | NVFP4 with `outliers=0.01` | +4.44 % | +4.64 % | +9.21 % | +8.54 % |
165
+
166
+ bf16 itself: 2.300 (14B) and 3.251 (1.5B) on 16 and 64 windows of 1,024 tokens. NVFP4's 4-bit weights alone cost less than NF4; its extra loss comes from quantizing the activations. On 1.5B the short fine-tune raised bf16's own held-out perplexity (3.251 → 3.306), so compare the formats with each other there.
167
+
168
+ Each engine runs in a fresh process with the same model, random-token data, LoRA settings (r=16, all projections), learning rate and loop. Step time is the median of the steps after the warm-up: 2 steps, plus, for TrainStream, the steps until it has finished tuning itself. Unsloth uses its own defaults. Re-run with `benchmarks/compare.py` and `benchmarks/quality.py`.
169
+
170
+ ## Results (RTX 5090 Laptop GPU 24 GB, 64 GB RAM)
171
+
172
+ Measured with TrainStream 0.1–0.4, when each feature arrived: these rows show what fits and how, not today's speed (the comparison above is 0.6.0).
173
+
174
+ | Workload | Stock PyTorch | TrainStream |
175
+ |---|---|---|
176
+ | 1.8B-parameter GPT, fp32, batch 8×512 | 34.1 GiB peak: exceeds VRAM, driver spills to RAM, 13.7 s/step | 20.6 GiB, 1.1 s/step (13.5 GiB with all activations paged) |
177
+ | Qwen2.5-1.5B full fine-tune (Hugging Face Trainer) | 35.9 GiB peak (spill), 111.7 s / 10 steps | 18.7 GiB, 28.5 s / 10 steps, same losses |
178
+ | 1.8B GPT, weights paged to RAM | — | 2.4 GiB peak, 2.1 s/step; 1.84 GiB inside a 4 GB VRAM cap with bf16 moments |
179
+ | 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 |
180
+ | LoRA r=16, Qwen2.5-7B architecture | 20.3 GB peak, 0.27 s/step | 4.6 GB peak, 1.25 s/step (every layer paged) |
181
+ | LoRA r=16, real Qwen2.5-14B, bf16 base | does not fit (29.5 GB of weights) | 1.37 s/step: the memory planner keeps 28 of 48 layers in VRAM and streams 20 from RAM |
182
+ | LoRA r=16, real Qwen2.5-14B, NF4 base (`quantize="auto", prefer="speed"`) | does not fit | **0.87 s/step**: all 48 layers in VRAM as 4-bit, 18.3 GB peak, 11.3 GB process RAM |
183
+ | LoRA r=16, Qwen2.5-32B architecture, NF4 base (`load_model(quantize="nf4")`) | does not fit (65.5 GB in bf16, more than the RAM) | 2.53 s/step, 21.3 GB peak, 31.4 GB process RAM |
184
+ | 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 |
185
+
186
+ Offloading costs throughput when a model already fits, which is why `offload="auto"` measures two steps and switches to plain fused GPU AdamW if offloading isn't needed. TrainStream's value is training what doesn't fit, or freeing VRAM for larger batches. All numbers come from one machine. The 7B and 32B rows use the exact Qwen2.5 shapes with random weights; the 14B rows use the real checkpoint.
187
+
188
+ ## Requirements
189
+
190
+ - An NVIDIA GPU and a CUDA build of PyTorch
191
+ - 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)
192
+ - Optional, for the Hugging Face integration: `transformers`, `accelerate`, and `peft` for LoRA (all three come with `trainstream[hf]`)
193
+
194
+ Tested on an RTX 5090 Laptop GPU with Windows 11 (PyTorch 2.14.1, CUDA 13.4, MSVC, Python 3.14), where all 223 tests pass with no warnings, and Ubuntu 24.04 under WSL2 (PyTorch 2.14.1, CUDA 13.3, GCC 13.3, Python 3.12), where all 223 pass as well (with bitsandbytes 0.50.2 installed for the NF4 comparison), both from a wheel installed with pip and with uv. Installs were checked with pip and with uv 0.13 (`uv pip install`, `uv sync` in a project, and building from the sdist) on both. Native Linux and other GPUs are untested.
195
+
196
+ ## Install
197
+
198
+ TrainStream installs with **pip** or **uv**. It is a small pure-Python wheel and doesn't pull in PyTorch, because you choose the CUDA build of PyTorch that matches your driver. Install CUDA PyTorch first (or in the same command), then TrainStream.
199
+
200
+ **pip**
201
+
202
+ ```bash
203
+ pip install torch --index-url https://download.pytorch.org/whl/cu130 # if you don't have CUDA PyTorch yet
204
+ pip install trainstream # core library
205
+ pip install "trainstream[hf]" # + transformers, accelerate, peft
206
+ ```
207
+
208
+ **uv** (`uv pip`)
209
+
210
+ ```bash
211
+ uv pip install torch trainstream --torch-backend=auto # picks the CUDA build for your driver
212
+ uv pip install torch "trainstream[hf]" --torch-backend=auto # + transformers, accelerate, peft
213
+ ```
214
+
215
+ **uv project** (`uv add` / `uv sync`). Point `torch` at the PyTorch index in your `pyproject.toml`:
216
+
217
+ ```toml
218
+ [project]
219
+ dependencies = ["torch", "trainstream"] # or "trainstream[hf]"
220
+
221
+ [tool.uv.sources]
222
+ torch = { index = "pytorch-cu130" }
223
+
224
+ [[tool.uv.index]]
225
+ name = "pytorch-cu130"
226
+ url = "https://download.pytorch.org/whl/cu130"
227
+ explicit = true
228
+ ```
229
+
230
+ Then `uv sync` and `uv run python train.py`.
231
+
232
+ > **CPU-only torch trap.** On Windows, `torch` from PyPI is the CPU build. If something else that depends on torch (for example `transformers` or `accelerate`) is installed without the CUDA index, the installer may fetch it, and `import trainstream` then stops with a message saying so. Fix: `pip install torch --index-url https://download.pytorch.org/whl/cu130 --force-reinstall`, or `uv pip install torch --torch-backend=auto --reinstall-package torch`.
233
+
234
+ The first `import trainstream` compiles the CUDA extension against your PyTorch (16–19 s on Windows and 18 s on Linux, measured) and caches it; later imports take about a second. On Windows it finds Visual Studio and the CUDA toolkit itself, so any terminal works. The cache is keyed by PyTorch, CUDA and Python version and GPU, so upgrading PyTorch triggers one rebuild.
235
+
236
+ Upgrade: `pip install -U --no-cache-dir trainstream` or `uv pip install -U trainstream` (`uv lock --upgrade-package trainstream` in a project).
237
+
238
+ To compile at install time instead of on first import: `pip install trainstream --no-binary trainstream --no-build-isolation` (or the same flags with `uv pip install`). From a checkout: `build.bat` (Windows) or `python setup.py build_ext --inplace`.
239
+
240
+ ## Quick start
241
+
242
+ ```python
243
+ import torch, trainstream
244
+
245
+ model = MyModel().cuda() # 1. build the model
246
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) # 2. a normal optimizer
247
+ model, optimizer = trainstream.wrap(model, optimizer) # 3. wrap once, before the first forward
248
+
249
+ for x, y in loader: # 4. an ordinary training loop
250
+ with torch.autocast("cuda", dtype=torch.bfloat16):
251
+ loss = model(x.cuda(), y.cuda())
252
+ loss.backward() # gradients stream to host RAM during backward
253
+ optimizer.step() # host AdamW; returns once the update is queued
254
+ optimizer.zero_grad()
255
+ ```
256
+
257
+ ### A model larger than the GPU
258
+
259
+ Leave it on the CPU and pass `device`. The memory planner measures the machine's VRAM and RAM, keeps as many layers resident as fit, and streams the rest:
260
+
261
+ ```python
262
+ model = MyGPT(cfg) # stays on the CPU
263
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
264
+ model, optimizer = trainstream.wrap(model, optimizer, device="cuda")
265
+ ```
266
+
267
+ ### Hugging Face Trainer
268
+
269
+ Swap one class name (`pip install transformers accelerate`):
270
+
271
+ ```python
272
+ from transformers import AutoModelForCausalLM, TrainingArguments
273
+ from trainstream.integrations.huggingface import TrainStreamTrainer
274
+
275
+ model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B", dtype=torch.float32)
276
+ args = TrainingArguments(output_dir="out", bf16=True, per_device_train_batch_size=4,
277
+ gradient_accumulation_steps=2, learning_rate=2e-5, max_grad_norm=1.0)
278
+ trainer = TrainStreamTrainer(model=model, args=args, train_dataset=ds,
279
+ trainstream={"moment_dtype": "auto"}) # optional: any wrap() option
280
+ trainer.train()
281
+ ```
282
+
283
+ `TrainStreamTrainer` computes the causal-LM loss in chunks, so the full logits tensor never exists. Hyper-parameters, the LR schedule, accumulation, clipping, checkpoints and `resume_from_checkpoint` carry over. Use `bf16=True` or fp32; fp16 with loss scaling isn't supported. For your own loop: `model, optimizer = wrap_hf(model, optimizer)`.
284
+
285
+ ### LoRA on a quantized base (QLoRA-style)
286
+
287
+ ```python
288
+ import peft
289
+ from trainstream.integrations.huggingface import TrainStreamTrainer, load_model
290
+
291
+ model = load_model("Qwen/Qwen2.5-14B", quantize="nf4") # streams the checkpoint, quantizes while reading
292
+ model = peft.get_peft_model(model, peft.LoraConfig(
293
+ r=16, lora_alpha=32, lora_dropout=0.0, task_type="CAUSAL_LM",
294
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]))
295
+ trainer = TrainStreamTrainer(model=model, args=TrainingArguments(output_dir="out", bf16=True), train_dataset=ds)
296
+ trainer.train()
297
+ ```
298
+
299
+ Or let `wrap()` decide: `trainstream.wrap(model, optimizer, device="cuda", quantize="auto", prefer="speed")`. Or quantize beforehand: `trainstream.quantize_model(model, "int8")`. Only frozen weights are quantized; freeze the base (or add adapters) first.
300
+
301
+ ### Data parallel
302
+
303
+ ```python
304
+ # torchrun --nproc_per_node=2 train.py
305
+ import os, torch.distributed as dist
306
+ dist.init_process_group("nccl")
307
+ torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))
308
+ model, optimizer = trainstream.wrap(MyModel().cuda(), torch.optim.AdamW(...)) # detected automatically
309
+ ```
310
+
311
+ ### DPO with LoRA
312
+
313
+ ```python
314
+ import trainstream
315
+ from trainstream.integrations.huggingface import causal_lm_logprobs, load_model, reference_logprobs, wrap_hf
316
+
317
+ model = peft.get_peft_model(load_model("Qwen/Qwen2.5-7B", quantize="nf4"), lora_config)
318
+ model, optimizer = wrap_hf(model, torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=5e-6))
319
+ pairs = [(chosen, rejected), ...] # dicts with input_ids and labels (-100 on the prompt), batch_size rows each
320
+ # adapters off: the same base is the reference; computed once, up front
321
+ ref = reference_logprobs(model, [b for pair in pairs for b in pair]).split(batch_size)
322
+ for k, (chosen, rejected) in enumerate(pairs):
323
+ pc = causal_lm_logprobs(model, chosen["input_ids"], chosen["labels"])
324
+ pr = causal_lm_logprobs(model, rejected["input_ids"], rejected["labels"])
325
+ loss, chosen_reward, rejected_reward = trainstream.dpo_loss(pc, pr, ref[2 * k], ref[2 * k + 1], beta=0.1)
326
+ loss.backward(); optimizer.step(); optimizer.zero_grad()
327
+ ```
328
+
329
+ ### Generating with a paged KV cache
330
+
331
+ ```python
332
+ from trainstream.integrations.huggingface import TrainStreamCache
333
+
334
+ cache = TrainStreamCache(model, max_tokens=prompt_len + 512) # resident layers chosen from free VRAM
335
+ out = model.generate(**inputs, max_new_tokens=512, do_sample=True, past_key_values=cache)
336
+ cache.reset() # before the next batch of prompts
337
+ ```
338
+
339
+ ### Inspecting what it decided
340
+
341
+ ```python
342
+ r = trainstream.memory_report(model)
343
+ r["events"] # plain-text log: quantization, memory plan, attention backend,
344
+ # activation paging / recompute decisions, resident mode, .to()
345
+ r["params"]["paged_blocks"] # layers whose weights stream from host RAM
346
+ r["params"]["quantize"] # None, "int8", "nf4", "fp8" or "nvfp4"
347
+ r["activations"]["policy"] # per block: "keep", "offload" or "recompute"
348
+ r["plan"]["history"] # every planner move, with the measured peaks
349
+ model._trainstream_session.adapting # True while it is still tuning itself
350
+ ```
351
+
352
+ ### Rules of thumb
353
+
354
+ - Order: build the model → freeze / add adapters → (quantize) → create the optimizer → `wrap()` → train.
355
+ - Use `max_grad_norm=` instead of `torch.nn.utils.clip_grad_norm_`; gradients have already left the GPU.
356
+ - Declare gradient accumulation with `accumulation_steps=`.
357
+ - `model.state_dict()` and `optimizer.state_dict()` work while wrapped. `trainstream.unwrap(model)` loads every weight back into VRAM and removes the hooks.
358
+ - Under WSL2 the Linux VM gets only part of the machine's RAM (about half by default), and TrainStream plans from what the VM has. If host RAM is the limit (the events say "host RAM holds N of M blocks' activations"), raise `memory=` in `%UserProfile%\.wslconfig`: in the benchmarks below, NF4 at 2,048 tokens ran 3.02 s/step in WSL with 31 GB against 2.57 s on Windows with 64 GB.
359
+
360
+ ## Options
361
+
362
+ ### `trainstream.wrap(model, optimizer=None, **options) → (model, optimizer)`
363
+
364
+ | Option | Accepted values | Default | What it does |
365
+ |---|---|---|---|
366
+ | `optimizer` | `torch.optim.AdamW`, `torch.optim.Adam`, `trainstream.OffloadAdamW`, `None` | `None` | converted, with hyper-parameters and state migrated; `None` creates one with `lr` |
367
+ | `optimizer_mode` | `"cpu"`, `"gpu"`, `"auto"` | `"cpu"` | host AdamW, a zero-copy CUDA kernel, or chosen from the hardware profile |
368
+ | `offload` | `"auto"`, `True` | `"auto"` | `"auto"` switches to fused torch AdamW after 2 steps if the model fits |
369
+ | `activation_offload` | `"auto"`, `True`, `False`, fraction 0.0–1.0 | `"auto"` | how many blocks' activations are paged to RAM; `"auto"`: measured in the first step, kept in VRAM if they fit under the VRAM target, otherwise paged as far as host RAM holds them (see `recompute` for the rest); `True`: all but the last block |
370
+ | `param_offload` | `"auto"`, `True`, `False`, fraction 0.0–1.0, list of block indices | `"auto"` | `"auto"`: the memory planner decides which layers stay in VRAM |
371
+ | `quantize` | `"auto"`, `"none"`, `None`, `False`, `"int8"`, `"nf4"`, `"fp8"`, `"nvfp4"` | `"auto"` | frozen weights only; `"auto"` quantizes only when the run can't fit otherwise (int8 before NF4); fp8 / nvfp4 run on Blackwell tensor cores |
372
+ | `quant_block_size` | even integer ≥ 2 | `64` | values per fp32 scale (int8 / nf4; fp8 and nvfp4 use fixed 32 / 16) |
373
+ | `prefer` | `"exact"`, `"speed"` | `"exact"` | `"speed"` lets `quantize="auto"` choose NF4 when it keeps every layer in VRAM |
374
+ | `recompute` | `"auto"`, `True`, `False`, fraction 0.0–1.0, list of block indices | `"auto"` | recompute blocks in backward instead of keeping or paging their activations; `"auto"`: when the activations can't all stay in VRAM (weights paged, or too large), as many as measured trials show is fastest, then blocks go back to VRAM as far as the room left under the target allows |
375
+ | `attention` | `"auto"`, `"cudnn"`, `"efficient"`, `"flash"`, `"math"`, `None` | `"auto"` | attention backend: the fastest measured on your model's shape, a named one first, or PyTorch's own order; deterministic mode leaves out the non-deterministic kernels |
376
+ | `outliers` | `0`, `False`, `None`, `"auto"`, `True`, float 0.0–0.1 | `0` | with `quantize="nvfp4"` on tensor cores: the fraction of input channels (the largest of each call) multiplied in bf16 instead of 4-bit; `"auto"` / `True` mean `0.01`; ignored by other formats |
377
+ | `host_arena` | `"auto"`, `False`, `None`, bytes | `"auto"` | page-locked host memory reserved up front, from which paged activations, staging and other pinned buffers are carved; `"auto"`: when the run streams anything, reserve one slab now and grow on demand within the page-lock budget; bytes: reserve that much now and cap growth there |
378
+ | `peer` | `"auto"`, `False`, `None`, bytes | `"auto"` | data parallel on one machine: pool the frozen blocks every rank streams in the GPUs' VRAM and copy them GPU to GPU (needs peer access between the GPUs and CUDA IPC; set up collectively at step 10); `"auto"`: when peer copies reach 0.9× the host link, each GPU gives half the headroom under the memory plan's target; bytes: that much per GPU. While active, `unwrap()` is collective |
379
+ | `moment_dtype` | `"auto"`, `"float32"`, `"bfloat16"`, `"bf16"`, `torch.bfloat16` | `"auto"` | fp32 unless that wouldn't fit in RAM; bf16 uses stochastic rounding |
380
+ | `device` | `None`, `"cuda"`, `"cuda:N"`, `torch.device` | `None` | target GPU when the model is on the CPU |
381
+ | `blocks` | `None`, list of layer modules | `None` | the repeated layer stack; auto-detected |
382
+ | `distributed` | `None`, `True`, `False` | `None` | data parallel when a process group with > 1 rank exists |
383
+ | `guard_params` | `True`, `False` | `True` | reads of a weight wait for its in-flight update |
384
+ | `max_grad_norm` | `None`, float | `None` | global-norm clipping inside the optimizer |
385
+ | `accumulation_steps` | integer ≥ 1 | `1` | backward passes per `step()` |
386
+ | `vram_headroom` | float 0.0–1.0 | `0.10` | share of usable VRAM the planner leaves free |
387
+ | `check_memory` | `True`, `False` | `True` | raise `MemoryError` at `wrap()` when the run can't fit |
388
+ | `max_inflight_bytes` | `None`, bytes | `None` (1 GiB or 10% of VRAM) | VRAM held by transfers still crossing PCIe |
389
+ | `max_activation_slowdown` | float | `0.15` | how much slower `"auto"` activation paging may make a step when there is no VRAM target to measure against (`ActivationOffloader` used on its own); `wrap()` always has one, so activations that fit stay in VRAM instead |
390
+ | `prefetch_depth` | integer ≥ 1 | `1` | activation blocks fetched ahead in backward |
391
+ | `min_offload_bytes` | bytes | `1048576` | smaller activations stay on the GPU |
392
+ | `threads` | `None`, integer | `None` (all cores) | host AdamW threads |
393
+ | `profile` | `True`, `False` | `True` | run (or load cached) hardware benchmarks |
394
+ | `lr` | float | `1e-3` | used only when `optimizer` is `None` |
395
+ | `verbose` | `True`, `False` | `False` | print decisions as they happen |
396
+
397
+ Anything else raises `ValueError` (for example `quantize="fp4"`).
398
+
399
+ ### Quantization formats
400
+
401
+ | `quantize=` | Bytes per weight | Quality cost (perplexity against bf16: Qwen2.5-14B / 1.5B) | Use it when |
402
+ |---|---|---|---|
403
+ | `"none"` | 2 (bf16) | none (bit-exact) | it fits |
404
+ | `"int8"` | 1.06 | +0.02 % / +0.04 % (mean weight error 0.6 %) | bf16 doesn't fit in RAM |
405
+ | `"nf4"` | 0.56 | +3.1 % / +5.9 % (mean weight error 9 %) | int8 doesn't fit either, or with `prefer="speed"` |
406
+ | `"fp8"` | 1.03 | +0.8 % / +1.8 % | FP8 tensor-core matmuls on Blackwell, near-bf16 quality |
407
+ | `"nvfp4"` | 0.56 | +5.2 % / +11.0 % (+4.4 % / +9.2 % with `outliers=0.01`) | the fastest 4-bit path on Blackwell, when its quality is acceptable |
408
+ | `"auto"` | — | — | decided from the measured RAM and VRAM |
409
+
410
+ NF4 is bit-identical to bitsandbytes' NF4 (same codes, packing and scales); TrainStream doesn't depend on bitsandbytes. A quantized run trains on the dequantized weights, as QLoRA does, so it isn't bit-exact with the bf16 base.
411
+
412
+ ### `load_model(name_or_path, ...)` (in `trainstream.integrations.huggingface`)
413
+
414
+ | Argument | Accepted values | Default |
415
+ |---|---|---|
416
+ | `name_or_path` | Hub id or local folder with `.safetensors` files | — |
417
+ | `quantize` | `"auto"`, `"none"`, `None`, `False`, `"int8"`, `"nf4"`, `"fp8"`, `"nvfp4"` | `"auto"` (quantize only if bf16 wouldn't fit in `ram_fraction` of free RAM) |
418
+ | `dtype` | `torch.bfloat16`, `torch.float16`, `torch.float32` | `torch.bfloat16` |
419
+ | `quant_block_size` | even integer ≥ 2 | `64` |
420
+ | `random_weights` | `True`, `False` | `False` (build the shapes without reading a checkpoint) |
421
+ | `ram_fraction` | float 0.0–1.0 | `0.85` |
422
+ | `trust_remote_code` | `True`, `False` | `False` |
423
+ | `**config_overrides` | any config field, e.g. `num_hidden_layers=4` | — |
424
+
425
+ ### `trainstream.quantize_model(model, quantize="nf4", *, blocks=None, block_size=64, pin_budget=None)`
426
+
427
+ Quantizes the frozen block weights in place, one tensor at a time, before `wrap()`. Returns `{"tensors": count, "bytes_before": ..., "bytes_after": ...}`.
428
+
429
+ | Argument | Accepted values | Default | What it does |
430
+ |---|---|---|---|
431
+ | `model` | `nn.Module` | — | the model; only frozen (`requires_grad=False`) weights of the block stack are quantized |
432
+ | `quantize` | `"int8"`, `"nf4"`, `"fp8"`, `"nvfp4"` | `"nf4"` | the storage format (see the table above) |
433
+ | `blocks` | `None`, list of layer modules | `None` | the layer stack to quantize; auto-detected with `find_blocks` |
434
+ | `block_size` | even integer ≥ 2 | `64` | values per scale for int8 / NF4 (FP8 and NVFP4 use fixed 32 / 16) |
435
+ | `pin_budget` | `None`, bytes | `None` (40 % of physical RAM) | how much of the quantized data may be page-locked; the rest stays pageable |
436
+
437
+ ### `trainstream.OffloadAdamW(params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=1e-2, *, ...)`
438
+
439
+ AdamW with its moments and fp32 master weights in host RAM. `wrap()` creates or converts one for you; you can also build it yourself.
440
+
441
+ | Argument | Accepted values | Default | What it does |
442
+ |---|---|---|---|
443
+ | `params` | parameters or param-group dicts, on a CUDA device | — | what it trains (frozen parameters are skipped) |
444
+ | `lr`, `betas`, `eps`, `weight_decay` | as `torch.optim.AdamW` | `1e-3`, `(0.9, 0.999)`, `1e-8`, `1e-2` | same math as `torch.optim.AdamW` (decoupled weight decay) |
445
+ | `mode` | `"cpu"`, `"gpu"` | `"cpu"` | `"cpu"`: gradients stream to RAM and host threads run AdamW; `"gpu"`: a CUDA kernel updates in place, reading the moments from pinned RAM (zero-copy) |
446
+ | `max_grad_norm` | `None`, float | `None` | global-norm gradient clipping inside the optimizer |
447
+ | `compute_grad_norm` | `True`, `False` | `False` | compute the global gradient norm without clipping (read it from `last_grad_norm`) |
448
+ | `threads` | `None`, integer | `None` (all cores) | host AdamW worker threads |
449
+ | `chunk_elems` | integer | `1048576` | elements per work chunk when a tensor's update is split across threads |
450
+ | `accumulation_steps` | integer ≥ 1 | `1` | backward passes per `step()`; an extra backward before `step()` raises |
451
+ | `eager` | `None`, `True`, `False` | `None` (on when there is no clipping or norm) | start each tensor's update as soon as its last gradient lands, during backward |
452
+ | `pin_moments` | `None`, `True`, `False` | `None` (pin unless it would lock more than 40 % of RAM) | page-lock the moments in host RAM (`"gpu"` mode always pins) |
453
+ | `moment_dtype` | `"float32"`, `"fp32"`, `"bfloat16"`, `"bf16"`, `torch.float32`, `torch.bfloat16` | `"float32"` | bf16 halves moment memory and traffic, with stochastic rounding |
454
+ | `distributed` | `True`, `False` | `False` | data parallel: broadcast from rank 0, gradients averaged on the host (`"cpu"` mode only) |
455
+
456
+ Methods and attributes: `step(closure=None)`, `zero_grad()`, `attach(model, guard=True)` (install the streaming hooks; `wrap()` does this), `detach()`, `synchronize()` (block until every queued update has landed), `stats()`, `state_dict()` / `load_state_dict()` (compatible with `torch.optim.AdamW`, plus the fp32 `master` weights), `add_param_group()`, `last_grad_norm`.
457
+
458
+ ### `trainstream.integrations.huggingface`
459
+
460
+ | Function / class | Arguments | What it does |
461
+ |---|---|---|
462
+ | `TrainStreamTrainer(*args, trainstream=None, **kwargs)` | everything `transformers.Trainer` takes, plus `trainstream`: a dict of any `wrap()` option and `"chunked_loss"` (`True`/`False`, default `True`: the causal-LM loss is computed in chunks so the logits never exist) and `"loss_chunk_size"` (tokens per chunk, default `1024`) | a `Trainer` whose optimizer state, gradients and activations live in host RAM |
463
+ | `wrap_hf(model, optimizer=None, **options)` | `options`: any `wrap()` option | `wrap()` with `blocks` preset to the decoder layers |
464
+ | `load_model(name_or_path, ...)` | see the table above | streams a checkpoint, quantizing while it reads |
465
+ | `TrainStreamCallback(optimizer)` | `optimizer`: an `OffloadAdamW` | for a stock `Trainer` that you gave an `OffloadAdamW`: prints the TrainStream metrics at each log step |
466
+ | `causal_lm_logprobs(model, input_ids, labels=None, attention_mask=None, *, chunk_size=1024, average=False, **model_kwargs)` | `labels`: as for the model's loss (the input ids; `-100` marks tokens that don't count, e.g. the prompt); default `input_ids`. `chunk_size`: tokens per chunk. `average`: mean per counted token instead of the sum. `model_kwargs`: passed to the model | the log-probability of each sequence, shape `[batch]`, differentiable; computed from the final hidden states in chunks (models with soft-capped or scaled logits use their own logits) |
467
+ | `reference_logprobs(model, batches, *, chunk_size=1024, average=False)` | `batches`: iterable of dicts with `"input_ids"` and optionally `"labels"` and `"attention_mask"` | DPO's reference log-probs without a second model: a PEFT model runs with its adapters off, in eval mode and without gradients; one float32 CPU tensor with every sequence's value, in order |
468
+ | `TrainStreamCache(model, max_tokens, *, resident_layers="auto", page_tokens=256, vram_fraction=0.5)` | `model`: a decoder with full attention in every layer (sliding-window models are refused). Other arguments as `PagedKVCache` below | a `transformers` `Cache` for `model.generate(past_key_values=...)` whose keys and values are partly in host RAM; greedy or sampling (no beam search); `.reset()` before the next prompts; `.kv` is the `PagedKVCache`. Needs `transformers` ≥ 4.56 |
469
+
470
+ How `TrainStreamTrainer` uses `TrainingArguments`:
471
+ - `optim` must be an Adam/AdamW variant.
472
+ - `learning_rate`, `adam_beta1`, `adam_beta2`, `adam_epsilon`, `weight_decay` and the decay / no-decay split carry over.
473
+ - `max_grad_norm` is applied inside the optimizer.
474
+ - `gradient_accumulation_steps` becomes `accumulation_steps`.
475
+ - Use `bf16=True` or fp32; `fp16=True` is rejected.
476
+ - It is single-GPU per process.
477
+
478
+ Its log lines add these metrics:
479
+ - `ts_vram_alloc_gb`, `ts_vram_peak_gb` (since the previous log)
480
+ - `ts_pinned_gb`, `ts_host_state_gb`
481
+ - `ts_d2h_gb` / `ts_h2d_gb` (bytes over PCIe since the previous log)
482
+
483
+ ### Losses
484
+
485
+ | Function | Arguments |
486
+ |---|---|
487
+ | `trainstream.chunked_cross_entropy(hidden, weight, targets, bias=None, *, chunk_size=1024, ignore_index=-100, reduction="mean", num_items=None)` | cross-entropy of `hidden @ weight.T (+ bias)` without building the logits. `hidden` `[..., D]` (after the last norm), `weight` `[V, D]`, `bias` `[V]` or `None`, `targets` class indices. `chunk_size`: tokens per chunk. `ignore_index`: positions that don't count. `reduction`: `"mean"` or `"sum"`. `num_items`: divide by this instead of the counted positions (as Hugging Face does with gradient accumulation) |
488
+ | `trainstream.causal_lm_loss(hidden, weight, labels, bias=None, **kwargs)` | the next-token loss as Hugging Face computes it (labels shifted by one); `kwargs` as above |
489
+ | `trainstream.sequence_logprobs(hidden, weight, labels, bias=None, *, chunk_size=1024, ignore_index=-100, shift=True, average=False)` | the log-probability of each sequence's labels, `[B]` float32, without building the logits; differentiable. `hidden` `[B, T, D]`, `labels` `[B, T]`. `shift=True`: labels are the input ids (position t predicts label t + 1), as Hugging Face passes them; `False`: position t predicts label t. `average`: mean per counted token instead of the sum |
490
+ | `trainstream.dpo_loss(policy_chosen, policy_rejected, reference_chosen, reference_rejected, *, beta=0.1, label_smoothing=0.0)` | Direct Preference Optimization from per-sequence log-probs (`[B]` each). Returns `(loss, chosen_rewards, rejected_rewards)`: the mean loss and the implicit rewards `beta * (policy - reference)` (detached). `beta`: strength of the pull toward the reference. `label_smoothing` > 0: the conservative variant for noisy preference labels |
491
+
492
+ ### `trainstream.PagedKVCache(num_layers, max_tokens, *, resident_layers="auto", page_tokens=256, vram_fraction=0.5)`
493
+
494
+ A KV cache for autoregressive generation whose offloaded layers keep their keys and values in pinned host pages. Inference only. The Hugging Face adapter is `TrainStreamCache` above.
495
+
496
+ | Argument | Accepted values | Default | What it does |
497
+ |---|---|---|---|
498
+ | `num_layers` | integer ≥ 1 | — | attention layers of the model |
499
+ | `max_tokens` | integer ≥ 1 | — | the longest sequence expected (prompt + new tokens); sizes the slots and the `"auto"` budget. Longer sequences still work, with a reallocation |
500
+ | `resident_layers` | `"auto"`, integer ≥ 0, list of layer indices | `"auto"` | layers whose keys and values stay in VRAM; `"auto"`: as many as fit in `vram_fraction` of the free VRAM at the first update; the offloaded ones are spread through the stack |
501
+ | `page_tokens` | integer ≥ 1 | `256` | tokens per host page (one D2H copy when a page fills) |
502
+ | `vram_fraction` | float in (0, 1] | `0.5` | share of free (plus cached but unused) VRAM the `"auto"` plan may fill |
503
+
504
+ Methods and attributes:
505
+ - `update(layer, k, v) → (keys, values)`: append `[batch, kv_heads, new_tokens, head_dim]` and get the layer's full keys and values, as strided views.
506
+ - `seq_length(layer=0)`: tokens cached in that layer.
507
+ - `reset()`: forget every token.
508
+ - `memory()`: `{"device_bytes", "host_bytes"}`.
509
+ - `.resident` and `.offloaded`: layer lists.
510
+ - `.stats`: `h2d_bytes`, `d2h_bytes`, `pages`, `prefetched` (slots filled ahead of time), `gathered` (filled on demand).
511
+
512
+ ### Inspection and utilities
513
+
514
+ | Function | Arguments | Returns |
515
+ |---|---|---|
516
+ | `trainstream.memory_report(model=None)` | a wrapped model, or `None` | for a wrapped model, a dict with these keys: `"events"` (the decision log), `"gpu"` (`allocated`, `reserved`, `peak_allocated`), `"pinned"`, `"pinned_by_tag"`, `"transfers"`, `"optimizer"`, `"resident"`, and when used, `"attention"` (`mode`, `backend`, `timings_ms`), `"activations"` (`policy`, `offloaded_blocks`, `recompute_blocks`, `decision`, ...), `"params"` (`paged_blocks`, `quantize`, `quantized_blocks`, `outliers`, `load_bytes` from host RAM, `bounce_bytes` staged from pageable RAM, `peer_bytes` copied from other GPUs, `peer_blocks`, `peer_owned_bytes`, ...) and `"plan"` (`target_vram`, `settled`, `history`, `budget`). Without a model: `"gpu"`, `"pinned"`, `"pinned_by_tag"` |
517
+ | `trainstream.unwrap(model)` | a wrapped model | loads every weight back into VRAM, removes the hooks, returns the model |
518
+ | `trainstream.profile_hardware(device=None, quick=True, force=False, threads=None)` | `device`: GPU to measure (default the current one). `quick`: shorter benchmarks. `force`: re-measure instead of using the cache. `threads`: host threads for the AdamW benchmark | a `HardwareProfile` (PCIe bandwidth, matmul and host AdamW throughput; `.summary()` prints it) |
519
+ | `trainstream.pinned_stats()` | — | page-locked allocator counters, plus live bytes per tag in `"by_tag"`. Arena keys: `arena_bytes` (reserved), `arena_used`, `arena_slabs`, `arena_cap`, `arena_fallbacks` (blocks that had to be page-locked on their own), `arena_largest_free`; `host_alloc_calls` counts every page-locking call |
520
+ | `trainstream.reserve_pinned(bytes_now, cap=0, slab=0)` | `bytes_now`: page-lock this much for the arena now. `cap`: let it grow on demand up to this (at least `bytes_now`). `slab`: slab size in bytes (`0` keeps the current one) | the bytes reserved so far; `wrap(host_arena=...)` calls it for you |
521
+ | `trainstream.transfer_stats(device=None)` | `device`: GPU (default the current one) | cumulative bytes and copies on TrainStream's D2H and H2D streams |
522
+ | `trainstream.find_blocks(model)` | a model | the repeated layer stack `wrap()` would use (the largest `ModuleList` / `Sequential` of ≥ 2 same-type children), or `[]` |
523
+
524
+ The session behind a wrapped model is `model._trainstream_session` (`trainstream.TrainStreamSession`):
525
+ - `.adapting`: `True` while it is still tuning itself. Meanwhile it may reset PyTorch's peak-memory counter (`torch.cuda.reset_peak_memory_stats`) to measure single steps; once it is `False`, the counter is left alone
526
+ - `.events`: the decision log
527
+ - `.config`: the options in effect
528
+ - `.steps`: optimizer steps so far
529
+ - `.peer`: the peer-GPU pools once set up (`.pooled_blocks`, `.bytes_owned`), else `None`
530
+
531
+ ### `trainstream.ActivationOffloader(model, blocks, offload="auto", *, ...)`: activation paging on its own
532
+
533
+ `wrap()` creates this for you. Use it directly to page the activations of any model with a layer stack, without the rest of TrainStream. Call `.remove()` to take it off.
534
+
535
+ | Argument | Accepted values | Default | What it does |
536
+ |---|---|---|---|
537
+ | `model`, `blocks` | the model and its layer stack | — | whose saved activations are paged |
538
+ | `offload` | `"auto"`, `True`, `False`, fraction 0.0–1.0, set / list of block indices | `"auto"` | which blocks' activations go to host RAM; `"auto"` with `vram_target`: a probe step measures them, they stay in VRAM if they fit, otherwise host RAM takes as many as it holds and the rest are recomputed; `"auto"` without: calibrates, then halves until the slowdown is within `max_slowdown` (never more blocks than host RAM holds) |
539
+ | `min_bytes` | bytes | `1048576` | smaller tensors stay on the GPU |
540
+ | `prefetch_depth` | integer ≥ 1 | `1` | blocks fetched back ahead of backward |
541
+ | `calibrate_steps` | integer ≥ 2 | `3` | steps measured with nothing paged before tuning (`"auto"` without `vram_target`) |
542
+ | `profile` | `None`, `HardwareProfile` | `None` | link model used to size the first guess |
543
+ | `max_slowdown` | float | `0.15` | how much slower `"auto"` may make a step (without `vram_target`) |
544
+ | `tune_window` | integer ≥ 1 | `3` | steps measured per setting while tuning (the first after a change is discarded) |
545
+ | `max_inflight_bytes` | bytes | `1073741824` | VRAM held by copies still crossing PCIe |
546
+ | `recompute` | `"auto"`, `True`, `False`, fraction, list of block indices | `"auto"` | blocks to recompute in backward instead of keeping or paging |
547
+ | `reserved_link_bytes`, `memory_pressure`, `vram_target` | set by `wrap()` | `(0, 0)`, `False`, `None` | PCIe already used by the optimizer per step; whether weights page; the VRAM target for the probe step |
548
+
549
+ Attributes: `.policy` (per block: `"keep"`, `"offload"` or `"recompute"`), `.decision` (what the tuner measured and chose; `kept_blocks` lists blocks moved back to VRAM later), `.stats`, `.events`, `.block_bytes` (measured saved activations of one block), `.adjustable` (`True` when `"auto"` chose the policy, so `keep_resident` may change it), `.weights_paged` (set by `wrap()`: a callable saying whether weights stream at the moment; it words the probe's event).
550
+
551
+ `.keep_resident(k)`: keep the activations of up to `k` blocks that `"auto"` pages or recomputes in VRAM instead, the latest first (backward reaches them first). Does nothing for policies you set explicitly. Returns the blocks moved. `wrap()` calls it once the policy is final, with as many blocks as the room left under its VRAM target holds.
552
+
553
+ `ParamPager`, `GradientOffloader`, `ParamReadyHooks` and `HardwareProfile` are also exported, and `trainstream.peer` holds the peer-GPU setup. `wrap()` and `OffloadAdamW` build and use them internally; they aren't meant to be constructed by hand.
554
+
555
+ ### Environment variables
556
+
557
+ | Variable | Effect |
558
+ |---|---|
559
+ | `TRAINSTREAM_CACHE_DIR` | where builds are cached (default `%LOCALAPPDATA%\trainstream\build`; Linux `$XDG_CACHE_HOME/trainstream`, else `~/.cache/trainstream`) |
560
+ | `TRAINSTREAM_VERBOSE_BUILD=1` | show compiler output |
561
+ | `TORCH_CUDA_ARCH_LIST` | GPU architectures to compile for (default: the GPUs present) |
562
+ | `CUDA_HOME` / `CUDA_PATH` | CUDA toolkit to use, if it isn't found automatically |
563
+ | `LOCAL_WORLD_SIZE` / `SLURM_NTASKS_PER_NODE` | processes sharing the machine; the planner splits RAM between them, and data parallel shares host memory when all ranks are local |
564
+ | `TRAINSTREAM_SHM=0` | data parallel without shared host memory (per-rank copies, gloo reduction); also turns off peer-GPU pooling, which uses the same local group |
565
+ | `TRAINSTREAM_HOST_ARENA_SLAB` | size of one pinned-arena slab, in bytes or with a `K` / `M` / `G` suffix (default `1G`) |
566
+
567
+ ## How it works
568
+
569
+ 1. **Measure.** At `wrap()`: free VRAM, available RAM, the page-lock budget, PCIe bandwidth, and the size of every layer.
570
+ 2. **Plan.** Which layers stay in VRAM and which stream from RAM, the optimizer moment precision, whether to quantize the frozen weights, and how many activations to page. Runs that can't fit are refused with an explanation.
571
+ 3. **Stream.** Gradients go to pinned host memory as autograd produces them; AdamW runs on host threads while backward continues; weights and activations come back on dedicated CUDA streams ordered by events, overlapping compute.
572
+ 4. **Adapt.** The first steps' measured peaks move layers in or out of VRAM, then the plan settles; a watchdog reacts if a later step grows. Activations stay in VRAM if they fit; otherwise measured trials choose which blocks are paged or recomputed, and the room left afterwards keeps the latest ones in VRAM. If the model fits anyway, it switches to stock fused AdamW.
573
+
574
+ ## Examples
575
+
576
+ | Script | What it does | Main flags |
577
+ |---|---|---|
578
+ | `examples/lora_large_model.py` | LoRA on bases larger than the GPU, bf16 or quantized | `--size 1.5b\|7b\|14b\|32b`, `--model HUB_ID`, `--quantize auto\|none\|int8\|nf4\|fp8\|nvfp4`, `--prefer exact\|speed`, `--recompute auto\|on\|off`, `--attention auto\|cudnn\|efficient\|flash\|math\|default`, `--outliers FRACTION`, `--stock`, `--steps`, `--batch`, `--seq`, `--rank` |
579
+ | `examples/dpo_lora.py` | DPO on a LoRA model: the base with adapters off is the reference, log-probs computed in chunks | `--model HUB_ID` (default: a random Qwen2.5-0.5B shape), `--quantize`, `--steps`, `--batch`, `--prompt`, `--answer`, `--beta`, `--rank` |
580
+ | `examples/hf_finetune.py` | fine-tune a Hugging Face model, optionally beside the stock Trainer | `--model`, `--lora`, `--compare`, `--activation-offload` |
581
+ | `examples/train_bigger_than_vram.py` | 1.8B model (7.3 GB of fp32 weights) inside a 4 GB VRAM cap | `--vram-gb`, `--moments fp32\|bf16`, `--layers`, `--d-model` |
582
+ | `examples/verify_ram_offload.py` | proves where training memory lives, with a PASS/FAIL verdict | `--gpt`, `--hf`, `--vram-cap-gb`, `--engine both\|baseline\|trainstream` |
583
+ | `benchmarks/compare.py` | head-to-head with stock PyTorch, bitsandbytes QLoRA, Unsloth, DeepSpeed | `--scenario full1.5b\|lora14b\|lora14b_2k`, `--engines ...`, `--steps` (2 warm-up + measured; default 10), `--timeout`, `--out` |
584
+ | `benchmarks/quality.py` | perplexity of each quantization format, before and after a short LoRA fine-tune | `--model`, `--formats none int8 nf4 fp8 nvfp4 nvfp4-w nvfp4-o` (`nvfp4-w`: 4-bit weights only, expanded before each matmul; `nvfp4-o`: tensor cores with `outliers=0.01`), `--windows`, `--seq`, `--train-steps`, `--batch`, `--lr`, `--out`; `--format F` runs one format in this process, `--live` with it streams progress as `LIVE {json}` lines and the result as `RESULT {json}` |
585
+
586
+ ## Limitations
587
+
588
+ - Host RAM is the ceiling: 8–12 bytes per trained parameter and 2–4 per frozen one (1.06 with int8, 0.56 with NF4), plus activations. Measured on 64 GB: a 3.6B full fine-tune, and LoRA on a 32.8B NF4 base.
589
+ - Quantization covers frozen block weights only (not embeddings, the output layer or trained weights) and isn't bit-exact. FP8 / NVFP4 tensor-core matmuls need a Blackwell GPU and quantize activations too; `outliers=` recovers part of the activation error, never the weights' own 4-bit error. `load_model` reads `.safetensors` causal-LM checkpoints only.
590
+ - `PagedKVCache` / `TrainStreamCache`: inference only, full-attention decoders, no beam search. With paged weights, every decoded token also streams the paged blocks over PCIe; a larger generation batch (which the paged cache makes room for) amortizes that.
591
+ - Weight paging covers the repeated block stack and needs `optimizer_mode="cpu"`; each paged weight crosses PCIe twice per step.
592
+ - Data parallel only (no tensor or pipeline parallelism). Ranks on one machine share host memory; tested with 2 processes on 1 GPU, not on a multi-GPU machine or across machines. Peer-GPU pooling covers frozen weights only and needs CUDA IPC and GPUs with peer access (GeForce pairs usually have none); tested with two processes on one GPU (Windows 11, WSL2), copies between two real GPUs are untested.
593
+ - `TrainStreamTrainer` is single-process. fp16 with a GradScaler and AMSGrad aren't supported; `torch.compile` hasn't been tested.
594
+ - Host AdamW speed is bounded by system RAM bandwidth.
595
+
596
+ ## License
597
+
598
+ Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Copyright 2026 John Anish G.