Skip to content

Commit 80639d4

Browse files
committed
Add NVFP4 quantization-aware distillation (QAD)
Summary: QAD recovers the accuracy lost when a model is quantized to NVFP4 by distilling from the original bf16 model into an NVFP4 fake-quantized copy. The student's MoE experts and dense linear layers are fake-quantized with a straight-through estimator during training; a frozen bf16 teacher supervises via KL divergence (plus an optional hard-label cross-entropy term). Adds: - NVFP4 fake-quant primitives: torchtitan/components/quantization/ nvfp4_fake_quant.py -- self-contained two-level block-scale E2M1 fake-quant (STE), plus a single entry point apply_nvfp4_fake_quant(model) that quantizes both the MoE experts and the dense linears (excluding lm_head/router/gate). - KD/QAD trainer + configs: torchtitan/experiments/kd/ -- KDTrainer (frozen bf16 teacher + fake-quant student), kd_loss (KL + CE), and qwen3 QAD configs (debug + 30B-A3B). - MoE fake-quant hook: GroupedExperts.fake_quant_fn in models/common/moe.py, applied to expert weights and activations inside _experts_forward. - Post-init hook: post_model_init_fn in trainer.py, used to apply the fake-quant to the student after model init/parallelization. - Single-file HF checkpoint support: protocols/state_dict_adapter.py. The NVFP4 fake-quant primitives in nvfp4_fake_quant.py are intentionally self-contained (no torchao dependency), but could be upstreamed to torchao if a shared home is preferred. Test Plan: Distill a qwen3-30B-A3B checkpoint (student + bf16 teacher both init from the same HF checkpoint): MODULE=kd.qwen3 CONFIG=qwen3_30b_a3b_qad NGPU=8 ./run_train.sh \ --training.steps 200 \ --checkpoint.interval 50 \ --checkpoint.initial_load_path /path/to/qwen3-30b-a3b-hf \ --hf_assets_path /path/to/qwen3-30b-a3b-hf Quick self-contained smoke test (tiny debug model, no external checkpoint): MODULE=kd.qwen3 CONFIG=qwen3_moe_debug_qad NGPU=2 ./run_train.sh Results (MATH accuracy, bf16 -> nvfp4): qwen3-30B-A3B baseline (200-step RL ckpt): 88.6% -> 86.6% (-2.0) + QAD (800 steps): nvfp4 88.6% (+2.0) -> 100% of the gap recovered gpt-oss-20B baseline (100-step RL ckpt): 62.6% -> 50.8% (-11.8) + QAD (1400 steps): nvfp4 55.2% (+4.4) -> 37% of the gap recovered QAD closes the full NVFP4 gap on qwen3-30B-A3B and recovers a third of the (much larger) gap on gpt-oss-20B.
1 parent 7cdf0aa commit 80639d4

10 files changed

