mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-02 03:14:52 +08:00
### What does this PR do? Type of change: new example Adds `examples/speculative_decoding/scripts/quantize_drafter.py`, a CLI that quantizes an exported speculative-decoding drafter to FP8 or NVFP4 — weight-only or weight+activation — with no calibration data. It needs no modeling code either. Exported drafters such as [`nvidia/MiniMax-M3-DSpark`](https://huggingface.co/nvidia/MiniMax-M3-DSpark) have no importable model class, so each 2-D weight is wrapped in a throwaway `nn.Linear` under its checkpoint key and ModelOpt's usual `quantizer_name` patterns select over those names. Works for any drafter layout (DSpark / DFlash / EAGLE3 / Medusa). **Formats:** `w4a16_nvfp4`, `nvfp4`, `fp8`, `fp8_pc_pt` — the ModelOpt formats vLLM's backend can actually serve. AWQ is deliberately not offered, since `awq_lite` silently degrades to plain RTN without a `forward_loop`. **Static activation scales without calibration.** `fp8` and `nvfp4` normally need an activation amax *measured* on calibration data; a fixed `input_scale` of 1.0 is applied instead. That works because acceptance length is governed almost entirely by **clipping**, not resolution: Sweeping the fixed scale over three decades (same setup as the Testing section below; bf16 baseline 3.1423): | `input_scale` | amax | FP8 AL | vs bf16 | NVFP4 AL | vs bf16 | |---|---|---|---|---|---| | 0.003 | 1.3 | 2.2204 | -29.34% | 2.2076 | -29.75% | | 0.01 | 4.5 | 2.6719 | -14.97% | 2.6641 | -15.22% | | 0.03 | 13.4 | 2.9751 | -5.32% | 2.9259 | -6.89% | | 0.1 | 44.8 | 3.1013 | -1.31% | 3.0206 | -3.88% | | 0.2 | 89.6 | 3.1178 | -0.78% | 3.0015 | -4.48% | | 0.3 | 134.4 | 3.1370 | -0.17% | 3.0222 | -3.82% | | 0.5 | 224.0 | 3.1268 | -0.50% | 3.0360 | -3.38% | | **1.0 (default)** | **448.0** | **3.1457** | **+0.11%** | **3.0193** | **-3.91%** | | 2.0 | 896.0 | 3.1354 | -0.22% | 3.0172 | -3.98% | | 4.0 | 1792.0 | 3.1245 | -0.57% | 3.0034 | -4.42% | Both formats fall off a cliff below ~0.03, where the declared range sits far under the activations' true magnitude and most of the tensor is clipped. Both then sit on a flat plateau from ~0.3 to 4.0 **with no drop-off at the top**, so the scale only has to be big enough. 1.0 is the middle of that plateau, which is why it is hardcoded rather than exposed. NVFP4 trails FP8 by a roughly constant 3.5% across the plateau — that gap is the 4-bit resolution cost, and no choice of scale recovers it. Deriving the amax from the weights instead was tried and does not work: `max|W|` averages 0.79 while a RMSNorm'd activation is O(1) with outlier channels in the tens, so the range lands 1–2 orders of magnitude low and clips, measuring -31% to -46% AL. **Where calibration would go.** All of this sits behind `resolve_activation_scales()`, the single place deciding where a static amax comes from. Real calibration slots in ahead of the fixed fallback with no change to the CLI or the call site, and composes because `set_static_activation_amax()` skips quantizers that already have an amax: ```python if calib_forward_loop is not None: mtq.calibrate(root, quant_cfg["algorithm"], forward_loop=calib_forward_loop) set_static_activation_amax(root) # fills in what calibration did not reach ``` **Serving a quantized drafter.** Four things had to be written into the exported checkpoint before vLLM would load one: - emit `quant_method` (`modelopt_fp4` / `modelopt`) — vLLM reads that key, ModelOpt writes only `quant_algo` - emit the exclusion list under `ignore` too — that is the key read from the flat `quantization_config`; `exclude_modules` alone yields an empty exclusion set - add `*<name>` wildcards so exclusions match a runtime's nested module prefix (`model.fc`) rather than the checkpoint key (`fc`) - add `*qkv_proj` / `*gate_up_proj` aliases for layers a runtime fuses, whose names appear in no checkpoint key Nothing is then needed on the caller side. **This closes the open question left in the previous revision of this PR: vLLM does read `quantization_config` off the draft checkpoint.** `ModelConfig._verify_quantization` fills `quantization` in from `quant_method` when it is unset, so once the export declares that key — the first fix above — detection works on its own. Verified on Nemotron-3.5-Lightning passing nothing: `Detected ModelOpt NVFP4 checkpoint (quant_algo=NVFP4)` → `FlashInferCuteDslNvFp4LinearKernel`, AL 4.278 against 4.203 measured earlier. `specdec_bench` also gains a `DSPARK` algorithm, which it did not have: an exported `Qwen3DSparkModel` would otherwise have to go through `DFLASH` and be built with vLLM `method="dflash"`. The branch sets `method="dspark"` and leaves `draft_sample_method` on vLLM's own default of `greedy`. A target whose fused-collective workspace (sized at CUDA-graph capture) overflows at large speculative batches can disable graphs with `--runtime_params '{"engine_args": {"enforce_eager": true}}'`. For DFlash-family drafters, `qwen3_dflash.py` builds its fused context-KV projection by reading `qkv_proj.weight` raw and calling `F.linear`, which cannot consume a packed weight. Keep those layers in bf16 with `--exclude '*q_proj*' '*k_proj*' '*v_proj*' '*qkv_proj*'`; `o_proj` and the MLP — the bulk of the drafter — still quantize. That exclusion is mandatory, not a tuning choice. `fc` (the projection from the target's captured layers into the draft) is the one real knob, and it is a genuine trade rather than a free win — see the Testing section for both models' numbers. The examples quantize it; add `'*fc*'` to the exclude list to keep it in bf16. `embed_tokens`, `markov_head` and `confidence_head` are excluded by default: they are 2-D so the flat view treats them as GEMMs, but they are embeddings or a single-output projection. `lm_head` is excluded by the preset itself — unlike on a base model it is 37% of this drafter's parameters, so `--quantize_lm_head` is a real lever (~1.9 GiB), but measure AL first. The flag re-enables both of `lm_head`'s quantizers; re-enabling only the weight one would ship a W+A checkpoint whose `lm_head` has no `input_scale` while the config still advertises it as quantized. ### Usage ```bash # weight+activation FP8, calibration-free, lossless on both models measured below python scripts/quantize_drafter.py \ --drafter_path deepseek-ai/dspark_qwen3_8b_block7 \ --qformat fp8 \ --export_path ./dspark-qwen3-8b-fp8 \ --exclude '*q_proj*' '*k_proj*' '*v_proj*' '*qkv_proj*' # smallest: weight-only NVFP4 python scripts/quantize_drafter.py \ --drafter_path nvidia/MiniMax-M3-DSpark \ --qformat w4a16_nvfp4 \ --export_path ./MiniMax-M3-DSpark-W4A16 ``` Or end to end on Slurm — quantize, then measure AL — via the launcher examples added here, one per target: ```bash uv run launch.py --yaml examples/Qwen/Qwen3-8B/hf_dspark_ptq_nvfp4.yaml --yes uv run launch.py --yaml examples/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16/hf_dspark_ptq_nvfp4.yaml --yes ``` Serving one, if you are not going through `specdec_bench`: ```python speculative_config = { "method": "dspark", "model": "./dspark-qwen3-8b-fp8", # quantization is read from its config.json "num_speculative_tokens": 7, } ``` ### Testing Two targets with different architectures, so the conclusions are not one model's quirk: * **Qwen3-8B** (dense transformer) + [`deepseek-ai/dspark_qwen3_8b_block7`](https://huggingface.co/deepseek-ai/dspark_qwen3_8b_block7), `block_size` 7, TP1. * **Nemotron-3.5-Lightning-30B-A3B** (hybrid Mamba-MoE) + [`nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark`](https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark), `block_size` 8, TP8, with the mamba engine settings the model card pins (`mamba_backend=flashinfer`, `mamba_ssm_cache_dtype=float16`, stochastic SSM-cache rounding). Both: MT-Bench 80 questions, greedy, one vLLM instance per point. | recipe | activations | Qwen3-8B AL | vs bf16 | Nemotron-3.5 AL | vs bf16 | |---|---|---|---|---|---| | bf16 baseline | — | 3.1423 | — | 4.3296 | — | | **`fp8`** | static, `input_scale` 1.0 | **3.1457** | **+0.11%** | **4.3289** | **-0.02%** | | `fp8_pc_pt` | dynamic per-token | 3.1228 | -0.62% | 4.3411 | +0.26% | | `w4a16_nvfp4`, `fc` in bf16 | bf16 (weight-only) | 3.0392 | -3.28% | 4.2899 | -0.92% | | `w4a16_nvfp4`, `fc` quantized | bf16 (weight-only) | 3.0186 | -3.94% | 4.2334 | -2.22% | | **`nvfp4`** | static, `input_scale` 1.0 | **3.0193** | **-3.91%** | **4.2030** | **-2.92%** | **FP8 weight+activation at the fixed `input_scale` of 1.0 is lossless on both.** +0.11% and -0.02% are both inside run-to-run noise — the Nemotron baseline was measured twice under identical settings and the two runs differ by 0.94% (4.3093 / 4.3499), which sets the resolution of that column. On the same reading, `fp8` and `fp8_pc_pt` are indistinguishable on Nemotron; the dynamic variant only pulls ahead on Qwen3. NVFP4 costs 3-4% on Qwen3 and 2-3% on Nemotron, i.e. the 4-bit weight resolution is the real price and it is model-dependent but bounded. Whether to quantize `fc` is a per-model call rather than a general recommendation — it buys a few percent of size for an AL cost that differs by ~2x between these two drafters: | `fc` bf16 → quantized | Qwen3-8B | Nemotron-3.5 | |---|---|---| | checkpoint size | 3.293 → 3.181 GiB (-3.4%) | 1.316 → 1.258 GiB (-4.4%) | | AL | 3.0392 → 3.0186 (-0.68%) | 4.2899 → 4.2334 (-1.32%) | `fc` itself is only 3.5% (Qwen3) / 4.5% (Nemotron) of drafter parameters; `embed_tokens` is the bulk (26% / 36%) and is excluded by default. The Qwen3 `w4a16_nvfp4` rows were measured in a later session than the rest of that column; the `fc`-in-bf16 run reproduced the original number to four decimals (3.0392), so the column is internally comparable. Also validated on `nvidia/MiniMax-M3-DSpark`: `w4a16_nvfp4` runs in 67 s on CPU, 9.98 GiB (fp32) -> 3.51 GiB; all 43 quantized tensors round-trip within 0.0952 relative error; the 29 untouched tensors are bit-identical to `bf16(source)`. ### Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ (example-only) - If you copied code from any other sources or added a new PIP dependency, did you follow guidance in `CONTRIBUTING.md`: N/A - Did you write any new necessary tests?: ❌ — validated manually as above. Can add a `tests/examples/speculative_decoding/` test over a small synthetic drafter if wanted before merge. - Did you update [Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: N/A (example-only) - Did you get Claude approval on this PR?: ❌ (not yet run) ### Additional Information The measurements above are one drafter on one target with one benchmark; the plateau's location and the ~3.5% NVFP4 gap should be re-measured before assuming they carry to a different drafter. Note when reading an exported checkpoint: `input_scale` is `amax/448` for FP8 but `amax/(6*448)` for NVFP4, so the one fixed amax records as 1.0 in an FP8 checkpoint and 0.1667 in an NVFP4 one. Both mean the same activation range. Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
379 lines
16 KiB
Python
379 lines
16 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Calibration-free PTQ for a speculative-decoding drafter.
|
|
|
|
Every scale is derived from the weights, so this needs no dataset and no forward pass, and
|
|
never imports the drafter's modeling code: each 2-D weight is wrapped in a throwaway
|
|
``nn.Linear`` under its checkpoint name, and ModelOpt's usual ``quantizer_name`` patterns
|
|
select over those names. Works for any drafter layout (DSpark / DFlash / EAGLE3 / Medusa),
|
|
including exported ones that ship no importable model class.
|
|
|
|
``fp8`` and ``nvfp4`` need a static activation amax, normally measured on calibration data;
|
|
a fixed ``input_scale`` of 1.0 is applied instead. Acceptance length is governed by
|
|
clipping rather than resolution, and AL sits on a flat plateau from input_scale ~0.3 to 4.0
|
|
(Qwen3-8B + DSpark, MT-Bench: +0.1% for FP8, -3.9% for NVFP4), so the scale only has to be
|
|
big enough. AWQ is not offered: ``awq_lite`` silently degrades to RTN without a
|
|
``forward_loop``.
|
|
|
|
Example:
|
|
python quantize_drafter.py \
|
|
--drafter_path nvidia/MiniMax-M3-DSpark \
|
|
--qformat fp8 \
|
|
--export_path ./MiniMax-M3-DSpark-FP8
|
|
"""
|
|
|
|
import argparse
|
|
import copy
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
import modelopt.torch.quantization as mtq
|
|
from modelopt.recipe.presets import QUANT_CFG_CHOICES
|
|
from modelopt.torch.export.quant_utils import (
|
|
get_activation_scaling_factor,
|
|
get_quant_config,
|
|
get_quantization_format,
|
|
get_weight_block_size,
|
|
get_weight_scaling_factor,
|
|
get_weight_scaling_factor_2,
|
|
to_quantized_weight,
|
|
)
|
|
from modelopt.torch.quantization.config import need_calibration
|
|
from modelopt.torch.quantization.utils import is_quantized_linear
|
|
|
|
# INT8/INT4 are absent on purpose: they quantize cleanly but vLLM's ModelOpt backend
|
|
# cannot serve them.
|
|
SUPPORTED_QFORMATS = [
|
|
"w4a16_nvfp4",
|
|
"nvfp4",
|
|
"fp8",
|
|
"fp8_pc_pt",
|
|
]
|
|
|
|
# All 2-D, so the flat view treats them as GEMMs, but none is one: markov_w1/embed_tokens
|
|
# are embeddings (ModelOpt's presets skip these via `parent_class`, which the flat view
|
|
# cannot see), and confidence_head has a single output whose per-channel scale is 0-dim.
|
|
# All tiny. `fc` is left to the presets; `lm_head` is excluded by the preset itself.
|
|
DEFAULT_EXCLUDE = ["*markov_head*", "*confidence_head*", "*embed_tokens*"]
|
|
|
|
# Sidecars carried over to the export, and -- with the weights and config.json -- the only
|
|
# files fetched when --drafter_path is a repo id rather than a local directory.
|
|
SIDECAR_FILES = ("tokenizer.json", "tokenizer_config.json", "generation_config.json")
|
|
|
|
# The amax that yields input_scale 1.0 for FP8. NVFP4 divides by 6*448, so the same amax
|
|
# records as 0.1667 there; both mean the same activation range.
|
|
FP8_E4M3_MAX = 448.0
|
|
STATIC_ACT_AMAX = FP8_E4M3_MAX
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument(
|
|
"--drafter_path", required=True, help="HF repo id or local dir of the drafter checkpoint."
|
|
)
|
|
parser.add_argument("--export_path", required=True, help="Output directory.")
|
|
parser.add_argument(
|
|
"--qformat",
|
|
default="w4a16_nvfp4",
|
|
choices=SUPPORTED_QFORMATS,
|
|
help="Quantization format. All are calibration-free; fp8 and nvfp4 additionally "
|
|
"quantize activations, using a fixed input_scale of 1.0.",
|
|
)
|
|
parser.add_argument(
|
|
"--dtype",
|
|
default="bfloat16",
|
|
choices=["bfloat16", "float16", "float32"],
|
|
help="Compute dtype the weights are cast to before quantizing.",
|
|
)
|
|
parser.add_argument(
|
|
"--exclude",
|
|
nargs="*",
|
|
default=[],
|
|
metavar="PATTERN",
|
|
help="Extra fnmatch patterns to leave unquantized, in `quantizer_name` form. "
|
|
f"Appended to the defaults ({' '.join(DEFAULT_EXCLUDE)}), which always apply.",
|
|
)
|
|
parser.add_argument(
|
|
"--quantize_lm_head",
|
|
action="store_true",
|
|
help="Also quantize lm_head -- the largest drafter tensor, but it feeds the "
|
|
"acceptance test directly, so measure AL first.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def auto_map_modules(config: dict, source_dir: Path) -> set[Path]:
|
|
"""Module files an ``auto_map`` points at, relative to ``source_dir``.
|
|
|
|
Values are either ``"modeling_x.XModel"`` or, for tokenizers, a list whose entries may
|
|
be null (``[null, "tokenization_x.XTokenizerFast"]``); a ``repo--`` prefix points at
|
|
another repository and is not a local file. A value may also carry a package path
|
|
(``"pkg/modeling_x.XModel"``), which is preserved so the exported reference resolves.
|
|
|
|
The config is untrusted -- it may come straight off the Hub -- so references that
|
|
escape ``source_dir`` are dropped rather than followed.
|
|
"""
|
|
modules = set()
|
|
for value in (config.get("auto_map") or {}).values():
|
|
for ref in value if isinstance(value, list) else [value]:
|
|
if not isinstance(ref, str) or "." not in ref:
|
|
continue
|
|
relative = Path(ref.split("--")[-1].rsplit(".", 1)[0] + ".py")
|
|
candidate = (source_dir / relative).resolve()
|
|
if relative.is_absolute() or not candidate.is_relative_to(source_dir.resolve()):
|
|
print(f"Skipping auto_map entry outside the checkpoint: {ref}")
|
|
continue
|
|
modules.add(relative)
|
|
return modules
|
|
|
|
|
|
def load_drafter(drafter_path: str) -> tuple[Path, dict[str, torch.Tensor]]:
|
|
"""Resolve a local dir or HF repo id to (dir, state_dict)."""
|
|
local_dir = Path(drafter_path)
|
|
if not local_dir.is_dir():
|
|
from huggingface_hub import snapshot_download
|
|
|
|
# A drafter that ships custom modeling code references it from auto_map, and the
|
|
# export carries that config verbatim -- so those .py files have to be fetched too
|
|
# or the exported references dangle.
|
|
local_dir = Path(
|
|
snapshot_download(
|
|
drafter_path,
|
|
allow_patterns=["*.safetensors", "config.json", "*.py", *SIDECAR_FILES],
|
|
)
|
|
)
|
|
|
|
shards = sorted(local_dir.glob("*.safetensors"))
|
|
assert shards, f"No .safetensors found under {local_dir}"
|
|
state_dict: dict[str, torch.Tensor] = {}
|
|
for shard in shards:
|
|
state_dict.update(load_file(shard))
|
|
return local_dir, state_dict
|
|
|
|
|
|
def build_linear_view(state_dict: dict[str, torch.Tensor], dtype: torch.dtype) -> nn.Module:
|
|
"""Expose every 2-D weight as an nn.Linear whose module name is its checkpoint key.
|
|
|
|
Nested ModuleDicts so ``named_modules()`` reproduces the dotted checkpoint keys, which
|
|
is what ``quantizer_name`` patterns match against.
|
|
"""
|
|
root = nn.ModuleDict()
|
|
for key, weight in state_dict.items():
|
|
if weight.dim() != 2 or not key.endswith(".weight"):
|
|
continue
|
|
*parents, leaf = key[: -len(".weight")].split(".")
|
|
node = root
|
|
for part in parents:
|
|
if part not in node:
|
|
node[part] = nn.ModuleDict()
|
|
node = node[part]
|
|
out_features, in_features = weight.shape
|
|
# On meta, so nn.Linear skips allocating and randomly initializing a weight that
|
|
# the next line replaces anyway.
|
|
with torch.device("meta"):
|
|
linear = nn.Linear(in_features, out_features, bias=False, dtype=dtype)
|
|
linear.weight = nn.Parameter(weight.to(dtype), requires_grad=False)
|
|
node[leaf] = linear
|
|
return root
|
|
|
|
|
|
def set_static_activation_amax(root: nn.Module, amax: float = STATIC_ACT_AMAX) -> int:
|
|
"""Give every static ``input_quantizer`` the same fixed amax. Returns how many were set.
|
|
|
|
Skips dynamic quantizers and any that already have an amax, so it composes as a
|
|
fallback rather than an overwrite.
|
|
"""
|
|
count = 0
|
|
for _, module in root.named_modules():
|
|
if not is_quantized_linear(module):
|
|
continue
|
|
input_quantizer = getattr(module, "input_quantizer", None)
|
|
if input_quantizer is None or not input_quantizer.is_enabled:
|
|
continue
|
|
if getattr(input_quantizer, "_dynamic", False):
|
|
continue
|
|
if getattr(input_quantizer, "amax", None) is not None:
|
|
continue
|
|
# Keep amax in fp32, as ModelOpt does everywhere else -- casting to the weight
|
|
# dtype would round a measured amax through bf16's 8-bit mantissa.
|
|
input_quantizer.amax = torch.tensor(amax, dtype=torch.float32)
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def resolve_activation_scales(root: nn.Module, quant_cfg: dict) -> None:
|
|
"""Establish activation scales for a format that quantizes activations statically.
|
|
|
|
The single place deciding where a static amax comes from: real calibration would call
|
|
``mtq.calibrate`` here, ahead of the fixed fallback.
|
|
"""
|
|
if not need_calibration(quant_cfg):
|
|
return
|
|
n = set_static_activation_amax(root)
|
|
print(f"Set {n} static activation amax values (fixed, input_scale 1.0) -- not calibrated.")
|
|
|
|
|
|
def build_quant_cfg(qformat: str, exclude: list[str], quantize_lm_head: bool) -> dict:
|
|
"""Take the shipped preset and layer the drafter-specific exclusions on top."""
|
|
quant_cfg = copy.deepcopy(QUANT_CFG_CHOICES[qformat])
|
|
if quantize_lm_head:
|
|
# The preset disables *all* of lm_head's quantizers. Re-enabling only the weight
|
|
# one would leave a W+A format exporting lm_head with no input_scale while the
|
|
# config still advertises it as fully quantized, which a runtime fails to load.
|
|
for quantizer in ("weight_quantizer", "input_quantizer"):
|
|
quant_cfg["quant_cfg"].append(
|
|
{"quantizer_name": f"*lm_head*{quantizer}", "enable": True}
|
|
)
|
|
for pattern in DEFAULT_EXCLUDE + exclude:
|
|
quant_cfg["quant_cfg"].append({"quantizer_name": pattern, "enable": False})
|
|
return quant_cfg
|
|
|
|
|
|
def export_quantized_state_dict(
|
|
root: nn.Module, state_dict: dict[str, torch.Tensor], dtype: torch.dtype
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Pack each quantized weight and emit it alongside its scales.
|
|
|
|
Unified-HF naming (``w.weight_scale`` etc). Untouched tensors carry through in ``dtype``.
|
|
"""
|
|
export_sd = {k: v.to(dtype) for k, v in state_dict.items()}
|
|
for name, module in root.named_modules():
|
|
if not is_quantized_linear(module) or not module.weight_quantizer.is_enabled:
|
|
continue
|
|
quantization = get_quantization_format(module)
|
|
assert quantization is not None, f"{name}: enabled quantizer resolved to no format"
|
|
weight_scale = get_weight_scaling_factor(module)
|
|
weight_scale_2 = get_weight_scaling_factor_2(module)
|
|
# The packing helpers index the scale as ``scale[:, None]``, which a 0-dim scale
|
|
# cannot satisfy. One row of weights, so leave it in ``dtype``.
|
|
if weight_scale is not None and weight_scale.dim() == 0 and module.weight.shape[0] == 1:
|
|
print(f"Skipping {name}: single-output projection, per-channel scale is scalar")
|
|
continue
|
|
export_sd[f"{name}.weight"] = to_quantized_weight(
|
|
module.weight,
|
|
weight_scale,
|
|
quantization,
|
|
weight_scale_2,
|
|
get_weight_block_size(module),
|
|
)
|
|
export_sd[f"{name}.weight_scale"] = weight_scale
|
|
if weight_scale_2 is not None:
|
|
export_sd[f"{name}.weight_scale_2"] = weight_scale_2
|
|
# Without this the runtime has no activation scale and the format silently degrades.
|
|
activation_scale = get_activation_scaling_factor(module)
|
|
if activation_scale is not None:
|
|
export_sd[f"{name}.input_scale"] = activation_scale
|
|
return export_sd
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
dtype = getattr(torch, args.dtype)
|
|
|
|
source_dir, state_dict = load_drafter(args.drafter_path)
|
|
root = build_linear_view(state_dict, dtype)
|
|
|
|
quant_cfg = build_quant_cfg(args.qformat, args.exclude, args.quantize_lm_head)
|
|
|
|
mtq.quantize(root, quant_cfg) # no forward_loop: scales come from the weights
|
|
resolve_activation_scales(root, quant_cfg)
|
|
|
|
mtq.print_quant_summary(root)
|
|
|
|
export_sd = export_quantized_state_dict(root, state_dict, dtype)
|
|
|
|
export_dir = Path(args.export_path)
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
save_file(export_sd, export_dir / "model.safetensors", metadata={"format": "pt"})
|
|
|
|
config = json.loads((source_dir / "config.json").read_text())
|
|
hf_quant_config = get_quant_config(root)
|
|
# ``get_quant_config`` only knows the linear view, so tensors it never saw (norms, 1-D
|
|
# weights) are missing and a loader walking the checkpoint expects a scale for them.
|
|
quantized = {
|
|
name
|
|
for name, module in root.named_modules()
|
|
if is_quantized_linear(module)
|
|
and f"{name}.weight" in export_sd
|
|
and f"{name}.weight_scale" in export_sd
|
|
}
|
|
unquantized = sorted(
|
|
key[: -len(".weight")]
|
|
for key in state_dict
|
|
if key.endswith(".weight") and key[: -len(".weight")] not in quantized
|
|
)
|
|
exclude_modules = hf_quant_config["quantization"].get("exclude_modules", [])
|
|
for name in unquantized:
|
|
if name not in exclude_modules:
|
|
exclude_modules.append(name)
|
|
# Runtimes match against their own module prefix, which is nested relative to the
|
|
# checkpoint key (vLLM builds the draft's ``fc`` at ``model.fc``).
|
|
wildcard = f"*{name}"
|
|
if wildcard not in exclude_modules:
|
|
exclude_modules.append(wildcard)
|
|
# Runtimes fuse sibling projections into one layer whose name is in no checkpoint key,
|
|
# so excluding only the parts would leave the fused layer quantized.
|
|
for fused, parts in (
|
|
("qkv_proj", ("q_proj", "k_proj", "v_proj")),
|
|
("gate_up_proj", ("gate_proj", "up_proj")),
|
|
):
|
|
if all(any(p in name for name in exclude_modules) for p in parts):
|
|
alias = f"*{fused}"
|
|
if alias not in exclude_modules:
|
|
exclude_modules.append(alias)
|
|
hf_quant_config["quantization"]["exclude_modules"] = exclude_modules
|
|
config["quantization_config"] = dict(hf_quant_config["quantization"])
|
|
# ModelOpt names the format ``quant_algo``; vLLM reads ``quant_method`` and treats its
|
|
# absence as unquantized, splitting NVFP4 off into its own backend. Emit both.
|
|
quant_algo = str(hf_quant_config["quantization"].get("quant_algo") or "")
|
|
config["quantization_config"].setdefault(
|
|
"quant_method", "modelopt_fp4" if "NVFP4" in quant_algo.upper() else "modelopt"
|
|
)
|
|
# Same list, second key: the flat ``quantization_config`` in config.json is read for
|
|
# ``ignore``, not ``exclude_modules``.
|
|
config["quantization_config"]["ignore"] = list(exclude_modules)
|
|
config["torch_dtype"] = args.dtype
|
|
(export_dir / "config.json").write_text(json.dumps(config, indent=2))
|
|
(export_dir / "hf_quant_config.json").write_text(json.dumps(hf_quant_config, indent=2))
|
|
|
|
for extra in SIDECAR_FILES:
|
|
if (source_dir / extra).is_file():
|
|
shutil.copy2(source_dir / extra, export_dir / extra)
|
|
|
|
# A drafter that ships custom modeling code points at it from auto_map; the export
|
|
# carries that config verbatim, so the .py files have to come along or the reference
|
|
# dangles. (The DFlash/DSpark exports have no auto_map -- this is for the ones that do.)
|
|
for relative in auto_map_modules(config, source_dir):
|
|
source_py = source_dir / relative
|
|
if source_py.is_file():
|
|
target = export_dir / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_py, target)
|
|
|
|
before = sum(v.numel() * v.element_size() for v in state_dict.values())
|
|
after = sum(v.numel() * v.element_size() for v in export_sd.values())
|
|
print(f"\n{args.qformat}: {before / 2**30:.2f} GiB -> {after / 2**30:.2f} GiB")
|
|
print(f"Exported to {export_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|