Skip to content

Commit 540b2ce

Browse files
committed
feat(qwen4_exp): load block-fp8 dense projections natively
1 parent fb7f732 commit 540b2ce

5 files changed

Lines changed: 429 additions & 162 deletions

File tree

python/freetoken/models/qwen4_exp/weight.py

Lines changed: 115 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
Three separate paths, because the checkpoint's three weight classes live in different places:
44
5-
* :func:`iter_weights` -- every dense (non-expert) tensor, with the ``model.language_model.`` prefix stripped and fused where the model expects one buffer. See ``_FUSIONS``.
5+
* :func:`iter_weights` -- every dense (non-expert) tensor, with the ``model.language_model.`` prefix stripped and fused where the model expects one buffer. See ``_DenseFuser``.
66
* :func:`load_ple_table` -- the 47.7 GiB FP8 n-gram table, 128 checkpoint shards concatenated into one pinned :class:`HostBank`.
77
* :func:`nvfp4_expert_spec` -- how the routed NVFP4 experts are named, for the offload cache's expert reader.
88
@@ -25,8 +25,10 @@
2525
from freetoken.models.nvfp4_banks import (
2626
Nvfp4ExpertSourceSpec,
2727
)
28+
from freetoken.layers.quantization import get_quant_config
29+
from freetoken.models.register import get_model_spec
2830
from freetoken.moe.host_banks import HostBank, read_range_into
29-
from freetoken.utils import download_hf_weight
31+
from freetoken.utils import cached_load_hf_config, download_hf_weight
3032
from freetoken.utils.progress import byte_bar
3133
from tqdm import tqdm
3234

@@ -70,32 +72,13 @@
7072
".self_attn.indexer.k_layernorm.weight",
7173
)
7274

73-
# Fused projections: concat the checkpoint parts along dim 0 in this exact order. A nonzero pad
74-
# rounds the merged row count up; the model splits the result back with the same sizes.
75-
_FUSIONS: dict[str, tuple[tuple[str, ...], int]] = {
76-
# q carries the output gate, so its half is twice the attention width: [2*qo | kv | kv].
77-
".self_attn.qkv_proj.weight": ((
78-
".self_attn.q_proj.weight", ".self_attn.k_proj.weight", ".self_attn.v_proj.weight",
79-
), 0),
80-
".linear_attn.in_proj.weight": ((
81-
".linear_attn.in_proj_qkv.weight", ".linear_attn.in_proj_z.weight",
82-
".linear_attn.in_proj_b.weight", ".linear_attn.in_proj_a.weight",
83-
), 0),
84-
".mlp.shared_expert.gate_up_proj.weight": ((
85-
".mlp.shared_expert.gate_proj.weight", ".mlp.shared_expert.up_proj.weight",
86-
), 0),
87-
# HC mix reads the low-rank down projection and the injection logits from one GEMM; vLLM
88-
# pads the merged output to a multiple of 16 rows for cuBLAS (hyperconnection.py pad_size).
89-
# The top-level hyper_connection_mixer has no injection and so never fuses.
90-
".attn_hyper_connection.input_mix_weight_down_block_inject.weight": ((
91-
".attn_hyper_connection.input_mix_weight_down.weight",
92-
".attn_hyper_connection.block_inject_weight.weight",
93-
), 16),
94-
".mlp_hyper_connection.input_mix_weight_down_block_inject.weight": ((
95-
".mlp_hyper_connection.input_mix_weight_down.weight",
96-
".mlp_hyper_connection.block_inject_weight.weight",
97-
), 16),
98-
}
75+
# The per-layer HC mix reads the low-rank down projection and the injection logits from one GEMM; vLLM pads the merged rows to a multiple of 16 for cuBLAS (hyperconnection.py pad_size).
76+
# The top-level hyper_connection_mixer has no injection and never fuses.
77+
_PAD_TO = {"input_mix_weight_down_block_inject": 16}
78+
_HC_WITH_INJECT = (".attn_hyper_connection", ".mlp_hyper_connection")
79+
_KIND_SUFFIXES = (".weight_scale_inv", ".weight")
80+
_FP8_DTYPES = (torch.float8_e4m3fn, torch.float8_e5m2)
81+
_ELEM_DTYPES = {"e4m3": torch.float8_e4m3fn}
9982

