已合并
feat: 新增 MXFP4 QAT 伪量化 Linear(带 STE) #264
huangwuwei创建于 17 天前
feat: 新增 MXFP4 QAT 伪量化 Linear(带 STE) #264
已合并
huangwuwei创建于 17 天前
9 个文件变更+1077-2
@@ -14,9 +14,15 @@ fakequant/
14│ ├── python/mxfp4/# Python 包装14│ ├── python/mxfp4/# Python 包装
15│ ├── reference/ # 纯 PyTorch 参考实现15│ ├── reference/ # 纯 PyTorch 参考实现
16│ └── tests/16│ └── tests/
17-── README.md17+── mxfp4_qat/ # MXFP4 量化感知训练(QAT):带 STE 的 nn.Linear 替代实现
18+│ ├── fake_quant.py# MXFP4 QDQ + STE autograd.Function + 量化器模块
19+│ └── linear.py # MXFP4QATLinear + convert_to_mxfp4_qat
20+├── README.md
21+└── README_en.md
18```22```
19 23 
24+`mxfp4_ascendc/` 面向**推理侧精度验证**(快速跑出伪量化数值),`mxfp4_qat/` 面向**训练侧**(让模型在训练中适应 MXFP4 误差);后者在 NPU 上会自动复用前者的算子加速。
25+ 
20## 说明26## 说明
21 27 
22- 本模块属于试验特性(`experimental`),接口与实现可能随硬件能力演进而调整。28- 本模块属于试验特性(`experimental`),接口与实现可能随硬件能力演进而调整。
@@ -0,0 +1,30 @@
1+# FakeQuant Simulation Toolkit
2+ 
3+This directory provides a fake quantization toolkit. It simulates the numerical behavior of quantization formats that the hardware does not yet natively support, so that accuracy can be verified and algorithms can be evaluated in software.
4+ 
5+Typical cases include formats that have not yet landed in the NPU operator stack, or environments where the corresponding low-bit compute units cannot be enabled. The fake-quant path still reproduces an approximate quantized numerical behavior.
6+ 
7+## Directory Layout
8+ 
9+```
10+fakequant/
11+├── mxfp4_ascendc/ # MXFP4 Ascend-C fake-quant operator (layout aligned with amct_ops/hifloat8_cast)
12+│ ├── op_kernel/ # device kernel + tiling
13+│ ├── op_extension/# Torch host + TORCH_LIBRARY registration
14+│ ├── python/mxfp4/# Python wrapper
15+│ ├── reference/ # pure PyTorch reference implementation
16+│ └── tests/
17+├── mxfp4_qat/ # MXFP4 quantization-aware training (QAT): STE-based nn.Linear replacement
18+│ ├── fake_quant.py# MXFP4 QDQ + STE autograd.Function + quantizer module
19+│ └── linear.py # MXFP4QATLinear + convert_to_mxfp4_qat
20+├── README.md
21+└── README_en.md
22+```
23+ 
24+`mxfp4_ascendc/` targets **inference-side accuracy verification** (producing fake-quant values quickly). `mxfp4_qat/` targets **training** (letting the model adapt to MXFP4 error during training). On NPU, the latter automatically reuses the former's operator for acceleration.
25+ 
26+## Notes
27+ 
28+- This module is experimental (`experimental`). Interfaces and implementations may change as hardware capability evolves.
29+- Fake-quant results are for accuracy alignment and scheme validation. They are not equivalent to the performance of real low-bit operators on the target hardware.
30+- The MXFP4 operator follows the three-layer layout of `amct_ops/hifloat8_cast`, but stays under `amct_pytorch/experimental/` during the experimental stage and is not moved into `amct_ops/` yet.
@@ -36,7 +36,8 @@ mxfp4_ascendc/
36├── CMakeLists.txt # 构建入口36├── CMakeLists.txt # 构建入口
37├── build.sh # 一键编译并 stage .so 到 python/mxfp4/37├── build.sh # 一键编译并 stage .so 到 python/mxfp4/
38├── tests/ # 正确性 / inv_scale / benchmark38├── tests/ # 正确性 / inv_scale / benchmark
39-── README.md39+── README.md
40+└── README_en.md
40```41```
41 42 
42## 从源码编译43## 从源码编译
@@ -86,6 +87,8 @@ quant_dequant_mxfp4(
86 87 
87AIV 核数由 host 侧 `PlatformAscendC::GetCoreNumAiv()` 运行时查询,无需手动指定。88AIV 核数由 host 侧 `PlatformAscendC::GetCoreNumAiv()` 运行时查询,无需手动指定。
88 89 
90+需要在训练中使用(带 STE 的可微伪量化)见 `../mxfp4_qat/`,其 `backend="auto"` 会在 NPU 张量上自动调用本算子。
91+ 
89### 性能92### 性能
90 93 
91| Shape | torch_npu | Ascend-C | 加速比 |94| Shape | torch_npu | Ascend-C | 加速比 |
@@ -0,0 +1,98 @@
1+# MXFP4 Ascend-C Accelerated Operator (experimental)
2+ 
3+An Ascend-C custom kernel that implements the MXFP4 (Microscaling FP4 E2M1) fake-quant operator on Ascend NPU. The directory layout is aligned with `amct_ops/hifloat8_cast` (`op_kernel` / `op_extension` / `python`). The implementation remains under `amct_pytorch/experimental/fakequant/` (experimental; not part of `amct_ops`).
4+ 
5+Compared with the torch_npu software path: **3.3x speedup** (large matrices), **18x speedup** (small matrices). Correctness is bit-exact against the PyTorch reference implementation.
6+ 
7+## Runtime Environment
8+ 
9+| Component | Version |
10+|-----------|---------|
11+| Hardware | Ascend 910B3 / compatible SoC |
12+| CANN | 8.2.RC1+ |
13+| Python | 3.10 (aarch64) |
14+| PyTorch | 2.6.0 |
15+| torch_npu | 2.6.0.post4 |
16+ 
17+> The open-source repository does not ship prebuilt `.so` files. Compile locally against your SoC / CANN / Python ABI.
18+ 
19+## Directory Structure
20+ 
21+```
22+mxfp4_ascendc/
23+├── op_kernel/
24+│ ├── mxfp4_kernel.cpp # Ascend-C device kernel
25+│ └── mxfp4_tiling.h # Host/device shared tiling constants and structs
26+├── op_extension/
27+│ ├── mxfp4_torch.cpp # PyTorch host: tiling + ACLRT_LAUNCH_KERNEL
28+│ ├── ops.h # C++ host API declaration (namespace AscendKernel)
29+│ └── register.cpp # TORCH_LIBRARY_FRAGMENT(amct, ...) + Meta
30+├── python/
31+│ └── mxfp4/
32+│ ├── __init__.py # load .so, self-check, re-export
33+│ └── ops.py # thin Python wrapper (pad / dtype)
34+├── reference/
35+│ └── mxfp4_ref.py # pure PyTorch reference implementation
36+├── CMakeLists.txt # build entry
37+├── build.sh # one-shot build and stage .so into python/mxfp4/
38+├── tests/ # correctness / inv_scale / benchmark
39+├── README.md
40+└── README_en.md
41+```
42+ 
43+## Build from Source
44+ 
45+```bash
46+cd /path/to/mxfp4_ascendc
47+ 
48+# Build (takes a few minutes); on success, .so files are copied to python/mxfp4/
49+bash build.sh
50+ 
51+# Correctness + performance tests
52+python tests/test_mxfp4.py
53+# python tests/test_inv_scale.py # inv_scale parameter correctness
54+# python tests/bench_qdq.py # extra performance comparison
55+```
56+ 
57+Specify SoC:
58+ 
59+```bash
60+SOC_VERSION=Ascend910_9392 bash build.sh
61+```
62+ 
63+## Quick Start
64+ 
65+```python
66+import sys
67+sys.path.insert(0, "/path/to/mxfp4_ascendc/python")
68+ 
69+from mxfp4 import quant_dequant_mxfp4
70+ 
71+x_npu = x.npu()
72+result = quant_dequant_mxfp4(x_npu)
73+ 
74+# Equivalent low-level call (input must already be float32 flat, numel a multiple of 32)
75+# result = torch.ops.amct.quant_dequant_mxfp4(x_flat, 1.0)
76+```
77+ 
78+### API
79+ 
80+```python
81+quant_dequant_mxfp4(
82+ x: torch.Tensor, # any shape, float32 recommended, on NPU
83+ block_size: int = 32, # quantization block width (must be 32)
84+ inv_scale_factor_scale: float = 1.0,
85+) -> torch.Tensor # same shape / dtype / device
86+```
87+ 
88+AIV core count is queried at runtime on the host via `PlatformAscendC::GetCoreNumAiv()`; you do not need to set it manually.
89+ 
90+For training use (differentiable fake quant with STE), see `../mxfp4_qat/`. Its `backend="auto"` automatically calls this operator on NPU tensors.
91+ 
92+### Performance
93+ 
94+| Shape | torch_npu | Ascend-C | Speedup |
95+|-------|-----------|----------|---------|
96+| (64, 4096) | 0.69 ms | 0.038 ms | **18.1x** |
97+| (256, 4096) | 0.72 ms | 0.059 ms | **12.3x** |
98+| (1024, 4096) | 0.72 ms | 0.219 ms | **3.28x** |
@@ -0,0 +1,180 @@
1+# MXFP4 QAT Linear(experimental)
2+ 
3+一个**最小可用**的 MXFP4 量化感知训练(QAT)实现:`MXFP4QATLinear` 可直接替换 `torch.nn.Linear`,前向按 MXFP4 数值伪量化、反向通过 STE(straight-through estimator,直通估计器)把梯度回传到高精度主权重,从而让模型在训练阶段就适应 MXFP4 的量化误差。
4+ 
5+本目录只提供**训练侧的量化算子与层**,不含训练框架。第 5 节说明如何把它接入你自己的框架(Megatron / DeepSpeed / HuggingFace Trainer 等)。
6+ 
7+## 1. MXFP4 与 STE
8+ 
9+**MXFP4** = 逐元素 FP4 E2M1 尾数 + 逐 block 的 E8M0(2 的幂)共享缩放:
10+ 
11+- 最后一维每 `block_size`(默认 32)个相邻元素共享一个 scale;
12+- `scale = 2^round(log2(max_abs / scale_factor))``scale_factor` 默认 `6.0`
13+- 元素码本为 `{0, ±0.5, ±1, ±1.5, ±2, ±3, ±4, ±6}`(乘以 scale)。
14+ 
15+量化是分段常量函数,几乎处处导数为 0,无法直接反传。**STE** 的做法是:前向用量化值、反向把量化算子当作恒等映射,因此梯度可以穿过量化点抵达高精度权重:
16+ 
17+```
18+forward : y = Q(x)
19+backward: dL/dx = dL/dy # 普通 STE
20+ dL/dx = dL/dy * (|x| <= 6*scale) # clipped STE(clip_grad=True)
21+```
22+ 
23+`clip_grad=True` 时会把**发生截断(saturation)**的元素梯度置零。因为 scale 被强制取整到 2 的幂(可能向下取整),block 内最大值有约一半概率超出 `6*scale` 而被截断,这些位置的梯度方向具有误导性,屏蔽后训练通常更稳。
24+ 
25+> 实现上等价于 MindSpeed-LLM 的 `x + (x_q - x).detach()` 写法,本目录改用显式的
26+> `torch.autograd.Function`,便于在 `backward` 里做梯度屏蔽,也更直观。
27+ 
28+## 2. 目录结构
29+ 
30+```
31+mxfp4_qat/
32+├── fake_quant.py # MXFP4 QDQ、STE autograd.Function、量化器模块、配置
33+├── linear.py # MXFP4QATLinear + convert_to_mxfp4_qat
34+├── README.md
35+└── README_en.md
36+```
37+ 
38+`fake_quant.py` 只依赖 `torch`,可以单文件拷进任意训练仓使用。
39+ 
40+## 3. 快速开始
41+ 
42+```python
43+import sys
44+sys.path.insert(0, ".../amct_pytorch/experimental/fakequant")
45+ 
46+from mxfp4_qat import MXFP4QATConfig, convert_to_mxfp4_qat
47+ 
48+model.load_state_dict(torch.load(ckpt)) # 从 float 预训练权重出发
49+convert_to_mxfp4_qat(model, MXFP4QATConfig(quantize_input=True))
50+# 其余训练代码不变
51+```
52+ 
53+## 4. API
54+ 
55+```python
56+import sys
57+sys.path.insert(0, ".../amct_pytorch/experimental/fakequant")
58+ 
59+from mxfp4_qat import MXFP4QATConfig, MXFP4QATLinear, convert_to_mxfp4_qat
60+```
61+ 
62+### `MXFP4QATConfig`
63+ 
64+| 字段 | 默认 | 说明 |
65+|------|------|------|
66+| `quantize_weight` | `True` | 是否伪量化权重 |
67+| `quantize_input` | `False` | 是否伪量化层输入。`False` → W4A16(推荐起点),`True` → W4A4 |
68+| `block_size` | `32` | 共享 scale 的元素数;Ascend-C 算子只支持 32 |
69+| `scale_factor` | `6.0` | 增大 → scale 变小,inlier 分辨率更高但截断更多;减小则相反 |
70+| `clip_grad` | `False` | `True` 使用 clipped STE |
71+| `backend` | `"auto"` | `"auto"`(NPU 上自动用 Ascend-C 算子,否则纯 PyTorch)/ `"torch"` / `"npu"` |
72+ 
73+### `MXFP4QATLinear`
74+ 
75+`nn.Linear` 的子类,前向为 `F.linear(Q(x), Q(W), b)`(bias 不量化)。
76+ 
77+由于量化器无参数、无 buffer,**`state_dict` 与 float 层完全一致**:float 权重可以直接 load 进 QAT 模型,QAT 训练完的权重也可以 load 回 float 模型或交给 AMCT 的 deploy 流程导出。
78+ 
79+```python
80+layer = MXFP4QATLinear(in_features, out_features, config=MXFP4QATConfig())
81+layer = MXFP4QATLinear.from_linear(existing_linear, config) # 复用原 Parameter,不额外占显存
82+```
83+ 
84+### `convert_to_mxfp4_qat(module, config=None, skip_names=())`
85+ 
86+原地递归替换 `module` 下所有 `nn.Linear``skip_names` 按模块点分路径做**子串匹配**,命中则跳过该子树:
87+ 
88+```python
89+convert_to_mxfp4_qat(
90+ model,
91+ MXFP4QATConfig(quantize_input=True, clip_grad=True),
92+ skip_names=("lm_head", "embed_tokens"), # 敏感层保持 float
93+)
94+```
95+ 
96+### 底层函数
97+ 
98+```python
99+mxfp4_quant_dequant(x, block_size=32, scale_factor=6.0, backend="auto") # 无梯度,与 mxfp4_ascendc 参考实现 bit-exact
100+mxfp4_fake_quant(x, block_size=32, scale_factor=6.0, clip_grad=False, backend="auto") # 带 STE
101+mxfp4_saturation_mask(x, block_size=32, scale_factor=6.0) # 截断位置掩码
102+MXFP4FakeQuantizer(block_size=32, scale_factor=6.0, clip_grad=False, backend="auto") # nn.Module 形态
103+```
104+ 
105+## 5. 接入自己的训练框架
106+ 
107+### 方式一:模型里是标准 `nn.Linear`
108+ 
109+建模完成、加载完预训练权重之后,`optimizer` 创建**之前**插入一行即可,其余训练代码不用改:
110+ 
111+```python
112+model = build_model()
113+model.load_state_dict(torch.load(ckpt)) # 从 float 预训练权重出发
114+ 
115+convert_to_mxfp4_qat(model, MXFP4QATConfig(quantize_input=True))
116+ 
117+optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
118+# ... 正常训练循环 ...
119+```
120+ 
121+`from_linear` 复用原 `Parameter` 对象,所以在 optimizer 之后转换也不会失效;但放在 optimizer 之前更保险(避免 parameter group 引用悬空)。
122+ 
123+### 方式二:框架有自定义 Linear(Megatron `ColumnParallelLinear` 等)
124+ 
125+无法用继承替换时,把 `MXFP4FakeQuantizer` 作为量化器挂到层上,在 `forward` 里手动调用即可 —— 这正是 MindSpeed-LLM 的做法:
126+ 
127+```python
128+from mxfp4_qat import MXFP4FakeQuantizer
129+ 
130+ 
131+class FakeQuantColumnParallelLinear(ColumnParallelLinear):
132+ def __init__(self, *args, **kwargs):
133+ super().__init__(*args, **kwargs)
134+ self.weight_quantizer = MXFP4FakeQuantizer()
135+ self.input_quantizer = MXFP4FakeQuantizer()
136+ 
137+ def forward(self, input_, weight=None, **kwargs):
138+ input_ = self.input_quantizer(input_)
139+ # 父类 forward 读取 self.weight,因此临时替换 .data 后再恢复。
140+ # 前向已在量化值上完成,梯度经 STE 正确回传到原始高精度权重。
141+ original = self.weight.data
142+ self.weight.data = self.weight_quantizer(original)
143+ try:
144+ return super().forward(input_, weight=weight, **kwargs)
145+ finally:
146+ self.weight.data = original
147+```
148+ 
149+MoE 的 GroupedMatmul 专家权重同理:在调用 GMM 之前对 `w1` / `w2` 和 permute 后的专家输入各过一次量化器。
150+ 
151+### 方式三:只想复用量化算子
152+ 
153+直接调用 `mxfp4_fake_quant(x)`,它就是一个可微的 `Tensor -> Tensor` 函数,放在任何位置(KV cache、logits、residual 等)都可以。
154+ 
155+### 训练建议
156+ 
157+- **从 float 预训练权重出发**做 QAT 微调,不要从随机初始化开始训。
158+- **先 W4A16 再 W4A4**:激活量化掉点通常明显大于权重量化,先确认 `quantize_input=False` 能收敛。
159+- **学习率取预训练的 1/10 左右**并配 cosine 衰减。
160+- **敏感层保持 float**`lm_head`、embedding、第一/最后一层通常通过 `skip_names` 排除。
161+- **NPU 上务必用 Ascend-C 后端**:纯 PyTorch 路径每次 QDQ 有十几个 elementwise kernel,大模型训练开销不可忽略;`backend="auto"` 会在 NPU 张量上自动切换。
162+- 训练完成后用 `amct_pytorch` 的 deploy 流程导出真实低比特权重;QAT 只是让权重"适应"MXFP4,导出仍需常规量化链路。
163+ 
164+### Ascend-C 算子的定位
165+ 
166+`backend="auto"` 时按以下顺序查找 `mxfp4` 包:环境变量 `MXFP4_ASCENDC_PATH` → 同级 `../mxfp4_ascendc/python`。算子需先自行编译:
167+ 
168+```bash
169+cd ../mxfp4_ascendc && bash build.sh
170+```
171+ 
172+未编译或不在 NPU 上时自动退回纯 PyTorch 路径(结果 bit-exact 一致,仅速度不同);显式指定 `backend="npu"` 而算子不可用时会抛出带修复指引的 `RuntimeError`
173+ 
174+## 6. 限制
175+ 
176+- 属于试验特性(`experimental`),接口可能调整。
177+- 只覆盖 `nn.Linear`;卷积、Embedding、Attention 内部的 matmul 未处理。
178+- scale 与截断阈值均由数据静态推导,未实现可学习的 scale / clipping(LSQ、PACT 等)。
179+- `block_size != 32` 只有纯 PyTorch 路径支持。
180+- 伪量化仅复现 MXFP4 的数值行为,不代表目标硬件上真实低比特算子的性能。
@@ -0,0 +1,180 @@
1+# MXFP4 QAT Linear (experimental)
2+ 
3+A **minimal usable** MXFP4 quantization-aware training (QAT) implementation: `MXFP4QATLinear` can replace `torch.nn.Linear` directly. The forward pass fake-quantizes values with MXFP4 numerics; the backward pass uses STE (straight-through estimator) to send gradients back to the high-precision master weights, so the model adapts to MXFP4 quantization error during training.
4+ 
5+This directory only provides **training-side quantization operators and layers**. It does not include a training framework. Section 5 describes how to plug them into your own stack (Megatron / DeepSpeed / HuggingFace Trainer, etc.).
6+ 
7+## 1. MXFP4 and STE
8+ 
9+**MXFP4** = per-element FP4 E2M1 mantissa + per-block E8M0 (power-of-two) shared scale:
10+ 
11+- Every `block_size` (default 32) adjacent elements along the last dimension share one scale;
12+- `scale = 2^round(log2(max_abs / scale_factor))`, with `scale_factor` defaulting to `6.0`;
13+- The element codebook is `{0, ±0.5, ±1, ±1.5, ±2, ±3, ±4, ±6}` (multiplied by scale).
14+ 
15+Quantization is a piecewise-constant function whose derivative is zero almost everywhere, so it cannot back-propagate directly. **STE** uses the quantized value in the forward pass and treats the quantizer as identity in the backward pass, so gradients can pass through the quantization point to the high-precision weights:
16+ 
17+```
18+forward : y = Q(x)
19+backward: dL/dx = dL/dy # plain STE
20+ dL/dx = dL/dy * (|x| <= 6*scale) # clipped STE (clip_grad=True)
21+```
22+ 
23+When `clip_grad=True`, gradients of **saturated (clipped)** elements are zeroed. Because scale is forced onto a power of two (and may round down), the block maximum has about a 50% chance of exceeding `6*scale` and being clipped. Gradients at those positions are misleading; masking them usually makes training more stable.
24+ 
25+> This is numerically equivalent to MindSpeed-LLM's `x + (x_q - x).detach()` form. This directory uses an explicit
26+> `torch.autograd.Function` instead, which makes gradient masking in `backward` straightforward and easier to follow.
27+ 
28+## 2. Directory Structure
29+ 
30+```
31+mxfp4_qat/
32+├── fake_quant.py # MXFP4 QDQ, STE autograd.Function, quantizer module, config
33+├── linear.py # MXFP4QATLinear + convert_to_mxfp4_qat
34+├── README.md
35+└── README_en.md
36+```
37+ 
38+`fake_quant.py` depends only on `torch` and can be copied as a single file into any training repository.
39+ 
40+## 3. Quick Start
41+ 
42+```python
43+import sys
44+sys.path.insert(0, ".../amct_pytorch/experimental/fakequant")
45+ 
46+from mxfp4_qat import MXFP4QATConfig, convert_to_mxfp4_qat
47+ 
48+model.load_state_dict(torch.load(ckpt)) # start from float pretrained weights
49+convert_to_mxfp4_qat(model, MXFP4QATConfig(quantize_input=True))
50+# remaining training code is unchanged
51+```
52+ 
53+## 4. API
54+ 
55+```python
56+import sys
57+sys.path.insert(0, ".../amct_pytorch/experimental/fakequant")
58+ 
59+from mxfp4_qat import MXFP4QATConfig, MXFP4QATLinear, convert_to_mxfp4_qat
60+```
61+ 
62+### `MXFP4QATConfig`
63+ 
64+| Field | Default | Description |
65+|-------|---------|-------------|
66+| `quantize_weight` | `True` | Whether to fake-quantize weights |
67+| `quantize_input` | `False` | Whether to fake-quantize layer inputs. `False` → W4A16 (recommended starting point), `True` → W4A4 |
68+| `block_size` | `32` | Number of elements sharing a scale; the Ascend-C operator only supports 32 |
69+| `scale_factor` | `6.0` | Larger → smaller scale, higher inlier resolution but more clipping; smaller is the opposite |
70+| `clip_grad` | `False` | `True` uses clipped STE |
71+| `backend` | `"auto"` | `"auto"` (use the Ascend-C operator on NPU tensors, otherwise pure PyTorch) / `"torch"` / `"npu"` |
72+ 
73+### `MXFP4QATLinear`
74+ 
75+A subclass of `nn.Linear`. Forward is `F.linear(Q(x), Q(W), b)` (bias is not quantized).
76+ 
77+Because the quantizer has no parameters and no buffers, the **`state_dict` matches a float layer exactly**: float weights can be loaded into a QAT model, and QAT-trained weights can be loaded back into a float model or handed to AMCT's deploy flow for export.
78+ 
79+```python
80+layer = MXFP4QATLinear(in_features, out_features, config=MXFP4QATConfig())
81+layer = MXFP4QATLinear.from_linear(existing_linear, config) # reuse the original Parameter, no extra GPU memory
82+```
83+ 
84+### `convert_to_mxfp4_qat(module, config=None, skip_names=())`
85+ 
86+Recursively replace all `nn.Linear` modules under `module` in place. `skip_names` does **substring matching** on dotted module paths; a hit skips that subtree:
87+ 
88+```python
89+convert_to_mxfp4_qat(
90+ model,
91+ MXFP4QATConfig(quantize_input=True, clip_grad=True),
92+ skip_names=("lm_head", "embed_tokens"), # keep sensitive layers in float
93+)
94+```
95+ 
96+### Low-level functions
97+ 
98+```python
99+mxfp4_quant_dequant(x, block_size=32, scale_factor=6.0, backend="auto") # no grad, bit-exact with mxfp4_ascendc reference
100+mxfp4_fake_quant(x, block_size=32, scale_factor=6.0, clip_grad=False, backend="auto") # with STE
101+mxfp4_saturation_mask(x, block_size=32, scale_factor=6.0) # saturation-position mask
102+MXFP4FakeQuantizer(block_size=32, scale_factor=6.0, clip_grad=False, backend="auto") # nn.Module form
103+```
104+ 
105+## 5. Integrating with Your Training Framework
106+ 
107+### Option 1: The model uses standard `nn.Linear`
108+ 
109+After the model is built and pretrained weights are loaded, insert one line **before** creating the `optimizer`. The rest of the training code stays unchanged:
110+ 
111+```python
112+model = build_model()
113+model.load_state_dict(torch.load(ckpt)) # start from float pretrained weights
114+ 
115+convert_to_mxfp4_qat(model, MXFP4QATConfig(quantize_input=True))
116+ 
117+optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
118+# ... normal training loop ...
119+```
120+ 
121+`from_linear` reuses the original `Parameter` objects, so converting after the optimizer is created still works; converting before the optimizer is safer (avoids dangling parameter-group references).
122+ 
123+### Option 2: The framework has a custom Linear (e.g. Megatron `ColumnParallelLinear`)
124+ 
125+When inheritance-based replacement is not possible, attach `MXFP4FakeQuantizer` as a quantizer on the layer and call it manually in `forward` — this is how MindSpeed-LLM does it:
126+ 
127+```python
128+from mxfp4_qat import MXFP4FakeQuantizer
129+ 
130+ 
131+class FakeQuantColumnParallelLinear(ColumnParallelLinear):
132+ def __init__(self, *args, **kwargs):
133+ super().__init__(*args, **kwargs)
134+ self.weight_quantizer = MXFP4FakeQuantizer()
135+ self.input_quantizer = MXFP4FakeQuantizer()
136+ 
137+ def forward(self, input_, weight=None, **kwargs):
138+ input_ = self.input_quantizer(input_)
139+ # The parent forward reads self.weight, so temporarily replace .data then restore.
140+ # Forward already ran on quantized values; STE sends gradients back to the original high-precision weights.
141+ original = self.weight.data
142+ self.weight.data = self.weight_quantizer(original)
143+ try:
144+ return super().forward(input_, weight=weight, **kwargs)
145+ finally:
146+ self.weight.data = original
147+```
148+ 
149+The same pattern applies to MoE GroupedMatmul expert weights: run the quantizer on `w1` / `w2` and on the permuted expert inputs before calling GMM.
150+ 
151+### Option 3: Reuse only the quantization operator
152+ 
153+Call `mxfp4_fake_quant(x)` directly. It is a differentiable `Tensor -> Tensor` function and can be placed anywhere (KV cache, logits, residual, etc.).
154+ 
155+### Training tips
156+ 
157+- **Start QAT fine-tuning from float pretrained weights**; do not train from random initialization.
158+- **W4A16 first, then W4A4**: activation quantization usually drops accuracy more than weight quantization. Confirm `quantize_input=False` converges first.
159+- **Use about 1/10 of the pretraining learning rate** with cosine decay.
160+- **Keep sensitive layers in float**: `lm_head`, embeddings, and the first/last layers are usually excluded via `skip_names`.
161+- **On NPU, use the Ascend-C backend**: the pure PyTorch path launches more than ten elementwise kernels per QDQ, which is not negligible for large-model training. `backend="auto"` switches automatically on NPU tensors.
162+- After training, export real low-bit weights with the `amct_pytorch` deploy flow. QAT only makes weights "adapt" to MXFP4; export still needs the regular quantization pipeline.
163+ 
164+### Role of the Ascend-C operator
165+ 
166+When `backend="auto"`, the `mxfp4` package is looked up in this order: environment variable `MXFP4_ASCENDC_PATH` → sibling `../mxfp4_ascendc/python`. Compile the operator first:
167+ 
168+```bash
169+cd ../mxfp4_ascendc && bash build.sh
170+```
171+ 
172+If it is not compiled or the tensor is not on NPU, the code falls back to the pure PyTorch path (bit-exact results, different speed only). Explicitly setting `backend="npu"` when the operator is unavailable raises a `RuntimeError` with fix instructions.
173+ 
174+## 6. Limitations
175+ 
176+- This is experimental (`experimental`); interfaces may change.
177+- Only `nn.Linear` is covered; convolution, Embedding, and matmul inside Attention are not handled.
178+- Scale and clip thresholds are derived statically from data. Learnable scale / clipping (LSQ, PACT, etc.) is not implemented.
179+- `block_size != 32` is supported only on the pure PyTorch path.
180+- Fake quant only reproduces MXFP4 numerical behavior. It does not represent the performance of real low-bit operators on the target hardware.
@@ -0,0 +1,60 @@
1+# -*- coding: UTF-8 -*-
2+# ----------------------------------------------------------------------------
3+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
4+#
5+# Licensed under the Apache License, Version 2.0 (the "License");
6+# you may not use this file except in compliance with the License.
7+# You may obtain a copy of the License at
8+#
9+# http://www.apache.org/licenses/LICENSE-2.0
10+#
11+# Unless required by applicable law or agreed to in writing, software
12+# distributed under the License is distributed on an "AS IS" BASIS,
13+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+# See the License for the specific language governing permissions and
15+# limitations under the License.
16+# ----------------------------------------------------------------------------
17+"""Minimal MXFP4 quantisation-aware training (QAT) building blocks.
18+ 
19+Usage:
20+ import sys
21+ sys.path.insert(0, ".../amct_pytorch/experimental/fakequant")
22+ 
23+ from mxfp4_qat import MXFP4QATConfig, convert_to_mxfp4_qat
24+ 
25+ convert_to_mxfp4_qat(model, MXFP4QATConfig(quantize_input=True))
26+ # ... train as usual; nothing else changes ...
27+"""
28+ 
29+from __future__ import annotations
30+ 
31+__all__ = [
32+ "BACKEND_AUTO",
33+ "BACKEND_NPU",
34+ "BACKEND_TORCH",
35+ "BLOCK_SIZE",
36+ "MXFP4_E2M1_MAX",
37+ "MXFP4FakeQuantizer",
38+ "MXFP4QATConfig",
39+ "MXFP4QATLinear",
40+ "SCALE_FACTOR",
41+ "convert_to_mxfp4_qat",
42+ "mxfp4_fake_quant",
43+ "mxfp4_quant_dequant",
44+ "mxfp4_saturation_mask",
45+]
46+ 
47+from .fake_quant import (
48+ BACKEND_AUTO,
49+ BACKEND_NPU,
50+ BACKEND_TORCH,
51+ BLOCK_SIZE,
52+ MXFP4_E2M1_MAX,
53+ SCALE_FACTOR,
54+ MXFP4FakeQuantizer,
55+ MXFP4QATConfig,
56+ mxfp4_fake_quant,
57+ mxfp4_quant_dequant,
58+ mxfp4_saturation_mask,
59+)
60+from .linear import MXFP4QATLinear, convert_to_mxfp4_qat
@@ -0,0 +1,373 @@
1+# -*- coding: UTF-8 -*-
2+# ----------------------------------------------------------------------------
3+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
4+#
5+# Licensed under the Apache License, Version 2.0 (the "License");
6+# you may not use this file except in compliance with the License.
7+# You may obtain a copy of the License at
8+#
9+# http://www.apache.org/licenses/LICENSE-2.0
10+#
11+# Unless required by applicable law or agreed to in writing, software
12+# distributed under the License is distributed on an "AS IS" BASIS,
13+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+# See the License for the specific language governing permissions and
15+# limitations under the License.
16+# ----------------------------------------------------------------------------
17+"""MXFP4 fake quantisation with a straight-through estimator (STE).
18+ 
19+MXFP4 = per-element FP4 E2M1 mantissa plus a per-block E8M0 (power-of-two)
20+shared scale, with ``block_size`` consecutive elements of the last dimension
21+sharing one scale.
22+ 
23+Quantisation is not differentiable (it is piecewise constant, so its true
24+derivative is zero almost everywhere). QAT therefore uses the straight-through
25+estimator: the forward pass sees quantised values while the backward pass
26+treats the quantiser as an identity, letting the gradient reach the underlying
27+high-precision weights and activations.
28+ 
29+This module is intentionally self-contained (torch only) so it can be copied
30+into a third-party training framework as-is. The Ascend-C kernel under
31+``../mxfp4_ascendc`` is used automatically when it is available and the tensor
32+lives on an NPU; otherwise a pure-PyTorch path runs on any device.
33+"""
34+ 
35+from __future__ import annotations
36+ 
37+__all__ = [
38+ "BACKEND_AUTO",
39+ "BACKEND_NPU",
40+ "BACKEND_TORCH",
41+ "BLOCK_SIZE",
42+ "MXFP4_E2M1_MAX",
43+ "MXFP4FakeQuantizer",
44+ "MXFP4QATConfig",
45+ "SCALE_FACTOR",
46+ "mxfp4_fake_quant",
47+ "mxfp4_quant_dequant",
48+ "mxfp4_saturation_mask",
49+]
50+ 
51+import dataclasses
52+import os
53+import sys
54+from typing import Callable
55+ 
56+import torch
57+import torch.nn.functional as F
58+from torch import nn
59+ 
60+BLOCK_SIZE = 32
61+SCALE_FACTOR = 6.0
62+MXFP4_E2M1_MAX = 6.0
63+ 
64+# Smallest representable block scale, kept in sync with MXFP4_MIN_SCALE_RAW in
65+# ../mxfp4_ascendc/op_kernel/mxfp4_tiling.h.
66+_MIN_SCALE_RAW = 2.0**-30
67+ 
68+# Positive FP4 E2M1 codebook and the midpoints between its adjacent entries.
69+_E2M1_CODES = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
70+_E2M1_MIDPOINTS = (0.25, 0.75, 1.25, 1.75, 2.50, 3.50, 5.00)
71+_codebook_cache: dict[tuple[torch.device, torch.dtype], tuple[torch.Tensor, ...]] = {}
72+ 
73+BACKEND_AUTO = "auto"
74+BACKEND_TORCH = "torch"
75+BACKEND_NPU = "npu"
76+_BACKENDS = (BACKEND_AUTO, BACKEND_TORCH, BACKEND_NPU)
77+ 
78+_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
79+# Ascend-C kernel shipped next to this package; overridable via env var so a
80+# framework can point at an out-of-tree build.
81+_VENDORED_KERNEL_PATH = os.path.join(_PKG_DIR, os.pardir, "mxfp4_ascendc", "python")
82+ 
83+_npu_qdq: Callable[..., torch.Tensor] | None = None
84+_npu_load_error: str | None = None
85+ 
86+ 
87+def _validate(block_size: int, scale_factor: float) -> None:
88+ if block_size <= 0:
89+ raise ValueError(f"block_size must be positive, got {block_size}")
90+ if scale_factor <= 0:
91+ raise ValueError(f"scale_factor must be positive, got {scale_factor}")
92+ 
93+ 
94+def _block_view(x: torch.Tensor, block_size: int) -> tuple[torch.Tensor, int]:
95+ """Reshape *x* to ``(..., n_block, block_size)``, zero-padding the last dim.
96+ 
97+ Padding is applied on the last dimension rather than on a flattened view so
98+ that adjacent rows never end up sharing an MXFP4 block.
99+ """
100+ if x.ndim == 0:
101+ raise ValueError("x must have at least 1 dimension")
102+ last_dim = x.shape[-1]
103+ pad = (block_size - last_dim % block_size) % block_size
104+ x_fp = x.to(torch.float32)
105+ if pad:
106+ x_fp = F.pad(x_fp, (0, pad))
107+ return x_fp.reshape(*x_fp.shape[:-1], -1, block_size), pad
108+ 
109+ 
110+def _unpad(blocked: torch.Tensor, last_dim: int, pad: int) -> torch.Tensor:
111+ flat = blocked.reshape(*blocked.shape[:-2], -1)
112+ return flat[..., :last_dim] if pad else flat
113+ 
114+ 
115+def _block_scale(x_blocked: torch.Tensor, scale_factor: float) -> torch.Tensor:
116+ """Per-block E8M0 scale: the power of two nearest to ``max_abs / scale_factor``."""
117+ max_abs = x_blocked.abs().amax(dim=-1, keepdim=True)
118+ raw_scale = torch.clamp(max_abs / scale_factor, min=_MIN_SCALE_RAW)
119+ return torch.exp2(torch.round(torch.log2(raw_scale)))
120+ 
121+ 
122+def _codebook(like: torch.Tensor) -> tuple[torch.Tensor, ...]:
123+ key = (like.device, like.dtype)
124+ if key not in _codebook_cache:
125+ _codebook_cache[key] = tuple(
126+ torch.tensor(values, device=like.device, dtype=like.dtype)
127+ for values in (_E2M1_MIDPOINTS, _E2M1_CODES)
128+ )
129+ return _codebook_cache[key]
130+ 
131+ 
132+def _round_to_e2m1(y_abs: torch.Tensor) -> torch.Tensor:
133+ """Round each magnitude to the nearest FP4 E2M1 code.
134+ 
135+ Since the thresholds are the codebook midpoints, the number of thresholds a
136+ value exceeds is exactly the index of its nearest code.
137+ """
138+ midpoints, codes = _codebook(y_abs)
139+ return codes[torch.bucketize(y_abs, midpoints, right=True)]
140+ 
141+ 
142+def _quant_dequant_torch(
143+ x: torch.Tensor, block_size: int, scale_factor: float
144+) -> torch.Tensor:
145+ x_blocked, pad = _block_view(x, block_size)
146+ scale = _block_scale(x_blocked, scale_factor)
147+ y = x_blocked / scale
148+ q = torch.sign(y) * _round_to_e2m1(y.abs())
149+ return _unpad(q * scale, x.shape[-1], pad).to(x.dtype)
150+ 
151+ 
152+def _load_npu_qdq() -> Callable[..., torch.Tensor]:
153+ """Import the Ascend-C MXFP4 kernel wrapper, caching success and failure."""
154+ global _npu_qdq, _npu_load_error
155+ 
156+ if _npu_qdq is not None:
157+ return _npu_qdq
158+ if _npu_load_error is not None:
159+ raise RuntimeError(_npu_load_error)
160+ 
161+ search_path = os.environ.get("MXFP4_ASCENDC_PATH") or os.path.realpath(
162+ _VENDORED_KERNEL_PATH
163+ )
164+ if search_path not in sys.path:
165+ sys.path.insert(0, search_path)
166+ 
167+ try:
168+ import mxfp4
169+ 
170+ _npu_qdq = mxfp4.quant_dequant_mxfp4
171+ except Exception as e:
172+ _npu_load_error = (
173+ f"cannot load the Ascend-C MXFP4 kernel from '{search_path}': {e}. "
174+ "Build it with `bash build.sh` in mxfp4_ascendc/, or point "
175+ "MXFP4_ASCENDC_PATH at a directory containing the built `mxfp4` "
176+ f"package, or use backend='{BACKEND_TORCH}'."
177+ )
178+ raise RuntimeError(_npu_load_error) from e
179+ 
180+ return _npu_qdq
181+ 
182+ 
183+def _npu_qdq_available() -> bool:
184+ try:
185+ _load_npu_qdq()
186+ except RuntimeError:
187+ return False
188+ return True
189+ 
190+ 
191+def mxfp4_quant_dequant(
192+ x: torch.Tensor,
193+ block_size: int = BLOCK_SIZE,
194+ scale_factor: float = SCALE_FACTOR,
195+ backend: str = BACKEND_AUTO,
196+) -> torch.Tensor:
197+ """Quantise *x* to MXFP4 and dequantise it back, without any autograd hook.
198+ 
199+ Bit-exactly matches ``mxfp4_ascendc/reference/mxfp4_ref.quant_dequant_mxfp4``.
200+ Use :func:`mxfp4_fake_quant` instead when training.
201+ 
202+ Args:
203+ x: Tensor with at least one dimension, any dtype/device.
204+ block_size: Elements per shared scale. The NPU kernel only supports 32.
205+ scale_factor: Divisor applied to each block maximum before rounding the
206+ scale to a power of two. ``6.0`` maps the block maximum onto the
207+ largest E2M1 code.
208+ backend: ``"auto"`` picks the Ascend-C kernel for NPU tensors when it is
209+ available and falls back to PyTorch; ``"torch"`` / ``"npu"`` force
210+ one path.
211+ 
212+ Returns:
213+ Tensor with the same shape, dtype and device as *x*.
214+ 
215+ Raises:
216+ ValueError: On a non-positive ``block_size`` / ``scale_factor``, a 0-dim
217+ input, or an unknown ``backend``.
218+ RuntimeError: If ``backend="npu"`` but the kernel cannot be loaded.
219+ """
220+ _validate(block_size, scale_factor)
221+ if backend not in _BACKENDS:
222+ raise ValueError(f"backend must be one of {_BACKENDS}, got {backend!r}")
223+ 
224+ use_npu = backend == BACKEND_NPU or (
225+ backend == BACKEND_AUTO and x.device.type == "npu" and _npu_qdq_available()
226+ )
227+ if not use_npu:
228+ return _quant_dequant_torch(x, block_size, scale_factor)
229+ 
230+ # The kernel hard-codes 1 / 6.0 and multiplies it by inv_scale_factor_scale,
231+ # so scale_factor == SCALE_FACTOR / inv_scale_factor_scale.
232+ return _load_npu_qdq()(
233+ x,
234+ block_size=block_size,
235+ inv_scale_factor_scale=SCALE_FACTOR / scale_factor,
236+ )
237+ 
238+ 
239+def mxfp4_saturation_mask(
240+ x: torch.Tensor,
241+ block_size: int = BLOCK_SIZE,
242+ scale_factor: float = SCALE_FACTOR,
243+) -> torch.Tensor:
244+ """Return a bool mask that is ``True`` where MXFP4 quantisation clips *x*.
245+ 
246+ An element saturates when its magnitude exceeds ``6 * block_scale``, which
247+ happens because the block scale is rounded to a power of two and may round
248+ down. Gradients of clipped elements are misleading under a plain STE, so
249+ :class:`MXFP4QATConfig` can mask them out (clipped STE).
250+ """
251+ _validate(block_size, scale_factor)
252+ x_blocked, pad = _block_view(x, block_size)
253+ limit = MXFP4_E2M1_MAX * _block_scale(x_blocked, scale_factor)
254+ return _unpad(x_blocked.abs() > limit, x.shape[-1], pad)
255+ 
256+ 
257+class _MXFP4FakeQuantSTE(torch.autograd.Function):
258+ """Fake-quantise in forward; pass the gradient through unchanged in backward."""
259+ 
260+ @staticmethod
261+ def forward(ctx, x, block_size, scale_factor, clip_grad, backend):
262+ ctx.clip_grad = clip_grad
263+ if clip_grad:
264+ ctx.save_for_backward(mxfp4_saturation_mask(x, block_size, scale_factor))
265+ return mxfp4_quant_dequant(x, block_size, scale_factor, backend)
266+ 
267+ @staticmethod
268+ def backward(ctx, grad_output):
269+ if ctx.clip_grad:
270+ (saturated,) = ctx.saved_tensors
271+ grad_output = grad_output.masked_fill(saturated, 0.0)
272+ return grad_output, None, None, None, None
273+ 
274+ 
275+def mxfp4_fake_quant(
276+ x: torch.Tensor,
277+ block_size: int = BLOCK_SIZE,
278+ scale_factor: float = SCALE_FACTOR,
279+ clip_grad: bool = False,
280+ backend: str = BACKEND_AUTO,
281+) -> torch.Tensor:
282+ """Differentiable MXFP4 fake quantisation (STE).
283+ 
284+ Args:
285+ x: Tensor to fake-quantise, typically a weight or an activation.
286+ block_size: Elements per shared scale.
287+ scale_factor: See :func:`mxfp4_quant_dequant`.
288+ clip_grad: If ``True``, zero the gradient of elements that saturated
289+ (clipped STE) instead of passing everything through.
290+ backend: See :func:`mxfp4_quant_dequant`.
291+ 
292+ Returns:
293+ Fake-quantised tensor, same shape/dtype/device as *x*, whose gradient
294+ flows back to *x* unchanged (or masked when ``clip_grad``).
295+ """
296+ return _MXFP4FakeQuantSTE.apply(x, block_size, scale_factor, clip_grad, backend)
297+ 
298+ 
299+class MXFP4FakeQuantizer(nn.Module):
300+ """Stateless module wrapper around :func:`mxfp4_fake_quant`.
301+ 
302+ Holds no parameters or buffers, so inserting it into a model leaves the
303+ ``state_dict`` untouched and checkpoints stay interchangeable with the
304+ float model.
305+ """
306+ 
307+ def __init__(
308+ self,
309+ block_size: int = BLOCK_SIZE,
310+ scale_factor: float = SCALE_FACTOR,
311+ clip_grad: bool = False,
312+ backend: str = BACKEND_AUTO,
313+ ) -> None:
314+ super().__init__()
315+ _validate(block_size, scale_factor)
316+ if backend not in _BACKENDS:
317+ raise ValueError(f"backend must be one of {_BACKENDS}, got {backend!r}")
318+ self.block_size = block_size
319+ self.scale_factor = scale_factor
320+ self.clip_grad = clip_grad
321+ self.backend = backend
322+ 
323+ def forward(self, x: torch.Tensor) -> torch.Tensor:
324+ return mxfp4_fake_quant(
325+ x, self.block_size, self.scale_factor, self.clip_grad, self.backend
326+ )
327+ 
328+ def extra_repr(self) -> str:
329+ return (
330+ f"block_size={self.block_size}, scale_factor={self.scale_factor}, "
331+ f"clip_grad={self.clip_grad}, backend={self.backend!r}"
332+ )
333+ 
334+ 
335+@dataclasses.dataclass
336+class MXFP4QATConfig:
337+ """MXFP4 QAT settings shared by every converted layer.
338+ 
339+ Attributes:
340+ quantize_weight: Fake-quantise the weight. Disabling it turns the layer
341+ into an activation-only experiment.
342+ quantize_input: Fake-quantise the layer input as well. ``False`` gives
343+ W4A16 (the recommended starting point); ``True`` gives W4A4.
344+ block_size: Elements per shared scale. The Ascend-C kernel needs 32.
345+ scale_factor: See :func:`mxfp4_quant_dequant`. Raising it shrinks the
346+ block scale, resolving inliers more finely but clipping outliers
347+ harder; lowering it does the opposite.
348+ clip_grad: Use a clipped STE instead of a plain one.
349+ backend: ``"auto"`` / ``"torch"`` / ``"npu"``.
350+ """
351+ 
352+ quantize_weight: bool = True
353+ quantize_input: bool = False
354+ block_size: int = BLOCK_SIZE
355+ scale_factor: float = SCALE_FACTOR
356+ clip_grad: bool = False
357+ backend: str = BACKEND_AUTO
358+ 
359+ def __post_init__(self) -> None:
360+ _validate(self.block_size, self.scale_factor)
361+ if self.backend not in _BACKENDS:
362+ raise ValueError(
363+ f"backend must be one of {_BACKENDS}, got {self.backend!r}"
364+ )
365+ 
366+ def make_quantizer(self) -> MXFP4FakeQuantizer:
367+ """Build a fresh quantizer module carrying this configuration."""
368+ return MXFP4FakeQuantizer(
369+ block_size=self.block_size,
370+ scale_factor=self.scale_factor,
371+ clip_grad=self.clip_grad,
372+ backend=self.backend,
373+ )
@@ -0,0 +1,145 @@
1+# -*- coding: UTF-8 -*-
2+# ----------------------------------------------------------------------------
3+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
4+#
5+# Licensed under the Apache License, Version 2.0 (the "License");
6+# you may not use this file except in compliance with the License.
7+# You may obtain a copy of the License at
8+#
9+# http://www.apache.org/licenses/LICENSE-2.0
10+#
11+# Unless required by applicable law or agreed to in writing, software
12+# distributed under the License is distributed on an "AS IS" BASIS,
13+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+# See the License for the specific language governing permissions and
15+# limitations under the License.
16+# ----------------------------------------------------------------------------
17+"""A drop-in ``torch.nn.Linear`` replacement performing MXFP4 QAT."""
18+ 
19+from __future__ import annotations
20+ 
21+__all__ = [
22+ "MXFP4QATLinear",
23+ "convert_to_mxfp4_qat",
24+]
25+ 
26+import torch
27+import torch.nn.functional as F
28+from torch import nn
29+ 
30+from .fake_quant import MXFP4QATConfig
31+ 
32+ 
33+class MXFP4QATLinear(nn.Linear):
34+ """``nn.Linear`` whose weight (and optionally input) is MXFP4 fake-quantised.
35+ 
36+ Master weights stay in high precision and are what the optimiser updates;
37+ every forward pass quantises a throwaway copy so the loss reflects MXFP4
38+ numerics, and the STE routes the gradient back to the master weight.
39+ 
40+ Because it subclasses ``nn.Linear`` and the quantizers are stateless, the
41+ ``state_dict`` is identical to the float layer's, so float checkpoints load
42+ into a QAT model and QAT checkpoints load back into the float model.
43+ 
44+ Args:
45+ in_features: Size of each input sample.
46+ out_features: Size of each output sample.
47+ bias: Whether to learn an additive bias. The bias is never quantised.
48+ device: Device of the created parameters.
49+ dtype: Dtype of the created parameters.
50+ config: MXFP4 QAT settings; defaults to ``MXFP4QATConfig()`` (W4A16).
51+ """
52+ 
53+ def __init__(
54+ self,
55+ in_features: int,
56+ out_features: int,
57+ bias: bool = True,
58+ device: torch.device | str | None = None,
59+ dtype: torch.dtype | None = None,
60+ config: MXFP4QATConfig | None = None,
61+ ) -> None:
62+ super().__init__(in_features, out_features, bias, device=device, dtype=dtype)
63+ self.config = config if config is not None else MXFP4QATConfig()
64+ self.weight_quantizer = (
65+ self.config.make_quantizer() if self.config.quantize_weight else None
66+ )
67+ self.input_quantizer = (
68+ self.config.make_quantizer() if self.config.quantize_input else None
69+ )
70+ 
71+ @classmethod
72+ def from_linear(
73+ cls, linear: nn.Linear, config: MXFP4QATConfig | None = None
74+ ) -> MXFP4QATLinear:
75+ """Wrap an existing ``nn.Linear``, reusing its parameter objects.
76+ 
77+ The weight and bias tensors are adopted rather than copied, so the
78+ conversion costs no extra memory and any optimiser state or parameter
79+ group already referencing them stays valid.
80+ """
81+ qat_linear = cls(
82+ linear.in_features,
83+ linear.out_features,
84+ bias=linear.bias is not None,
85+ device="meta",
86+ dtype=linear.weight.dtype,
87+ config=config,
88+ )
89+ qat_linear.weight = linear.weight
90+ if linear.bias is not None:
91+ qat_linear.bias = linear.bias
92+ return qat_linear
93+ 
94+ def forward(self, input: torch.Tensor) -> torch.Tensor:
95+ if self.input_quantizer is not None:
96+ input = self.input_quantizer(input)
97+ weight = self.weight
98+ if self.weight_quantizer is not None:
99+ weight = self.weight_quantizer(weight)
100+ return F.linear(input, weight, self.bias)
101+ 
102+ 
103+def convert_to_mxfp4_qat(
104+ module: nn.Module,
105+ config: MXFP4QATConfig | None = None,
106+ skip_names: tuple[str, ...] = (),
107+) -> nn.Module:
108+ """Recursively replace every ``nn.Linear`` in *module* with an MXFP4 QAT one.
109+ 
110+ The replacement happens in place; the returned reference is the same object,
111+ provided only for chaining. Subclasses of ``nn.Linear`` are included so
112+ framework wrappers (Megatron-style Linear subclasses, etc.) are converted
113+ rather than left in float. Already converted ``MXFP4QATLinear`` layers are
114+ skipped, so calling this twice is a no-op.
115+ 
116+ Args:
117+ module: Root module to rewrite.
118+ config: Settings applied to every replaced layer.
119+ skip_names: Substrings matched against the dotted module path. A match
120+ skips that submodule and everything below it, which is the usual way
121+ to keep sensitive layers (``"lm_head"``, ``"embed"``) in float.
122+ 
123+ Returns:
124+ The same *module*, with its linear layers replaced.
125+ """
126+ return _convert(module, config, skip_names, prefix="")
127+ 
128+ 
129+def _convert(
130+ module: nn.Module,
131+ config: MXFP4QATConfig | None,
132+ skip_names: tuple[str, ...],
133+ prefix: str,
134+) -> nn.Module:
135+ for name, child in list(module.named_children()):
136+ path = f"{prefix}{name}"
137+ if any(pattern in path for pattern in skip_names):
138+ continue
139+ if isinstance(child, MXFP4QATLinear):
140+ continue
141+ if isinstance(child, nn.Linear):
142+ setattr(module, name, MXFP4QATLinear.from_linear(child, config))
143+ else:
144+ _convert(child, config, skip_names, prefix=f"{path}.")
145+ return module