experts4bit-qlora 0.1.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.
- experts4bit_qlora-0.1.0/LICENSE +29 -0
- experts4bit_qlora-0.1.0/PKG-INFO +147 -0
- experts4bit_qlora-0.1.0/README.md +119 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/__init__.py +18 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/_vendor/__init__.py +1 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/_vendor/experts.py +355 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/loader.py +139 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/lora.py +161 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/train.py +221 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora/util.py +6 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora.egg-info/PKG-INFO +147 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora.egg-info/SOURCES.txt +16 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora.egg-info/dependency_links.txt +1 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora.egg-info/requires.txt +15 -0
- experts4bit_qlora-0.1.0/experts4bit_qlora.egg-info/top_level.txt +1 -0
- experts4bit_qlora-0.1.0/pyproject.toml +40 -0
- experts4bit_qlora-0.1.0/setup.cfg +4 -0
- experts4bit_qlora-0.1.0/tests/test_loader_architectures.py +112 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Anderson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
experts4bit_qlora/_vendor/experts.py is vendored from bitsandbytes
|
|
26
|
+
(https://github.com/bitsandbytes-foundation/bitsandbytes), which is likewise
|
|
27
|
+
distributed under the MIT License. It is included here only while the upstream
|
|
28
|
+
Experts4bit proposal (bitsandbytes#1965) is unmerged, and will be removed once
|
|
29
|
+
the class ships in a bitsandbytes release.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: experts4bit-qlora
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: QLoRA fine-tuning of fused 4-bit Mixture-of-Experts on a single small GPU, on stock bitsandbytes.
|
|
5
|
+
Author-email: Jordan Anderson <paul.jordan.anderson@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pjordanandrsn/experts4bit-qlora
|
|
8
|
+
Project-URL: Upstream (bitsandbytes#1965), https://github.com/bitsandbytes-foundation/bitsandbytes/pull/1965
|
|
9
|
+
Project-URL: Tracking (bitsandbytes#1849), https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849
|
|
10
|
+
Keywords: bitsandbytes,qlora,moe,mixture-of-experts,quantization,nf4,lora,olmoe
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: torch>=2.2
|
|
15
|
+
Requires-Dist: bitsandbytes>=0.43
|
|
16
|
+
Provides-Extra: train
|
|
17
|
+
Requires-Dist: transformers>=5.0; extra == "train"
|
|
18
|
+
Requires-Dist: datasets>=2.14; extra == "train"
|
|
19
|
+
Requires-Dist: accelerate>=0.30; extra == "train"
|
|
20
|
+
Requires-Dist: safetensors>=0.4; extra == "train"
|
|
21
|
+
Requires-Dist: huggingface_hub>=0.23; extra == "train"
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
24
|
+
Requires-Dist: transformers>=5.0; extra == "test"
|
|
25
|
+
Requires-Dist: accelerate>=0.30; extra == "test"
|
|
26
|
+
Requires-Dist: safetensors>=0.4; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# experts4bit-qlora
|
|
30
|
+
|
|
31
|
+
[](https://github.com/pjordanandrsn/experts4bit-qlora/actions/workflows/ci.yml)
|
|
32
|
+
|
|
33
|
+
QLoRA fine-tuning of **fused Mixture-of-Experts** weights on a single small GPU — the part that
|
|
34
|
+
doesn't fit anywhere else yet.
|
|
35
|
+
|
|
36
|
+
## The problem
|
|
37
|
+
|
|
38
|
+
transformers v5 stores MoE experts as one fused 3-D `nn.Parameter` per layer
|
|
39
|
+
(`OlmoeExperts`, `Qwen3MoeExperts`, …). bitsandbytes' 4-bit walker only replaces `nn.Linear`
|
|
40
|
+
modules, so it **silently skips the experts** — which are the overwhelming majority of a MoE's
|
|
41
|
+
weights. `load_in_4bit` "shrinks" the model but the experts stay in full precision
|
|
42
|
+
([bitsandbytes#1849](https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849)).
|
|
43
|
+
|
|
44
|
+
`Experts4bit` is the primitive that 4-bit-quantizes exactly that fused stack. This package pairs
|
|
45
|
+
it with a **streaming loader** and **per-expert LoRA**, so you can actually *fine-tune* a real
|
|
46
|
+
sparse-MoE on reasonable hardware.
|
|
47
|
+
|
|
48
|
+
## What it buys you (measured on an RTX A2000 12 GB)
|
|
49
|
+
|
|
50
|
+
- **It fits at all.** Full bf16 OLMoE-1B-7B is ~13.9 GB — it **OOMs** on a 12 GB card. In 4-bit
|
|
51
|
+
it loads at **4.69 GB** and trains in <8 GB. The streaming loader never materializes the bf16
|
|
52
|
+
model in CPU *or* GPU RAM (verified under a 3 GB container RAM cap).
|
|
53
|
+
- **It trains.** QLoRA on the frozen NF4 experts improves a held-out Alpaca eval from
|
|
54
|
+
**1.4813 → 1.0290** (see [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md)).
|
|
55
|
+
- **Honest caveat — this is a memory technology, not an energy one.** On a GPU that *already*
|
|
56
|
+
fits the model, 4-bit is a **1.2–2.3× energy penalty** (NF4 is storage-only; the GEMM runs in
|
|
57
|
+
bf16 either way, plus dequant). The energy win only shows up when memory is the binding
|
|
58
|
+
constraint — then it's the difference between running and not, and up to **4.4× lower
|
|
59
|
+
energy/token** from the batch that freed memory unlocks. Numbers and method in the docs.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# primitive + adapters + benchmarks (torch + bitsandbytes):
|
|
65
|
+
pip install "git+https://github.com/pjordanandrsn/experts4bit-qlora"
|
|
66
|
+
# + the streaming MoE trainer (transformers>=5.0, datasets, ...):
|
|
67
|
+
pip install "experts4bit-qlora[train] @ git+https://github.com/pjordanandrsn/experts4bit-qlora"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Runs on a **stock** `pip install bitsandbytes` today — see "Relationship to bitsandbytes" below.
|
|
71
|
+
|
|
72
|
+
## Quickstart
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import torch
|
|
76
|
+
from experts4bit_qlora import Experts4bit, ExpertsLoRA
|
|
77
|
+
|
|
78
|
+
# Freeze a fused expert stack in 4-bit, attach trainable per-expert LoRA.
|
|
79
|
+
gate_up = torch.randn(8, 2 * 256, 128) # [num_experts, 2*intermediate, hidden]
|
|
80
|
+
down = torch.randn(8, 128, 256) # [num_experts, hidden, intermediate]
|
|
81
|
+
base = Experts4bit.from_float(gate_up, down, quant_type="nf4", compute_dtype=torch.float32)
|
|
82
|
+
model = ExpertsLoRA(base, r=8, alpha=16) # only the LoRA adapters train
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
End-to-end OLMoE QLoRA fine-tune (needs a CUDA GPU + `[train]` extras):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
STEPS=150 R=8 TRAIN_EXPERTS=1 TRAIN_ATTENTION=0 OUT=./out \
|
|
89
|
+
python -m experts4bit_qlora.train
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Scope
|
|
93
|
+
|
|
94
|
+
The `Experts4bit` primitive and `ExpertsLoRA` adapters are **model-agnostic**. The **streaming loader /
|
|
95
|
+
trainer** (`python -m experts4bit_qlora.train`) supports fused-MoE architectures that store experts
|
|
96
|
+
per-expert on disk under `model.layers.{i}.mlp.experts.{e}.{gate,up,down}_proj.weight` with a SwiGLU gate:
|
|
97
|
+
|
|
98
|
+
- **OLMoE** (OLMoE-1B-7B) — convergence-tested end-to-end; fits a 12 GB card at ~4.7 GB.
|
|
99
|
+
- **Qwen3-MoE / Qwen3.5-MoE** — same checkpoint + module layout (verified byte-for-byte identical to
|
|
100
|
+
OLMoE's on-disk format); structurally tested in `tests/test_loader_architectures.py`. The real weights
|
|
101
|
+
(30–35B) need a ≥24 GB card — or the CPU-offloading path (tracked separately) — to fit 12 GB.
|
|
102
|
+
|
|
103
|
+
Anything else **fails fast with a clear error**. **Gemma 4** is a genuinely different design (experts at
|
|
104
|
+
`layers.{i}.experts` beside a parallel dense MLP, with a custom router) and needs its own loader
|
|
105
|
+
adaptation — not yet supported. PRs welcome.
|
|
106
|
+
|
|
107
|
+
## Benchmarks
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Runs on stock bitsandbytes (uses the portable dequantize forward):
|
|
111
|
+
python bench/bench_energy_excluded.py # memory wall + tokens-per-joule vs batch
|
|
112
|
+
|
|
113
|
+
# Require bitsandbytes >= 0.50 — measure the upstream matmul_4bit optimization (#1965):
|
|
114
|
+
python bench/_upstream/bench_matmul4bit.py --mode both # equivalence + latency/memory
|
|
115
|
+
python bench/_upstream/bench_energy.py # joules/op: bf16 vs dequant vs matmul_4bit
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The LoRA-placement ablation (which of experts / attention / router to train) and full energy
|
|
119
|
+
analysis are written up in [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md). Short version: on Alpaca
|
|
120
|
+
the placements are largely **redundant**, attention-only is the efficiency pick, and training the
|
|
121
|
+
router **hurts**.
|
|
122
|
+
|
|
123
|
+
## Relationship to bitsandbytes
|
|
124
|
+
|
|
125
|
+
`Experts4bit` is a bitsandbytes primitive, proposed upstream in
|
|
126
|
+
[bitsandbytes#1965](https://github.com/bitsandbytes-foundation/bitsandbytes/pull/1965). Until it
|
|
127
|
+
ships in a release, this package **vendors** a copy (`experts4bit_qlora/_vendor/experts.py`) so it
|
|
128
|
+
runs on stock bitsandbytes today. The import shim prefers the upstream class when present:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
try:
|
|
132
|
+
from bitsandbytes.nn import Experts4bit # once bitsandbytes#1965 releases
|
|
133
|
+
except ImportError:
|
|
134
|
+
from ._vendor.experts import Experts4bit # vendored fallback (stock bnb)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The vendored forward also **auto-detects** whether `matmul_4bit` is correct on your installed
|
|
138
|
+
bitsandbytes — it only handles this weight layout correctly on **bnb ≥ 0.50**, so on older releases
|
|
139
|
+
the primitive uses the portable dequantize path, and the `matmul_4bit` memory optimization engages
|
|
140
|
+
automatically once you upgrade. Results are correct on any supported bnb either way.
|
|
141
|
+
|
|
142
|
+
When it lands upstream: bump the `bitsandbytes` floor and delete `_vendor/` — no API change.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT (see [LICENSE](LICENSE)). `experts4bit_qlora/_vendor/experts.py` is vendored from
|
|
147
|
+
bitsandbytes (also MIT) pending upstream merge.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# experts4bit-qlora
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pjordanandrsn/experts4bit-qlora/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
QLoRA fine-tuning of **fused Mixture-of-Experts** weights on a single small GPU — the part that
|
|
6
|
+
doesn't fit anywhere else yet.
|
|
7
|
+
|
|
8
|
+
## The problem
|
|
9
|
+
|
|
10
|
+
transformers v5 stores MoE experts as one fused 3-D `nn.Parameter` per layer
|
|
11
|
+
(`OlmoeExperts`, `Qwen3MoeExperts`, …). bitsandbytes' 4-bit walker only replaces `nn.Linear`
|
|
12
|
+
modules, so it **silently skips the experts** — which are the overwhelming majority of a MoE's
|
|
13
|
+
weights. `load_in_4bit` "shrinks" the model but the experts stay in full precision
|
|
14
|
+
([bitsandbytes#1849](https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849)).
|
|
15
|
+
|
|
16
|
+
`Experts4bit` is the primitive that 4-bit-quantizes exactly that fused stack. This package pairs
|
|
17
|
+
it with a **streaming loader** and **per-expert LoRA**, so you can actually *fine-tune* a real
|
|
18
|
+
sparse-MoE on reasonable hardware.
|
|
19
|
+
|
|
20
|
+
## What it buys you (measured on an RTX A2000 12 GB)
|
|
21
|
+
|
|
22
|
+
- **It fits at all.** Full bf16 OLMoE-1B-7B is ~13.9 GB — it **OOMs** on a 12 GB card. In 4-bit
|
|
23
|
+
it loads at **4.69 GB** and trains in <8 GB. The streaming loader never materializes the bf16
|
|
24
|
+
model in CPU *or* GPU RAM (verified under a 3 GB container RAM cap).
|
|
25
|
+
- **It trains.** QLoRA on the frozen NF4 experts improves a held-out Alpaca eval from
|
|
26
|
+
**1.4813 → 1.0290** (see [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md)).
|
|
27
|
+
- **Honest caveat — this is a memory technology, not an energy one.** On a GPU that *already*
|
|
28
|
+
fits the model, 4-bit is a **1.2–2.3× energy penalty** (NF4 is storage-only; the GEMM runs in
|
|
29
|
+
bf16 either way, plus dequant). The energy win only shows up when memory is the binding
|
|
30
|
+
constraint — then it's the difference between running and not, and up to **4.4× lower
|
|
31
|
+
energy/token** from the batch that freed memory unlocks. Numbers and method in the docs.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# primitive + adapters + benchmarks (torch + bitsandbytes):
|
|
37
|
+
pip install "git+https://github.com/pjordanandrsn/experts4bit-qlora"
|
|
38
|
+
# + the streaming MoE trainer (transformers>=5.0, datasets, ...):
|
|
39
|
+
pip install "experts4bit-qlora[train] @ git+https://github.com/pjordanandrsn/experts4bit-qlora"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Runs on a **stock** `pip install bitsandbytes` today — see "Relationship to bitsandbytes" below.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import torch
|
|
48
|
+
from experts4bit_qlora import Experts4bit, ExpertsLoRA
|
|
49
|
+
|
|
50
|
+
# Freeze a fused expert stack in 4-bit, attach trainable per-expert LoRA.
|
|
51
|
+
gate_up = torch.randn(8, 2 * 256, 128) # [num_experts, 2*intermediate, hidden]
|
|
52
|
+
down = torch.randn(8, 128, 256) # [num_experts, hidden, intermediate]
|
|
53
|
+
base = Experts4bit.from_float(gate_up, down, quant_type="nf4", compute_dtype=torch.float32)
|
|
54
|
+
model = ExpertsLoRA(base, r=8, alpha=16) # only the LoRA adapters train
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
End-to-end OLMoE QLoRA fine-tune (needs a CUDA GPU + `[train]` extras):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
STEPS=150 R=8 TRAIN_EXPERTS=1 TRAIN_ATTENTION=0 OUT=./out \
|
|
61
|
+
python -m experts4bit_qlora.train
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Scope
|
|
65
|
+
|
|
66
|
+
The `Experts4bit` primitive and `ExpertsLoRA` adapters are **model-agnostic**. The **streaming loader /
|
|
67
|
+
trainer** (`python -m experts4bit_qlora.train`) supports fused-MoE architectures that store experts
|
|
68
|
+
per-expert on disk under `model.layers.{i}.mlp.experts.{e}.{gate,up,down}_proj.weight` with a SwiGLU gate:
|
|
69
|
+
|
|
70
|
+
- **OLMoE** (OLMoE-1B-7B) — convergence-tested end-to-end; fits a 12 GB card at ~4.7 GB.
|
|
71
|
+
- **Qwen3-MoE / Qwen3.5-MoE** — same checkpoint + module layout (verified byte-for-byte identical to
|
|
72
|
+
OLMoE's on-disk format); structurally tested in `tests/test_loader_architectures.py`. The real weights
|
|
73
|
+
(30–35B) need a ≥24 GB card — or the CPU-offloading path (tracked separately) — to fit 12 GB.
|
|
74
|
+
|
|
75
|
+
Anything else **fails fast with a clear error**. **Gemma 4** is a genuinely different design (experts at
|
|
76
|
+
`layers.{i}.experts` beside a parallel dense MLP, with a custom router) and needs its own loader
|
|
77
|
+
adaptation — not yet supported. PRs welcome.
|
|
78
|
+
|
|
79
|
+
## Benchmarks
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Runs on stock bitsandbytes (uses the portable dequantize forward):
|
|
83
|
+
python bench/bench_energy_excluded.py # memory wall + tokens-per-joule vs batch
|
|
84
|
+
|
|
85
|
+
# Require bitsandbytes >= 0.50 — measure the upstream matmul_4bit optimization (#1965):
|
|
86
|
+
python bench/_upstream/bench_matmul4bit.py --mode both # equivalence + latency/memory
|
|
87
|
+
python bench/_upstream/bench_energy.py # joules/op: bf16 vs dequant vs matmul_4bit
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The LoRA-placement ablation (which of experts / attention / router to train) and full energy
|
|
91
|
+
analysis are written up in [`docs/METHODOLOGY.md`](docs/METHODOLOGY.md). Short version: on Alpaca
|
|
92
|
+
the placements are largely **redundant**, attention-only is the efficiency pick, and training the
|
|
93
|
+
router **hurts**.
|
|
94
|
+
|
|
95
|
+
## Relationship to bitsandbytes
|
|
96
|
+
|
|
97
|
+
`Experts4bit` is a bitsandbytes primitive, proposed upstream in
|
|
98
|
+
[bitsandbytes#1965](https://github.com/bitsandbytes-foundation/bitsandbytes/pull/1965). Until it
|
|
99
|
+
ships in a release, this package **vendors** a copy (`experts4bit_qlora/_vendor/experts.py`) so it
|
|
100
|
+
runs on stock bitsandbytes today. The import shim prefers the upstream class when present:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
try:
|
|
104
|
+
from bitsandbytes.nn import Experts4bit # once bitsandbytes#1965 releases
|
|
105
|
+
except ImportError:
|
|
106
|
+
from ._vendor.experts import Experts4bit # vendored fallback (stock bnb)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The vendored forward also **auto-detects** whether `matmul_4bit` is correct on your installed
|
|
110
|
+
bitsandbytes — it only handles this weight layout correctly on **bnb ≥ 0.50**, so on older releases
|
|
111
|
+
the primitive uses the portable dequantize path, and the `matmul_4bit` memory optimization engages
|
|
112
|
+
automatically once you upgrade. Results are correct on any supported bnb either way.
|
|
113
|
+
|
|
114
|
+
When it lands upstream: bump the `bitsandbytes` floor and delete `_vendor/` — no API change.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT (see [LICENSE](LICENSE)). `experts4bit_qlora/_vendor/experts.py` is vendored from
|
|
119
|
+
bitsandbytes (also MIT) pending upstream merge.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""experts4bit-qlora — QLoRA fine-tuning of fused 4-bit Mixture-of-Experts on a single small GPU.
|
|
2
|
+
|
|
3
|
+
``Experts4bit`` resolves to the upstream bitsandbytes class once it ships in a release
|
|
4
|
+
(bitsandbytes#1965); until then it falls back to a vendored copy, so this package works on a
|
|
5
|
+
stock ``pip install bitsandbytes`` today. When upstream releases it, bump the bitsandbytes floor
|
|
6
|
+
and delete ``experts4bit_qlora/_vendor/`` — no API change for callers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# Upstream (bitsandbytes#1965) once released; else the vendored copy on stock bitsandbytes.
|
|
11
|
+
from bitsandbytes.nn import Experts4bit
|
|
12
|
+
except ImportError:
|
|
13
|
+
from ._vendor.experts import Experts4bit
|
|
14
|
+
|
|
15
|
+
from .lora import ExpertsLoRA, LoRALinear, add_attention_lora
|
|
16
|
+
|
|
17
|
+
__all__ = ["Experts4bit", "ExpertsLoRA", "LoRALinear", "add_attention_lora"]
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vendored bitsandbytes primitives (temporary — see experts.py header for provenance)."""
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Vendored from bitsandbytes (github.com/bitsandbytes-foundation/bitsandbytes), pending the
|
|
2
|
+
# upstream merge of Experts4bit (bitsandbytes#1965). While unmerged, this copy lets
|
|
3
|
+
# experts4bit_qlora run on a stock `pip install bitsandbytes`; it depends only on released
|
|
4
|
+
# bitsandbytes public APIs (quantize_4bit, dequantize_4bit, QuantState, get_4bit_type,
|
|
5
|
+
# matmul_4bit). Once Experts4bit ships in a bitsandbytes release, experts4bit_qlora imports it
|
|
6
|
+
# from bitsandbytes.nn directly (see the shim in experts4bit_qlora/__init__.py) and this file
|
|
7
|
+
# is deleted.
|
|
8
|
+
#
|
|
9
|
+
# Original author: Jordan Anderson <paul.jordan.anderson@gmail.com>. Licensed under MIT.
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn as nn
|
|
17
|
+
import torch.nn.functional as F_nn
|
|
18
|
+
|
|
19
|
+
import bitsandbytes as bnb
|
|
20
|
+
import bitsandbytes.functional as F
|
|
21
|
+
from bitsandbytes.functional import QuantState
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@functools.lru_cache(maxsize=1)
|
|
25
|
+
def _matmul_4bit_matches_dequant() -> bool:
|
|
26
|
+
"""Whether ``bnb.matmul_4bit`` agrees with dequantize-then-linear for the ``[packed, 1]`` layout.
|
|
27
|
+
|
|
28
|
+
bitsandbytes < 0.50 mishandles the transpose back-compat shim (keyed on ``A.shape[0] == 1``) for a
|
|
29
|
+
``[packed, 1]``-shaped 4-bit weight, so ``matmul_4bit`` returns wrong results there. On those
|
|
30
|
+
releases :meth:`Experts4bit.forward` falls back to the (bit-exact) dequantize path. Probed once,
|
|
31
|
+
lazily, on the first forward — GPU-only, so returns ``False`` without CUDA.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
if not torch.cuda.is_available():
|
|
35
|
+
return False
|
|
36
|
+
dev = "cuda"
|
|
37
|
+
w = torch.randn(64, 64, dtype=torch.bfloat16, device=dev)
|
|
38
|
+
x = torch.randn(2, 64, dtype=torch.bfloat16, device=dev)
|
|
39
|
+
q, st = F.quantize_4bit(w.contiguous(), blocksize=64, compress_statistics=False, quant_type="nf4")
|
|
40
|
+
qs = QuantState(
|
|
41
|
+
absmax=st.absmax.reshape(-1),
|
|
42
|
+
shape=torch.Size((64, 64)),
|
|
43
|
+
code=F.get_4bit_type("nf4", device=dev),
|
|
44
|
+
blocksize=64,
|
|
45
|
+
quant_type="nf4",
|
|
46
|
+
dtype=torch.bfloat16,
|
|
47
|
+
)
|
|
48
|
+
ref = F_nn.linear(x, F.dequantize_4bit(q.reshape(-1, 1), quant_state=qs))
|
|
49
|
+
got = bnb.matmul_4bit(x, q.reshape(-1, 1), quant_state=qs)
|
|
50
|
+
# Accept bf16 gemv rounding vs dequantize-then-linear (a few %); reject the catastrophic
|
|
51
|
+
# transpose-shim bug on bitsandbytes<0.50 (relative error >> 1).
|
|
52
|
+
return bool((got - ref).abs().max() / ref.abs().max().clamp_min(1e-6) < 0.05)
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Experts4bit(nn.Module):
|
|
58
|
+
"""4-bit quantized storage for fused Mixture-of-Experts expert weights.
|
|
59
|
+
|
|
60
|
+
A growing number of models in the Hugging Face ecosystem store their MoE expert
|
|
61
|
+
weights as a single 3D ``nn.Parameter`` of shape ``[num_experts, out_features,
|
|
62
|
+
in_features]`` (e.g. ``OlmoeExperts``, ``Qwen3MoeExperts``) rather than as a
|
|
63
|
+
collection of ``nn.Linear`` layers. The default 4-bit quantization walker only
|
|
64
|
+
replaces ``nn.Linear`` modules, so these fused experts are silently skipped and
|
|
65
|
+
stay in full precision — the dominant contribution to the model's memory footprint
|
|
66
|
+
(see https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849).
|
|
67
|
+
|
|
68
|
+
``Experts4bit`` holds the two expert projections (``gate_up_proj`` and ``down_proj``)
|
|
69
|
+
in 4-bit NF4/FP4 precision. Unlike :class:`Linear4bit`, the packed weights are kept
|
|
70
|
+
as plain ``nn.Parameter`` buffers and the per-expert quantization statistics
|
|
71
|
+
(``absmax``) live on the module as ordinary buffers. This avoids bending
|
|
72
|
+
:class:`Params4bit`'s tensor-subclass and device-movement machinery around a 3D
|
|
73
|
+
stack, and it means the module serializes through the standard ``state_dict``
|
|
74
|
+
mechanism with no custom save/load hooks.
|
|
75
|
+
|
|
76
|
+
The forward pass dequantizes a single expert at a time (a per-expert loop), mirroring
|
|
77
|
+
the reference fused-experts forward. Grouped-GEMM is intentionally left for future
|
|
78
|
+
work.
|
|
79
|
+
|
|
80
|
+
<Tip warning={true}>This feature is experimental and may change in future releases.</Tip>
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
num_experts (`int`): Number of experts in the layer.
|
|
84
|
+
hidden_dim (`int`): Model hidden size (the ``in_features`` of ``gate_up_proj`` and
|
|
85
|
+
the ``out_features`` of ``down_proj``).
|
|
86
|
+
intermediate_dim (`int`): Expert intermediate size (the ``in_features`` of
|
|
87
|
+
``down_proj``).
|
|
88
|
+
has_gate (`bool`, *optional*, defaults to `True`): Whether ``gate_up_proj`` packs a
|
|
89
|
+
gate and an up projection (SwiGLU-style). When `False`, the projection is a
|
|
90
|
+
plain up projection of size ``intermediate_dim``.
|
|
91
|
+
activation (`Callable`, *optional*): The activation applied to the gate. Defaults
|
|
92
|
+
to ``torch.nn.functional.silu`` (SwiGLU), matching OLMoE / Qwen3-MoE.
|
|
93
|
+
compute_dtype (`torch.dtype`, *optional*): The dtype expert weights are
|
|
94
|
+
dequantized to for the matmul. When `None`, the input's dtype is used.
|
|
95
|
+
quant_type (`str`, *optional*, defaults to `"nf4"`): The 4-bit data type, ``nf4``
|
|
96
|
+
or ``fp4``.
|
|
97
|
+
blocksize (`int`, *optional*, defaults to `64`): The quantization block size.
|
|
98
|
+
device (*optional*): The device for the (empty) packed buffers.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If ``quant_type`` is invalid, or if ``hidden_dim`` / ``intermediate_dim``
|
|
102
|
+
is not divisible by ``blocksize`` (required so per-expert quantization blocks
|
|
103
|
+
never straddle an expert boundary).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
num_experts: int,
|
|
109
|
+
hidden_dim: int,
|
|
110
|
+
intermediate_dim: int,
|
|
111
|
+
has_gate: bool = True,
|
|
112
|
+
activation: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
|
|
113
|
+
compute_dtype: Optional[torch.dtype] = None,
|
|
114
|
+
quant_type: str = "nf4",
|
|
115
|
+
blocksize: int = 64,
|
|
116
|
+
device=None,
|
|
117
|
+
):
|
|
118
|
+
super().__init__()
|
|
119
|
+
|
|
120
|
+
if quant_type not in ("nf4", "fp4"):
|
|
121
|
+
raise ValueError(f"quant_type must be 'nf4' or 'fp4', got {quant_type!r}")
|
|
122
|
+
|
|
123
|
+
# Each expert is quantized independently, so an expert occupies a contiguous
|
|
124
|
+
# `out_features * in_features` run of elements. Requiring the in_features dim to
|
|
125
|
+
# be a multiple of the blocksize guarantees `out_features * in_features` is too,
|
|
126
|
+
# so blocks tile each expert exactly and absmax reshapes cleanly to
|
|
127
|
+
# [num_experts, blocks_per_expert]. (gate_up in_features is hidden_dim; down_proj
|
|
128
|
+
# in_features is intermediate_dim.)
|
|
129
|
+
for name, in_features in (
|
|
130
|
+
("hidden_dim", hidden_dim),
|
|
131
|
+
("intermediate_dim", intermediate_dim),
|
|
132
|
+
):
|
|
133
|
+
if in_features % blocksize != 0:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"{name} ({in_features}) must be divisible by blocksize ({blocksize}) "
|
|
136
|
+
"so per-expert quantization blocks align with expert boundaries"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self.num_experts = num_experts
|
|
140
|
+
self.hidden_dim = hidden_dim
|
|
141
|
+
self.intermediate_dim = intermediate_dim
|
|
142
|
+
self.has_gate = has_gate
|
|
143
|
+
self.act_fn = activation if activation is not None else F_nn.silu
|
|
144
|
+
self.compute_dtype = compute_dtype
|
|
145
|
+
self.quant_type = quant_type
|
|
146
|
+
self.blocksize = blocksize
|
|
147
|
+
|
|
148
|
+
gate_up_out = 2 * intermediate_dim if has_gate else intermediate_dim
|
|
149
|
+
self._gate_up_shape = (gate_up_out, hidden_dim)
|
|
150
|
+
self._down_shape = (hidden_dim, intermediate_dim)
|
|
151
|
+
|
|
152
|
+
gate_up_numel = gate_up_out * hidden_dim
|
|
153
|
+
down_numel = hidden_dim * intermediate_dim
|
|
154
|
+
|
|
155
|
+
# Packed 4-bit weights as plain (frozen) parameters: two 4-bit values per byte.
|
|
156
|
+
self.gate_up_proj = nn.Parameter(
|
|
157
|
+
torch.empty(num_experts, gate_up_numel // 2, dtype=torch.uint8, device=device),
|
|
158
|
+
requires_grad=False,
|
|
159
|
+
)
|
|
160
|
+
self.down_proj = nn.Parameter(
|
|
161
|
+
torch.empty(num_experts, down_numel // 2, dtype=torch.uint8, device=device),
|
|
162
|
+
requires_grad=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Per-expert quantization scales.
|
|
166
|
+
self.register_buffer(
|
|
167
|
+
"gate_up_absmax",
|
|
168
|
+
torch.empty(
|
|
169
|
+
num_experts,
|
|
170
|
+
gate_up_numel // blocksize,
|
|
171
|
+
dtype=torch.float32,
|
|
172
|
+
device=device,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
self.register_buffer(
|
|
176
|
+
"down_absmax",
|
|
177
|
+
torch.empty(num_experts, down_numel // blocksize, dtype=torch.float32, device=device),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# The 4-bit codebook is identical for every expert and fully determined by
|
|
181
|
+
# quant_type, so it is reconstructed at init rather than serialized.
|
|
182
|
+
self.register_buffer("code", F.get_4bit_type(quant_type, device=device), persistent=False)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_float(
|
|
186
|
+
cls,
|
|
187
|
+
gate_up_proj: torch.Tensor,
|
|
188
|
+
down_proj: torch.Tensor,
|
|
189
|
+
has_gate: bool = True,
|
|
190
|
+
activation: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
|
|
191
|
+
compute_dtype: Optional[torch.dtype] = None,
|
|
192
|
+
quant_type: str = "nf4",
|
|
193
|
+
blocksize: int = 64,
|
|
194
|
+
) -> "Experts4bit":
|
|
195
|
+
"""Build an :class:`Experts4bit` by quantizing full-precision expert weights.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
gate_up_proj (`torch.Tensor`): Shape ``[num_experts, gate_up_out, hidden_dim]``,
|
|
199
|
+
where ``gate_up_out`` is ``2 * intermediate_dim`` when ``has_gate`` else
|
|
200
|
+
``intermediate_dim``.
|
|
201
|
+
down_proj (`torch.Tensor`): Shape ``[num_experts, hidden_dim, intermediate_dim]``.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
`Experts4bit`: A module holding the quantized weights on the inputs' device.
|
|
205
|
+
"""
|
|
206
|
+
if gate_up_proj.dim() != 3 or down_proj.dim() != 3:
|
|
207
|
+
raise ValueError("gate_up_proj and down_proj must be 3D [num_experts, out, in] tensors")
|
|
208
|
+
|
|
209
|
+
num_experts, _, hidden_dim = gate_up_proj.shape
|
|
210
|
+
intermediate_dim = down_proj.shape[2]
|
|
211
|
+
|
|
212
|
+
module = cls(
|
|
213
|
+
num_experts,
|
|
214
|
+
hidden_dim,
|
|
215
|
+
intermediate_dim,
|
|
216
|
+
has_gate=has_gate,
|
|
217
|
+
activation=activation,
|
|
218
|
+
compute_dtype=compute_dtype if compute_dtype is not None else gate_up_proj.dtype,
|
|
219
|
+
quant_type=quant_type,
|
|
220
|
+
blocksize=blocksize,
|
|
221
|
+
device=gate_up_proj.device,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
gate_up_packed, gate_up_absmax = module._quantize_stack(gate_up_proj)
|
|
225
|
+
down_packed, down_absmax = module._quantize_stack(down_proj)
|
|
226
|
+
|
|
227
|
+
module.gate_up_proj = nn.Parameter(gate_up_packed, requires_grad=False)
|
|
228
|
+
module.down_proj = nn.Parameter(down_packed, requires_grad=False)
|
|
229
|
+
module.gate_up_absmax = gate_up_absmax
|
|
230
|
+
module.down_absmax = down_absmax
|
|
231
|
+
return module
|
|
232
|
+
|
|
233
|
+
def _quantize_stack(self, weights: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
234
|
+
"""Quantize a ``[num_experts, out, in]`` stack to packed bytes + per-expert absmax."""
|
|
235
|
+
packed = []
|
|
236
|
+
absmax = []
|
|
237
|
+
for e in range(weights.shape[0]):
|
|
238
|
+
q, state = F.quantize_4bit(
|
|
239
|
+
weights[e].contiguous(),
|
|
240
|
+
blocksize=self.blocksize,
|
|
241
|
+
compress_statistics=False,
|
|
242
|
+
quant_type=self.quant_type,
|
|
243
|
+
)
|
|
244
|
+
packed.append(q.reshape(-1))
|
|
245
|
+
absmax.append(state.absmax.reshape(-1))
|
|
246
|
+
return torch.stack(packed), torch.stack(absmax)
|
|
247
|
+
|
|
248
|
+
def _dequantize_expert(
|
|
249
|
+
self,
|
|
250
|
+
packed: torch.Tensor,
|
|
251
|
+
absmax: torch.Tensor,
|
|
252
|
+
shape: tuple[int, int],
|
|
253
|
+
expert_idx: int,
|
|
254
|
+
dtype: torch.dtype,
|
|
255
|
+
) -> torch.Tensor:
|
|
256
|
+
"""Dequantize a single expert's 2D weight ``[out, in]`` for the matmul."""
|
|
257
|
+
quant_state = QuantState(
|
|
258
|
+
absmax=absmax[expert_idx],
|
|
259
|
+
shape=torch.Size(shape),
|
|
260
|
+
code=self.code,
|
|
261
|
+
blocksize=self.blocksize,
|
|
262
|
+
quant_type=self.quant_type,
|
|
263
|
+
dtype=dtype,
|
|
264
|
+
)
|
|
265
|
+
# Restore the [packed, 1] layout quantize_4bit emits (and which keeps the
|
|
266
|
+
# transpose back-compat shim — keyed on A.shape[0] == 1 — from firing).
|
|
267
|
+
return F.dequantize_4bit(packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
|
|
268
|
+
|
|
269
|
+
def _expert_matmul(
|
|
270
|
+
self,
|
|
271
|
+
packed: torch.Tensor,
|
|
272
|
+
absmax: torch.Tensor,
|
|
273
|
+
shape: tuple[int, int],
|
|
274
|
+
expert_idx: int,
|
|
275
|
+
x: torch.Tensor,
|
|
276
|
+
compute_dtype: torch.dtype,
|
|
277
|
+
) -> torch.Tensor:
|
|
278
|
+
"""Apply one expert's frozen 4-bit ``[out, in]`` weight to ``x``.
|
|
279
|
+
|
|
280
|
+
Routes through :func:`bnb.matmul_4bit` — the same path :class:`Linear4bit` uses — so that
|
|
281
|
+
in training the autograd ``Function`` re-dequantizes the weight in the backward pass rather
|
|
282
|
+
than stashing the full-precision expert weight as a saved activation, and never produces a
|
|
283
|
+
gradient for the frozen packed weights. Numerically identical to dequantize-then-``linear``;
|
|
284
|
+
this keeps memory low when training a LoRA/adapter on top of a frozen ``Experts4bit`` base.
|
|
285
|
+
"""
|
|
286
|
+
quant_state = QuantState(
|
|
287
|
+
absmax=absmax[expert_idx],
|
|
288
|
+
shape=torch.Size(shape),
|
|
289
|
+
code=self.code,
|
|
290
|
+
blocksize=self.blocksize,
|
|
291
|
+
quant_type=self.quant_type,
|
|
292
|
+
dtype=compute_dtype,
|
|
293
|
+
)
|
|
294
|
+
return bnb.matmul_4bit(x, packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
|
|
295
|
+
|
|
296
|
+
def _project(self, packed, absmax, shape, expert_idx, x, compute_dtype, use_matmul_4bit):
|
|
297
|
+
"""One expert projection: matmul_4bit when supported (bnb>=0.50), else dequantize+linear."""
|
|
298
|
+
if use_matmul_4bit:
|
|
299
|
+
return self._expert_matmul(packed, absmax, shape, expert_idx, x, compute_dtype)
|
|
300
|
+
weight = self._dequantize_expert(packed, absmax, shape, expert_idx, compute_dtype)
|
|
301
|
+
return F_nn.linear(x, weight)
|
|
302
|
+
|
|
303
|
+
def forward(
|
|
304
|
+
self,
|
|
305
|
+
hidden_states: torch.Tensor,
|
|
306
|
+
top_k_index: torch.Tensor,
|
|
307
|
+
top_k_weights: torch.Tensor,
|
|
308
|
+
) -> torch.Tensor:
|
|
309
|
+
compute_dtype = self.compute_dtype if self.compute_dtype is not None else hidden_states.dtype
|
|
310
|
+
hidden_states = hidden_states.to(compute_dtype)
|
|
311
|
+
|
|
312
|
+
# matmul_4bit lowers training memory but is CUDA-only here and only correct on
|
|
313
|
+
# bitsandbytes>=0.50 for the [packed, 1] layout; otherwise use the bit-exact dequantize path.
|
|
314
|
+
use_matmul_4bit = hidden_states.is_cuda and _matmul_4bit_matches_dequant()
|
|
315
|
+
|
|
316
|
+
# Accumulate in float32 for numerical stability with bf16/fp16 routing weights.
|
|
317
|
+
final_hidden_states = torch.zeros_like(hidden_states, dtype=torch.float32)
|
|
318
|
+
|
|
319
|
+
with torch.no_grad():
|
|
320
|
+
expert_mask = F_nn.one_hot(top_k_index, num_classes=self.num_experts)
|
|
321
|
+
expert_mask = expert_mask.permute(2, 1, 0)
|
|
322
|
+
expert_hit = torch.greater(expert_mask.sum(dim=(-1, -2)), 0).nonzero(as_tuple=False).view(-1)
|
|
323
|
+
|
|
324
|
+
for expert_idx in expert_hit:
|
|
325
|
+
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
|
|
326
|
+
current_state = hidden_states[token_idx]
|
|
327
|
+
|
|
328
|
+
proj = self._project(
|
|
329
|
+
self.gate_up_proj,
|
|
330
|
+
self.gate_up_absmax,
|
|
331
|
+
self._gate_up_shape,
|
|
332
|
+
expert_idx,
|
|
333
|
+
current_state,
|
|
334
|
+
compute_dtype,
|
|
335
|
+
use_matmul_4bit,
|
|
336
|
+
)
|
|
337
|
+
if self.has_gate:
|
|
338
|
+
gate, up = proj.chunk(2, dim=-1)
|
|
339
|
+
current_hidden = self.act_fn(gate) * up
|
|
340
|
+
else:
|
|
341
|
+
current_hidden = self.act_fn(proj)
|
|
342
|
+
|
|
343
|
+
current_hidden = self._project(
|
|
344
|
+
self.down_proj,
|
|
345
|
+
self.down_absmax,
|
|
346
|
+
self._down_shape,
|
|
347
|
+
expert_idx,
|
|
348
|
+
current_hidden,
|
|
349
|
+
compute_dtype,
|
|
350
|
+
use_matmul_4bit,
|
|
351
|
+
)
|
|
352
|
+
current_hidden = current_hidden * top_k_weights[token_idx, top_k_pos, None]
|
|
353
|
+
final_hidden_states.index_add_(0, token_idx, current_hidden.to(final_hidden_states.dtype))
|
|
354
|
+
|
|
355
|
+
return final_hidden_states.to(hidden_states.dtype)
|