| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- # SPDX-License-Identifier: Apache-2.0
- """FP8 weights for the dense linears a checkpoint left in bf16.
- GLM-5.3-Flash's NVFP4 checkpoint quantizes only the routed experts. About 4 GB
- per rank of dense projections (the KDA input projection, o_proj, the shared
- experts, the MLA and indexer query projections) stay bf16, and decode reads
- all of it every step. On GB10 those GEMMs run at the memory roofline, so the
- bytes are the cost: FP8 halves them.
- Weights get one scale per output channel, activations one per token, and the
- GEMM is CUTLASS's scaled_mm, which reaches ~90% of the FP8 read roofline from
- M = 1 upwards. The router gate stays bf16 (routing is sensitive), and so do
- layers whose weight other code reads directly (kv_b_proj, the indexer's
- wk_weights_proj). The lm_head converts too unless VLLM_DENSE_FP8_LM_HEAD=0.
- Layers whose names match VLLM_DENSE_W4 go to NVFP4 instead (W4A16 through
- megadense4.cu, for up to 64 tokens where _W4_PLAN says so and 32 elsewhere;
- larger batches use an FP8 copy kept alongside). Half the bytes again, at a
- quality cost that depends on the layer: the KDA in_proj alone cost about as much NLL as FP8 on
- everything, all dense layers about 2-3x that. The DFlash drafter's layers
- only change acceptance, never the output.
- """
- import os
- import re
- import torch
- from torch.nn import Parameter
- from vllm import _custom_ops as ops
- from vllm.logger import init_logger
- from vllm.model_executor.layers.linear import LinearBase, LinearMethodBase, UnquantizedLinearMethod
- from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
- logger = init_logger(__name__)
- _ROWS = 2048
- # megadense4 launch per (N, K) weight shape and 8-row bucket of the batch (1-8,
- # 9-16, ..., 57-64): the variant (tens digit K split, units digit tiles per
- # block, 1 = 4 tiles unsplit), or None for the FP8 copy: the fastest of every
- # variant and CUTLASS FP8 on GB10 for each TP=4 shape. Past 24 rows every block
- # re-reads the whole batch, so fewer warps per tile and fewer blocks win. Other
- # shapes use megadense4's default launch up to 32 rows, and FP8 above.
- _W4_PLAN = {
- (6416, 4096): [81, 81, 41, 82, 41, 41, 41, 1], # KDA in_proj
- (4096, 4096): [81, 81, 81, 82, 21, 21, 21, 21], # MLA o_proj
- (4096, 2048): [42, 42, 21, 21, 21, 21, 21, 21], # KDA o_proj
- (1024, 4096): [82, 82, 82, None, None, None, None, None], # shared gate_up
- (4096, 512): [41, 21, 21, 21, 21, 21, None, None], # shared down
- }
- _L2_WEIGHT_BYTES = 16 << 20
- _EXCLUDE = re.compile(r"(^|\.)(gate|kv_b_proj|wk_weights_proj|index_kpool_compress_gate)$|(^|\.)visual\.")
- # Activations already quantized to per-token FP8 elsewhere (the SP gather),
- # keyed by the placeholder tensor handed to the layer in their place.
- _prequant: dict[int, tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
- def prequant(placeholder: torch.Tensor, xq: torch.Tensor, xs: torch.Tensor) -> None:
- """The next FP8 linear called on `placeholder` uses (xq, xs) as its input."""
- _prequant[placeholder.data_ptr()] = (placeholder, xq, xs)
- def _fp8_linear(x: torch.Tensor, weight: torch.Tensor, scale: torch.Tensor,
- bias: torch.Tensor | None) -> torch.Tensor:
- shape = x.shape
- x2 = x.reshape(-1, shape[-1])
- if x2.shape[0] == 0:
- return x.new_empty(*shape[:-1], weight.shape[0])
- pq = _prequant.pop(x.data_ptr(), None) if _prequant else None
- if pq is not None and pq[0] is x:
- xq, xs = pq[1], pq[2]
- else:
- xq, xs = ops.scaled_fp8_quant(x2.contiguous(), use_per_token_if_dynamic=True)
- M = xq.shape[0]
- if M <= _ROWS or weight.numel() <= _L2_WEIGHT_BYTES or weight.shape[0] % 16:
- out = ops.cutlass_scaled_mm(xq, weight.t(), xs, scale, x.dtype, bias)
- return out.reshape(*shape[:-1], weight.shape[0])
- # CUTLASS rasterizes along N, so every row of tiles rereads the whole
- # weight. Once it outgrows L2 that comes from DRAM: 16k x 6416 x 4096 runs
- # at 68 TFLOPS in one call and 169 in 2048-row calls.
- out = torch.empty(M, weight.shape[0], dtype=x.dtype, device=x.device)
- for m0 in range(0, M, _ROWS):
- torch.ops._C.cutlass_scaled_mm(out[m0:m0 + _ROWS], xq[m0:m0 + _ROWS], weight.t(),
- xs[m0:m0 + _ROWS], scale, bias)
- return out.reshape(*shape[:-1], weight.shape[0])
- class Fp8DenseLinearMethod(LinearMethodBase):
- """A converted layer's forward: per-token FP8 activations x FP8 weight.
- Not an UnquantizedLinearMethod: code that sees one assumes a bf16 weight it
- can use directly (the DFlash drafter concatenates its KV weights)."""
- def create_weights(self, *args, **kwargs):
- raise NotImplementedError("layers are converted after loading")
- def apply(self, layer, x, bias=None):
- return _trim(layer, _fp8_linear(x, layer.weight, layer.weight_scale, bias))
- def _trim(layer, y: torch.Tensor) -> torch.Tensor:
- """Drop the zero rows _pad_rows16 added (TP=3 KDA in_proj: N=8726)."""
- n = getattr(layer, "n_trim", None)
- return y if n is None else y[..., :n]
- def _pad_rows16(module: torch.nn.Module) -> None:
- """Zero-extend the output rows to a multiple of 16 so the layer can
- convert (megadense4 and the tiled layout need N % 16 == 0). Outputs are
- trimmed back in apply."""
- w = module.weight.data
- N = w.shape[0]
- pad = -N % 16
- if pad == 0:
- return
- module.weight = Parameter(torch.cat([w, w.new_zeros(pad, w.shape[1])]), requires_grad=False)
- module.n_trim = N
- class Fp8LMHeadMethod:
- """The lm_head's logits (the only path a ParallelLMHead takes at decode)."""
- def __init__(self, inner):
- self.inner = inner
- def apply(self, layer, x, bias=None):
- return _fp8_linear(x, layer.weight, layer.weight_scale, bias)
- def __getattr__(self, name):
- return getattr(self.inner, name)
- _W4_GRID = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
- _w4_ext = None
- def _jit_load(name, *args, **kwargs):
- """torch's cpp_extension.load(), safe after a build was killed midway (#53).
- torch marks a build with a `lock` file and waits forever on one it finds, so a
- build killed midway hangs every later load with nothing in the log. Builds here
- also hold an flock, which the kernel drops when its holder dies: whoever holds
- it is the only builder, and any `lock` file it finds is stale."""
- import fcntl
- from torch.utils.cpp_extension import _get_build_directory, load
- build = _get_build_directory(name, False)
- with open(os.path.join(build, "lock.flock"), "w") as f:
- fcntl.flock(f, fcntl.LOCK_EX)
- try:
- os.remove(os.path.join(build, "lock"))
- except FileNotFoundError:
- pass
- return load(name, *args, **kwargs)
- def _w4():
- global _w4_ext
- if _w4_ext is None:
- _w4_ext = _jit_load("megadense4", [os.environ.get("VLLM_MEGADENSE4_SRC", "/opt/megamoe/megadense4.cu")],
- extra_cuda_cflags=["-O3", "-gencode=arch=compute_121a,code=sm_121a"])
- return _w4_ext
- class W4DenseLinearMethod(LinearMethodBase):
- """A layer converted to NVFP4 in megadense4.cu's tiled layout."""
- def create_weights(self, *args, **kwargs):
- raise NotImplementedError("layers are converted after loading")
- def apply(self, layer, x, bias=None):
- shape = x.shape
- x2 = x.reshape(-1, shape[-1])
- N, K = layer.w4_shape
- if x2.shape[0] == 0:
- return _trim(layer, x.new_empty(*shape[:-1], N))
- M = x2.shape[0]
- plan = _W4_PLAN.get((N, K))
- variant = (plan[(M - 1) // 8] if M <= 64 else None) if plan else (0 if M <= 32 else None)
- if variant is None or x2.dtype != torch.bfloat16:
- return _trim(layer, _fp8_linear(x, layer.weight_fp8, layer.weight_fp8_scale, bias))
- y = torch.empty(M, N, dtype=torch.bfloat16, device=x.device)
- _w4_ext.gemm(x2.contiguous(), layer.weight, layer.weight_scale, layer.weight_scale_2, y, variant)
- if bias is not None:
- y += bias
- return _trim(layer, y.reshape(*shape[:-1], N))
- class W4LMHeadMethod(W4DenseLinearMethod):
- """The lm_head in NVFP4 (selected by VLLM_DENSE_W4 matching "lm_head")."""
- def __init__(self, inner):
- self.inner = inner
- def __getattr__(self, name):
- return getattr(self.inner, name)
- def _quantize_w4(module: torch.nn.Module) -> int:
- """NVFP4 with round to nearest: e4m3 scale per 16 k, one fp32 scale per
- tensor, packed into megadense4.cu's tiled layout. Also keeps an FP8 copy
- for the batch sizes where megadense4 loses to CUTLASS (see _W4_PLAN)."""
- w = module.weight.data
- N, K = w.shape
- wq8, ws8 = ops.scaled_fp8_quant(w.contiguous(), use_per_token_if_dynamic=True)
- module.weight_fp8 = Parameter(wq8, requires_grad=False)
- module.weight_fp8_scale = Parameter(ws8.view(1, -1).to(torch.float32).contiguous(), requires_grad=False)
- wf = w.float().view(N, K // 16, 16)
- gscale = (wf.abs().amax().clamp(min=1e-12) / (6.0 * 448.0)).item()
- bs = (wf.abs().amax(-1, keepdim=True) / 6.0 / gscale).to(torch.float8_e4m3fn).float()
- bs = torch.where(bs == 0, torch.ones_like(bs), bs)
- v = wf / (bs * gscale)
- grid = torch.tensor(_W4_GRID, device=w.device)
- idx = torch.bucketize(v.abs().clamp(max=6.0), (grid[1:] + grid[:-1]) / 2)
- codes = (idx + 8 * (v < 0)).to(torch.uint8).view(N, K)
- packed = codes[:, 0::2] | (codes[:, 1::2] << 4)
- wt = packed.view(N // 16, 2, 8, K // 128, 4, 16).permute(0, 3, 1, 2, 4, 5).contiguous().view(N, K // 2)
- st = (bs.to(torch.float8_e4m3fn).view(torch.uint8).view(N, K // 16).view(N // 16, 2, 8, K // 128, 4, 2)
- .permute(0, 3, 2, 4, 1, 5).contiguous().view(-1).view(torch.int32))
- module.weight = Parameter(wt, requires_grad=False)
- module.weight_scale = Parameter(st, requires_grad=False)
- # A tensor, so weight snapshots save and restore it with the weights.
- module.weight_scale_2 = Parameter(torch.tensor([gscale], dtype=torch.float32, device=w.device),
- requires_grad=False)
- module.w4_shape = (N, K)
- return w.numel() * w.element_size() - wt.numel() - st.numel() * 4 # bytes a decode step no longer reads
- def _quantize(module: torch.nn.Module) -> int:
- w = module.weight.data
- wq, ws = ops.scaled_fp8_quant(w.contiguous(), use_per_token_if_dynamic=True)
- module.weight = Parameter(wq, requires_grad=False)
- module.weight_scale = Parameter(ws.view(1, -1).to(torch.float32).contiguous(), requires_grad=False)
- return w.numel() * w.element_size() - wq.numel()
- @torch.no_grad()
- def convert(model: torch.nn.Module) -> None:
- """Convert every eligible bf16 linear in place. Idempotent."""
- if os.environ.get("VLLM_DENSE_FP8") != "1":
- return
- lm_head = os.environ.get("VLLM_DENSE_FP8_LM_HEAD", "1") == "1"
- w4 = re.compile(os.environ["VLLM_DENSE_W4"]) if os.environ.get("VLLM_DENSE_W4") else None
- saved, count, count4 = 0, 0, 0
- # Hand freed bf16 blocks back as we go: at TP=2 a rank holds ~91 GiB of
- # weights on a 121.6 GiB box, and the allocator would otherwise keep every
- # replaced weight reserved until the end and get the worker OOM-killed.
- torch.cuda.empty_cache()
- freed = 0
- for name, m in model.named_modules():
- if freed >= 1 << 30:
- torch.cuda.empty_cache()
- freed = 0
- w = getattr(m, "weight", None)
- if not isinstance(w, torch.Tensor) or w.dtype != torch.bfloat16 or w.dim() != 2:
- continue
- if w.shape[1] % 16 or _EXCLUDE.search(name):
- continue
- method = getattr(m, "quant_method", None)
- if w.shape[0] % 16:
- if not (isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod)
- and getattr(m, "bias", None) is None):
- continue
- _pad_rows16(m)
- w = m.weight
- if (w4 is not None and w4.search(name) and isinstance(m, LinearBase)
- and isinstance(method, UnquantizedLinearMethod) and w.shape[1] % 128 == 0):
- _w4()
- saved += _quantize_w4(m)
- freed += w.numel() * w.element_size()
- m.quant_method = W4DenseLinearMethod()
- count4 += 1
- elif isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod):
- saved += _quantize(m)
- freed += w.numel() * w.element_size()
- m.quant_method = Fp8DenseLinearMethod()
- count += 1
- elif (lm_head and isinstance(m, ParallelLMHead) and w4 is not None and w4.search(name)
- and not isinstance(method, (Fp8LMHeadMethod, W4LMHeadMethod)) and w.shape[1] % 128 == 0):
- _w4()
- saved += _quantize_w4(m)
- m.quant_method = W4LMHeadMethod(m.quant_method)
- count4 += 1
- elif lm_head and isinstance(m, ParallelLMHead) and not isinstance(method, (Fp8LMHeadMethod, W4LMHeadMethod)):
- saved += _quantize(m)
- m.quant_method = Fp8LMHeadMethod(m.quant_method)
- count += 1
- torch.cuda.empty_cache()
- logger.info("Dense FP8: converted %d linears to FP8 and %d to NVFP4, %.2f GiB less to read per step",
- count, count4, saved / 2**30)
- _FP4_GRID = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
- def _nvfp4_roundtrip(w: torch.Tensor) -> torch.Tensor:
- """w through NVFP4 (16-wide blocks along K, e4m3 block scales, one fp32
- global scale) and back to its dtype, rounding to nearest."""
- N, K = w.shape
- wf = w.float().view(N, K // 16, 16)
- gscale = wf.abs().amax().clamp(min=1e-12) / (6.0 * 448.0)
- bscale = (wf.abs().amax(-1, keepdim=True) / 6.0 / gscale).to(torch.float8_e4m3fn).float()
- bscale = torch.where(bscale == 0, torch.ones_like(bscale), bscale)
- v = wf / (bscale * gscale)
- grid = torch.tensor(_FP4_GRID, device=w.device)
- mid = (grid[1:] + grid[:-1]) / 2
- q = grid[torch.bucketize(v.abs().clamp(max=6.0), mid)] * v.sign()
- return (q * bscale * gscale).view(N, K).to(w.dtype)
- @torch.no_grad()
- def simulate_nvfp4(model: torch.nn.Module) -> None:
- """Diagnostic: round every eligible bf16 linear (not the lm_head) through
- NVFP4 in place, to measure what 4-bit dense weights would cost in quality."""
- if os.environ.get("VLLM_DENSE_NVFP4_SIM") != "1":
- return
- only = re.compile(os.environ.get("VLLM_DENSE_NVFP4_SIM_MATCH", "."))
- count = 0
- for name, m in model.named_modules():
- w = getattr(m, "weight", None)
- method = getattr(m, "quant_method", None)
- if not only.search(name):
- continue
- if (isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod)
- and isinstance(w, torch.Tensor) and w.dtype == torch.bfloat16 and w.dim() == 2
- and w.shape[1] % 16 == 0 and not _EXCLUDE.search(name)):
- w.copy_(_nvfp4_roundtrip(w))
- count += 1
- logger.warning("NVFP4 simulation: rounded %d linears through NVFP4", count)
|