10083

10184
def _rename(raw_name: str) -> str | None:
@@ -115,26 +98,95 @@ def _rename(raw_name: str) -> str | None:
11598
return raw_name
11699

117100

118-
def _try_fuse(
119-
name: str, tensor: torch.Tensor, buf: dict[str, dict[int, torch.Tensor]]
120-
) -> tuple[str, torch.Tensor] | tuple[()] | None:
121-
"""Buffer a fusion part; return the merged ``(name, tensor)`` once all parts arrive, ``()`` while incomplete, ``None`` if ``name`` is not a fusion part."""
122-
for fused_suffix, (parts, pad_to) in _FUSIONS.items():
123-
for idx, part in enumerate(parts):
124-
if not name.endswith(part):
125-
continue
126-
key = name[: -len(part)] + fused_suffix
127-
slots = buf.setdefault(key, {})
128-
slots[idx] = tensor
129-
if len(slots) < len(parts):
130-
return ()
131-
del buf[key]
132-
rows = [slots[i] for i in range(len(parts))]
133-
pad = (-sum(t.shape[0] for t in rows)) % pad_to if pad_to else 0
134-
if pad:
135-
rows.append(torch.zeros(pad, *rows[0].shape[1:], dtype=rows[0].dtype, device=rows[0].device))
136-
return key, torch.cat(rows, dim=0)
137-
return None
101+
def _split_kind(name: str) -> tuple[str, str]:
102+
"""``name`` -> ``(module, kind)``; kind is "" for tensors that are neither a weight nor a block scale."""
103+
for suffix in _KIND_SUFFIXES:
104+
if name.endswith(suffix):
105+
return name[: -len(suffix)], suffix
106+
return name, ""
107+
108+
109+
class _DenseFuser:
110+
"""Concatenates checkpoint projection parts into the model's merged buffers, per kind (weight / block scale).
111+
112+
The part table is the family's packed_modules_mapping. The QuantConfig picks the GDN in_proj layout and validates each part against the scheme the model built its buffer from.
113+
"""
114+
115+
def __init__(self, quant, packed: tuple[tuple[str, tuple[str, ...]], ...]) -> None:
116+
self.quant = quant
117+
self.groups = {fused: parts for fused, parts in packed if fused != "experts"} # experts: bank reader
118+
self.by_part: dict[str, list[tuple[str, int]]] = {}
119+
for fused, parts in self.groups.items():
120+
for idx, part in enumerate(parts):
121+
self.by_part.setdefault(part, []).append((fused, idx))
122+
self.buf: dict[tuple[str, str], dict[int, torch.Tensor]] = {}
123+
124+
def scheme(self, module: str):
125+
return None if self.quant is None else self.quant.scheme_for(module)
126+
127+
def _target(self, parent: str, leaf: str) -> tuple[str, int] | None:
128+
candidates = self.by_part.get(leaf)
129+
if not candidates:
130+
return None
131+
if len(candidates) > 1:
132+
# GDN: quantized checkpoints split qkv|z from the bf16 b|a; same test as gdn.py
133+
split = self.scheme(f"{parent}.in_proj_qkvz") is not None
134+
keep = {"in_proj_qkvz", "in_proj_ba"} if split else {"in_proj"}
135+
candidates = [c for c in candidates if c[0] in keep]
136+
if not candidates:
137+
raise ValueError(f"{parent}.{leaf}: no merged projection for the {'split' if split else 'fused'} GDN layout")
138+
fused, idx = candidates[0]
139+
if fused in _PAD_TO and not parent.endswith(_HC_WITH_INJECT):
140+
return None
141+
return f"{parent}.{fused}", idx
142+
143+
def check(self, module: str, name: str, tensor: torch.Tensor) -> None:
144+
"""``tensor`` (checkpoint key ``name``) must match the scheme the model built ``module`` from."""
145+
scheme = self.scheme(module)
146+
if name.endswith(".weight_scale_inv"):
147+
if scheme is None or not scheme.has("weight_scale_inv"):
148+
raise ValueError(f"{name}: {module} has no block scale in the checkpoint's quant config ({scheme})")
149+
return
150+
is_fp8 = tensor.dtype in _FP8_DTYPES
151+
if scheme is None:
152+
if is_fp8:
153+
raise ValueError(f"{name} is {tensor.dtype} but the checkpoint's quant config declares {module} unquantized")
154+
return
155+
expected = _ELEM_DTYPES.get(scheme.weight.elem)
156+
if expected is not None and tensor.dtype is not expected:
157+
raise ValueError(f"{name} is {tensor.dtype} but the checkpoint's quant config declares {module} {scheme}")
158+
rows, cols = (scheme.weight.group or (1, 1))
159+
if rows > 1 and tensor.shape[0] % rows or cols > 1 and tensor.shape[1] % cols:
160+
raise ValueError(f"{name}: {tuple(tensor.shape)} is not a multiple of the {rows}x{cols} scale block of {module}")
161+
162+
def check_unfused(self, name: str, tensor: torch.Tensor) -> None:
163+
module, kind = _split_kind(name)
164+
if kind == ".weight_scale_inv" or (kind == ".weight" and tensor.dtype in _FP8_DTYPES):
165+
self.check(module, name, tensor)
166+
167+
def fuse(self, name: str, tensor: torch.Tensor) -> list[tuple[str, torch.Tensor]] | None:
168+
"""Buffer a part; return the merged ``[(name, tensor)]`` once its kind is complete, ``[]`` while incomplete, ``None`` if ``name`` is not a part."""
169+
module, kind = _split_kind(name)
170+
if not kind:
171+
return None
172+
parent, _, leaf = module.rpartition(".")
173+
hit = self._target(parent, leaf)
174+
if hit is None:
175+
return None
176+
fused, idx = hit
177+
self.check(fused, name, tensor)
178+
slots = self.buf.setdefault((fused, kind), {})
179+
slots[idx] = tensor
180+
parts = self.groups[fused.rpartition(".")[2]]
181+
if len(slots) < len(parts):
182+
return []
183+
del self.buf[(fused, kind)]
184+
rows = [slots[i] for i in range(len(parts))]
185+
pad_to = _PAD_TO.get(fused.rpartition(".")[2], 0) if kind == ".weight" else 0
186+
pad = (-sum(t.shape[0] for t in rows)) % pad_to if pad_to else 0
187+
if pad:
188+
rows.append(torch.zeros(pad, *rows[0].shape[1:], dtype=rows[0].dtype, device=rows[0].device))
189+
return [(fused + kind, torch.cat(rows, dim=0))]
138190

