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.
@@ -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
+ [![CI](https://github.com/pjordanandrsn/experts4bit-qlora/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/pjordanandrsn/experts4bit-qlora/actions/workflows/ci.yml/badge.svg)](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)