Lines changed: 608 additions & 7 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""NVFP4 fake-quantization primitives.
8+
9+
Note: these primitives are self-contained, can be moved to torchao if needed.
10+
11+
A minimal, self-contained implementation of NVFP4 fake quantization applied to a
12+
model's MoE experts and dense linear layers during training (e.g. for
13+
quantization-aware distillation or QAT), using a straight-through estimator.
14+
15+
NVFP4 numerics (two-level block-scale quantization):
16+
17+
* **Global scale (per tensor):** ``(448 * 6) / amax(x)`` -- 448 is the max
18+
FP8-E4M3 value, 6 is the max FP4-E2M1 value.
19+
* **Block scale (per 16 elements):** each block gets its own FP8-E4M3 scale
20+
``block_amax / 6``, so precision adapts to local magnitude.
21+
22+
Every value is rounded to the nearest E2M1 grid point
23+
(0, 0.5, 1, 1.5, 2, 3, 4, 6) then dequantized. Training uses a straight-through
24+
estimator (STE): forward returns the dequantized value, backward is identity.
25+
26+
The single public entry point is :func:`apply_nvfp4_fake_quant`, which
27+
fake-quantizes BOTH the MoE experts and the dense linear layers (weight + input
28+
activation) -- the full set of layers an NVFP4 eval quantizes (everything
29+
except ``lm_head`` / ``router`` / ``gate``).
30+
"""
31+
32+
import logging
33+
34+
import torch
35+
import torch.nn as nn
36+
import torch.nn.functional as F
37+
38+
logger = logging.getLogger(__name__)
39+
40+
41+
# Representable positive E2M1 values and the round-to-nearest midpoint
42+
# boundaries between consecutive values.
43+
_E2M1_VALUES = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
44+
_E2M1_BOUNDARIES = [0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0]
45+
_SF_BLOCK_SIZE = 16
46+
47+
# Module-name substrings the NVFP4 eval does NOT quantize, so QAD skips them too
48+
# to match the eval scope exactly: the LM head and the MoE router gate.
49+
_LINEAR_FQ_EXCLUDE = ("lm_head", "router", "gate")
50+
51+
52+
def _nvfp4_quant_dequant(x: torch.Tensor) -> torch.Tensor:
53+
"""NVFP4 E2M1 block-scale quantize-dequantize roundtrip.
54+
55+
For 3D tensors (expert weights) each expert slice is quantized independently
56+
to avoid OOM from materializing the full tensor in float32. Returns the
57+
dequantized tensor in the same dtype as *x*.
58+
"""
59+
if x.ndim == 3:
60+
out = torch.empty_like(x)
61+
for i in range(x.shape[0]):
62+
out[i] = _nvfp4_quant_dequant(x[i])
63+
return out
64+
65+
device = x.device
66+
orig_dtype = x.dtype
67+
68+
# Level 1: global scale factor maps tensor range into FP8*FP4 range
69+
gsf = (448 * 6) / x.float().abs().nan_to_num().max()
70+
x = x.float() * gsf
71+
72+
# Level 2: per-block FP8 scale factor adapts to local magnitudes
73+
orig_shape = x.shape
74+
x_flat = x.reshape(-1, _SF_BLOCK_SIZE)
75+
block_amax = x_flat.abs().amax(dim=-1, keepdim=True)
76+
block_sf = (block_amax / 6.0).to(torch.float8_e4m3fn).float()
77+
78+
# Round to nearest E2M1 value
79+
x_norm = x_flat / block_sf.clamp(min=torch.finfo(torch.float8_e4m3fn).tiny)
80+
sign = x_norm.sign()
81+
x_abs = x_norm.abs()
82+
boundaries = torch.tensor(_E2M1_BOUNDARIES, device=device)
83+
values = torch.tensor(_E2M1_VALUES, device=device)
84+
indices = torch.bucketize(x_abs, boundaries)
85+
x_quant = sign * values[indices]
86+
87+
# Dequantize: reverse block scale, reshape, reverse global scale
88+
x_dq = (x_quant * block_sf).reshape(orig_shape) / gsf
89+
return x_dq.to(orig_dtype)
90+
91+
92+
def _nvfp4_fake_quantize_ste(x: torch.Tensor) -> torch.Tensor:
93+
"""NVFP4 block-scale fake quantize with STE (straight-through estimator).
94+
95+
Forward returns the dequantized value; backward treats quantization as
96+
identity. Used for the MoE ``fake_quant_fn`` hook and for linear weights.
97+
"""
98+
dq = _nvfp4_quant_dequant(x.detach())
99+
return x + (dq - x).detach()
100+
101+
102+
def _nvfp4_fake_quantize_act_ste(x: torch.Tensor) -> torch.Tensor:
103+
"""STE NVFP4 fake-quant for a linear layer's INPUT activation of any rank.
104+
105+
``_nvfp4_quant_dequant`` treats a 3D tensor as independent per-expert slices
106+
(looping dim 0), which is wrong for an activation shaped
107+
``(batch, seq, features)``. Flatten to 2D ``(tokens, features)`` first so the
108+
per-tensor global scale spans all tokens and the FP8 block scales tile along
109+
the feature dim -- matching how the eval quantizes a linear's input -- then
110+
restore the original shape. STE preserved through the reshape.
111+
"""
112+
if x.ndim <= 2:
113+
return _nvfp4_fake_quantize_ste(x)
114+
orig_shape = x.shape
115+
flat = x.reshape(-1, orig_shape[-1])
116+
return _nvfp4_fake_quantize_ste(flat).reshape(orig_shape)
117+
118+
119+
def _wrap_linear_nvfp4_fake_quant(module: nn.Linear) -> None:
120+
"""Override a linear's forward so its WEIGHT and INPUT ACTIVATION are both
121+
NVFP4 fake-quantized (STE) before the matmul, matching the NVFP4 eval.
122+
Instance-level forward override (eager only)."""
123+
124+
def fq_forward(x: torch.Tensor) -> torch.Tensor:
125+
w = _nvfp4_fake_quantize_ste(module.weight)
126+
x = _nvfp4_fake_quantize_act_ste(x)
127+
return F.linear(x, w, module.bias)
128+
129+
module.forward = fq_forward
130+
module._nvfp4_fake_quant_linear = True
131+
132+
133+
def apply_nvfp4_fake_quant(model: nn.Module) -> nn.Module:
134+
"""Enable NVFP4 fake quantization on BOTH the MoE experts AND the dense
135+
linear layers -- the full set of layers quantized by the NVFP4 eval.
136+
137+
* **MoE experts:** sets ``fake_quant_fn`` on every ``GroupedExperts`` module
138+
(identified by having both ``num_experts`` and ``fake_quant_fn``), which
139+
fake-quantizes the expert weights and activations inside
140+
``_experts_forward`` (FSDP2/DTensor-compatible).
141+
* **Dense linears:** overrides the forward of every ``nn.Linear`` whose name
142+
does not match :data:`_LINEAR_FQ_EXCLUDE` (``lm_head``/``router``/``gate``)
143+
so its weight and input activation are fake-quantized.
144+
145+
Applied in-place; the same model is returned. Eager-only (linear forwards are
146+
overridden at the instance level).
147+
"""
148+
# Apply fake quant to the MoE experts (via the GroupedExperts hook).
149+
n_moe = 0
150+
for module in model.modules():
151+
if hasattr(module, "num_experts") and hasattr(module, "fake_quant_fn"):
152+
module.fake_quant_fn = _nvfp4_fake_quantize_ste
153+
n_moe += 1
154+
155+
# Apply fake quant to the dense linear layers (excl lm_head/router/gate).
156+
n_lin = 0
157+
for name, module in model.named_modules():
158+
if isinstance(module, nn.Linear) and not any(
159+
excl in name for excl in _LINEAR_FQ_EXCLUDE
160+
):
161+
_wrap_linear_nvfp4_fake_quant(module)
162+
n_lin += 1
163+
164+
logger.info(
165+
"Applied NVFP4 fake quant to %d GroupedExperts modules and %d linear "
166+
"layers (excluded lm_head/router/gate)",
167+
n_moe,
168+
n_lin,
169+
)
170+
return model

torchtitan/experiments/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"autoparallel.llama3",
1414
"autoparallel.local_map_deepseek_v3",
1515
"torchft.llama3",
16+
"kd.qwen3",
1617
"rl",
1718
# RL examples own a per-example config_registry under rl/examples/<name>;
1819
# listed here so `--module <name>` resolves (see ConfigManager).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.

torchtitan/experiments/kd/loss.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Knowledge distillation loss combining KL divergence with cross-entropy."""
8+
9+
import torch
10+
import torch.nn.functional as F
11+
12+
from torchtitan.components.loss import cross_entropy_loss, IGNORE_INDEX
13+
14+
15+
def kd_loss(
16+
student_logits: torch.Tensor,
17+
teacher_logits: torch.Tensor,
18+
labels: torch.Tensor,
19+
temperature: float,
20+
alpha: float,
21+
) -> torch.Tensor:
22+
"""Compute knowledge distillation loss.
23+
24+
Args:
25+
student_logits: [B, L, V] student model output logits.
26+
teacher_logits: [B, L, V] teacher model output logits (detached).
27+
labels: [B, L] target token ids.
28+
temperature: Softmax temperature for soft targets.
29+
alpha: Weight for distillation loss vs hard-label loss.
30+
1.0 = pure distillation, 0.0 = pure cross-entropy.
31+
32+
Returns:
33+
Scalar loss (sum reduction, to be divided by global_valid_tokens).
34+
"""
35+
# Mask for valid (non-padding) tokens
36+
valid_mask = labels != IGNORE_INDEX # [B, L]
37+
flat_mask = valid_mask.flatten() # [B*L]
38+
39+
# Hard-label cross-entropy loss (sum reduction)
40+
ce = cross_entropy_loss(student_logits, labels)
41+
42+
# Soft-target KL divergence loss
43+
flat_student = student_logits.flatten(0, 1).float() # [B*L, V]
44+
flat_teacher = teacher_logits.flatten(0, 1).float() # [B*L, V]
45+
46+
# Only compute KL on valid tokens
47+
flat_student = flat_student[flat_mask]
48+
flat_teacher = flat_teacher[flat_mask]
49+
50+
student_log_probs = F.log_softmax(flat_student / temperature, dim=-1)
51+
teacher_probs = F.softmax(flat_teacher / temperature, dim=-1)
52+
53+
# KL(teacher || student) with sum reduction, scaled by T^2
54+
kl = F.kl_div(student_log_probs, teacher_probs, reduction="sum") * (temperature**2)
55+
56+
return alpha * kl + (1 - alpha) * ce
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Config registry for knowledge distillation (KD) / quantization-aware
8+
distillation (QAD) experiments with Qwen3 models.
9+
10+
QAD distills from a bf16 teacher into an NVFP4 fake-quantized student. The
11+
student's MoE experts and dense linears are fake-quantized via
12+
:func:`~torchtitan.components.quantization.nvfp4_fake_quant.apply_nvfp4_fake_quant`
13+
(wired as the ``post_model_init_fn``); the teacher stays bf16.
14+
"""
15+
16+
from torchtitan.components.checkpoint import CheckpointManager
17+
from torchtitan.components.loss import CrossEntropyLoss
18+
from torchtitan.components.lr_scheduler import LRSchedulersContainer
19+
from torchtitan.components.metrics import MetricsProcessor
20+
from torchtitan.components.optimizer import default_adamw
21+
from torchtitan.components.quantization.nvfp4_fake_quant import (
22+
apply_nvfp4_fake_quant,
23+
)
24+
from torchtitan.config import (
25+
ParallelismConfig,
26+
TrainingConfig,
27+
)
28+
from torchtitan.distributed.activation_checkpoint import SelectiveAC
29+
from torchtitan.experiments.kd.trainer import KDTrainer
30+
from torchtitan.hf_datasets.text_datasets import HuggingFaceTextDataLoader
31+
from torchtitan.models.qwen3 import model_registry
32+
33+
34+
def qwen3_moe_debug() -> KDTrainer.Config:
35+
"""Debug KD config with a tiny Qwen3 MoE model (no fake quant)."""
36+
return KDTrainer.Config(
37+
loss=CrossEntropyLoss.Config(),
38+
hf_assets_path="./tests/assets/tokenizer",
39+
model_spec=model_registry("debugmodel_moe"),
40+
optimizer=default_adamw(lr=3e-4),
41+
lr_scheduler=LRSchedulersContainer.Config(warmup_steps=2),
42+
training=TrainingConfig(
43+
local_batch_size=4,
44+
seq_len=4096,
45+
steps=10,
46+
),
47+
dataloader=HuggingFaceTextDataLoader.Config(
48+
dataset="c4_test",
49+
),
50+
metrics=MetricsProcessor.Config(log_freq=1),
51+
checkpoint=CheckpointManager.Config(
52+
interval=10,
53+
last_save_model_only=False,
54+
),
55+
activation_checkpoint=SelectiveAC.Config(),
56+
parallelism=ParallelismConfig(
57+
expert_parallel_degree=1,
58+
),
59+
# KD-specific settings
60+
temperature=2.0,
61+
alpha=0.5,
62+
)
63+
64+
65+
def qwen3_moe_debug_qad() -> KDTrainer.Config:
66+
"""Debug QAD (quantization-aware distillation) config with a tiny Qwen3 MoE
67+
model.
68+
69+
Same as :func:`qwen3_moe_debug` but with NVFP4 fake quantization applied to
70+
the student's MoE experts AND dense linears (w4a4). The teacher stays bf16.
71+
Self-contained (tiny model, ``c4_test``, no external checkpoint) -- the
72+
quickest way to exercise the full QAD path end to end.
73+
"""
74+
config = qwen3_moe_debug()
75+
config.post_model_init_fn = apply_nvfp4_fake_quant
76+
return config
77+
78+
79+
def qwen3_30b_a3b_qad() -> KDTrainer.Config:
80+
"""QAD config for Qwen3-30B-A3B.
81+
82+
Distills from a bf16 teacher into an NVFP4 fake-quantized student. Both the
83+
student and the (frozen) teacher are initialized from the same HF checkpoint;
84+
the student's MoE experts and dense linears are NVFP4 fake-quantized.
85+
"""
86+
return KDTrainer.Config(
87+
loss=CrossEntropyLoss.Config(),
88+
hf_assets_path="./assets/hf/Qwen3-30B-A3B",
89+
model_spec=model_registry("30B-A3B", attn_backend="flex"),
90+
optimizer=default_adamw(lr=5e-6, betas=(0.9, 0.999), weight_decay=0.0),
91+
lr_scheduler=LRSchedulersContainer.Config(
92+
warmup_steps=100,
93+
decay_ratio=0.1,
94+
decay_type="cosine",
95+
),
96+
training=TrainingConfig(
97+
local_batch_size=2,
98+
seq_len=2048,
99+
steps=100,
100+
),
101+
dataloader=HuggingFaceTextDataLoader.Config(
102+
dataset="c4",
103+
),
104+
metrics=MetricsProcessor.Config(log_freq=10),
105+
checkpoint=CheckpointManager.Config(
106+
enable=True,
107+
initial_load_in_hf=True,
108+
last_save_in_hf=True,
109+
),
110+
activation_checkpoint=SelectiveAC.Config(),
111+
parallelism=ParallelismConfig(
112+
data_parallel_shard_degree=-1,
113+
expert_parallel_degree=1,
114+
),
115+
# KD-specific settings
116+
temperature=2.0,
117+
alpha=0.5,
118+
# QAD: fake-quantize the student (experts + linears); teacher stays bf16
119+
post_model_init_fn=apply_nvfp4_fake_quant,
120+
)

0 commit comments

Comments
 (0)