139191

140192
def iter_weights(
@@ -146,24 +198,19 @@ def iter_weights(
146198
) -> Iterator[tuple[str, torch.Tensor]]:
147199
"""Yield the dense (non-expert) weights, prefix-stripped and fused to the model's buffers.
148200
149-
Keys keep the checkpoint's module names below the stripped prefix, so the emitted set is the
150-
model's state dict minus the routed experts. Nothing here is quantized: every release's skip
151-
list (modelopt ``ignore``, fp8 ``modules_to_not_convert``) covers everything except those experts,
152-
so attention, GDN, HC, PLE, the shared expert and lm_head are all plain bf16 (the n-gram hash
153-
constants stay int64). Fusions:
154-
attention q|k|v -> ``qkv_proj``, GDN ``in_proj_{qkv,z,b,a}`` -> ``in_proj``, shared-expert
155-
gate|up -> ``gate_up_proj``, and each per-layer HC's ``input_mix_weight_down`` |
156-
``block_inject_weight`` -> a zero-padded ``input_mix_weight_down_block_inject``.
157-
158-
``include_moe_experts`` is accepted for the loader contract but never yields anything: the
159-
routed experts are NVFP4 and always come from the offload cache's expert reader.
201+
Keys keep the checkpoint's module names below the stripped prefix, so the emitted set is the model's state dict minus the routed experts.
202+
A dense projection is bf16 or 128x128 block-fp8 (``.weight`` e4m3 + ``.weight_scale_inv``) as the checkpoint's QuantConfig says: the official releases skip everything but the routed experts, the community NVFP4-FP8 requants quantize the attention / GDN projections.
203+
Fusions, per kind: attention q|k|v -> ``qkv_proj``; GDN ``in_proj_{qkv,z,b,a}`` -> ``in_proj``, or ``in_proj_qkvz`` + bf16 ``in_proj_ba`` when qkv|z is quantized; shared-expert gate|up -> ``gate_up_proj``; each per-layer HC's ``input_mix_weight_down`` | ``block_inject_weight`` -> a zero-padded ``input_mix_weight_down_block_inject``.
204+
``include_moe_experts`` is accepted for the loader contract but never yields anything: the routed experts are NVFP4 and always come from the offload cache's expert reader.
160205
"""
161206
if get_tp_info().size > 1:
162207
raise NotImplementedError("qwen4_exp weight loading supports TP=1 only")
163208
if not include_non_moe:
164209
return
165210

166-
fuse_buf: dict[str, dict[int, torch.Tensor]] = {}
211+
hf_config = cached_load_hf_config(model_path)
212+
spec = get_model_spec(hf_config.architectures[0])
213+
fuser = _DenseFuser(get_quant_config(), spec.packed_modules_mapping)
167214
for file in tqdm(
168215
iter_weight_files(model_path),
169216
desc="Loading weights",
@@ -175,14 +222,14 @@ def iter_weights(
175222
if name is None:
176223
continue
177224
tensor = f.get_tensor(raw_name)
178-
fused = _try_fuse(name, tensor, fuse_buf)
179-
if fused is not None:
180-
if fused != (): # () means buffered, not yet complete
181-
yield fused
182-
continue
183-
yield name, tensor
184-
185-
assert not fuse_buf, f"Incomplete projection fusions: {sorted(fuse_buf)}"
225+
fused = fuser.fuse(name, tensor)
226+
if fused is None:
227+
fuser.check_unfused(name, tensor)
228+
yield name, tensor
229+
else:
230+
yield from fused
231+
232+
assert not fuser.buf, f"Incomplete projection fusions: {sorted(k[0] + k[1] for k in fuser.buf)}"
186233

187234

188235
# ======================================================================================

tests/models/qwen4_exp/common.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,118 @@ def spy(self, index, md, slot):
255255

256256
monkeypatch.setattr(QSASparseAttnBackend, "_select", spy)
257257
return seen
258+
259+
260+
LM = "model.language_model"
261+
262+
# quantization_config of each released checkpoint, trimmed to the entries parse_config and the reader look at
263+
264+
# RadixArk/Qwen3.8-Flash-Next-NVFP4: modelopt NVFP4 everywhere except the ignore list
265+
RADIXARK_NVFP4 = {
266+
"quant_algo": "NVFP4",
267+
"quant_method": "modelopt",
268+
"ignore": [
269+
"model.embed_tokens",
270+
"mtp.*",
271+
"model.mtp.*",
272+
"*.self_attn.*",
273+
"*.linear_attn.*",
274+
"*.mlp.gate*",
275+
"*.mlp.shared_expert.*",
276+
"*.mlp.shared_expert_gate*",
277+
"*hyper_connection*",
278+
"*.ple.*",
279+
"model.visual.*",
280+
"model.language_model.embed_tokens",
281+
"lm_head",
282+
],
283+
}
284+
285+
# nvidia/Qwen3.8-Flash-Next-NVFP4: modelopt MIXED_PRECISION, the per-module algo sits in quantized_layers
286+
NVIDIA_NVFP4 = {
287+
"quant_algo": "MIXED_PRECISION",
288+
"quant_method": "modelopt",
289+
"quantized_layers": {
290+
**{f"model.language_model.layers.{i}.mlp.experts": {"quant_algo": "NVFP4", "group_size": 16} for i in range(48)},
291+
"model.language_model.layers.1.ple.ple_embedding.ngram_embedding": {"quant_algo": "FP8"},
292+
"mtp.layers.0.mlp.experts": {"quant_algo": "FP8_PB_WO", "group_size": 128},
293+
},
294+
"ignore": ["lm_head", "model.language_model.embed_tokens", "model.language_model.layers.0.mlp.shared_expert*", "model.visual*"],
295+
}
296+
297+
# Qwen/Qwen3.8-Flash-Next-FP8: 128x128 block-fp8 experts, everything else listed in modules_to_not_convert
298+
QWEN_FP8 = {
299+
"quant_method": "fp8",
300+
"activation_scheme": "dynamic",
301+
"weight_per_tensor": False,
302+
"act_per_tensor": False,
303+
"weight_block_size": [128, 128],
304+
"modules_to_not_convert": [
305+
"lm_head",
306+
"model.language_model.embed_tokens",
307+
"model.language_model.hyper_connection_mixer.input_mix_weight_up",
308+
"model.language_model.layers.0.linear_attn.in_proj_qkv",
309+
"model.language_model.layers.3.self_attn.q_proj",
310+
"model.language_model.layers.3.mlp.gate",
311+
"model.language_model.layers.3.mlp.shared_expert.gate_proj",
312+
],
313+
"modules_to_convert": ["ple.ple_embedding.ngram_embedding"],
314+
}
315+
316+
317+
def mixed_precision_quant(gdn_layers, attn_layers, moe_layers) -> dict:
318+
"""modelopt MIXED_PRECISION with NVFP4 routed experts and FP8_PB_WO attention / GDN projections, ignore list as in lovedheart/Qwen3.8-Flash-Next-NVFP4-FP8."""
319+
return {
320+
"quant_method": "modelopt",
321+
"quant_algo": "MIXED_PRECISION",
322+
"quantized_layers": {
323+
**{f"{LM}.layers.{i}.mlp.experts": {"quant_algo": "NVFP4", "group_size": 16} for i in moe_layers},
324+
**{f"{LM}.layers.{i}.linear_attn.{p}": {"quant_algo": "FP8_PB_WO", "group_size": 128}
325+
for i in gdn_layers for p in ("in_proj_qkv", "in_proj_z", "out_proj")},
326+
**{f"{LM}.layers.{i}.self_attn.{p}_proj": {"quant_algo": "FP8_PB_WO", "group_size": 128}
327+
for i in attn_layers for p in "qkvo"},
328+
},
329+
"ignore": [
330+
"model.embed_tokens", "mtp.*", "model.mtp.*", "*.mlp.gate*", "*.mlp.shared_expert.*",
331+
"*.mlp.shared_expert_gate*", "*hyper_connection*", "*.ple.*", "model.visual.*",
332+
"model.language_model.embed_tokens", "lm_head", "*.self_attn.indexer*",
333+
],
334+
}
335+
336+
337+
# lovedheart/Qwen3.8-Flash-Next-NVFP4-FP8, trimmed to layers 0 (GDN) and 3 (attention)
338+
LOVEDHEART_NVFP4_FP8 = mixed_precision_quant(gdn_layers=(0,), attn_layers=(3,), moe_layers=(0, 3))
339+
340+
341+
def install_quant_config(model_path: str) -> None:
342+
"""Install ``model_path``'s QuantConfig process-wide, as EngineConfig does before the reader runs."""
343+
from freetoken.layers.quantization import set_quant_config
344+
from freetoken.models.register import checkpoint_quant_config, get_model_spec
345+
from freetoken.utils import cached_load_hf_config
346+
347+
hf = cached_load_hf_config(model_path)
348+
set_quant_config(checkpoint_quant_config(model_path, hf, get_model_spec(hf.architectures[0])))
349+
350+
351+
def meta_state_dict(model_path: str) -> dict[str, torch.Tensor]:
352+
"""State dict of the model the engine builds for ``model_path`` (experts offloaded), on the meta device."""
353+
from freetoken.engine.config import EngineConfig
354+
from freetoken.engine.engine import _decode_target
355+
from freetoken.layers import rotary
356+
from freetoken.models import create_model
357+
from freetoken.utils.torch_utils import torch_dtype
358+
359+
if try_get_tp_info() is None:
360+
set_tp_info(rank=0, size=1)
361+
config = EngineConfig(model_path=model_path, tp_info=try_get_tp_info(), dtype=torch.bfloat16, moe_strategy="offload")
362+
object.__setattr__(config.model_config, "moe_strategy", "offload")
363+
object.__setattr__(config.model_config, "decode_target", _decode_target(config))
364+
saved = rotary._ROPE_DEVICE
365+
rotary.set_rope_device(torch.device("cpu")) # get_rope refuses to build on meta
366+
rotary.get_rope.cache_clear()
367+
try:
368+
with torch.device("meta"), torch_dtype(torch.bfloat16):
369+
return create_model(config.model_config).state_dict()
370+
finally:
371+
rotary.set_rope_device(saved)
372+
rotary.get_rope.cache_clear() # the cpu rope must not leak into the GPU tests' cache

0 commit comments

Comments
 (0)