dense_fp8.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. # SPDX-License-Identifier: Apache-2.0
  2. """FP8 weights for the dense linears a checkpoint left in bf16.
  3. GLM-5.3-Flash's NVFP4 checkpoint quantizes only the routed experts. About 4 GB
  4. per rank of dense projections (the KDA input projection, o_proj, the shared
  5. experts, the MLA and indexer query projections) stay bf16, and decode reads
  6. all of it every step. On GB10 those GEMMs run at the memory roofline, so the
  7. bytes are the cost: FP8 halves them.
  8. Weights get one scale per output channel, activations one per token, and the
  9. GEMM is CUTLASS's scaled_mm, which reaches ~90% of the FP8 read roofline from
  10. M = 1 upwards. The router gate stays bf16 (routing is sensitive), and so do
  11. layers whose weight other code reads directly (kv_b_proj, the indexer's
  12. wk_weights_proj). The lm_head converts too unless VLLM_DENSE_FP8_LM_HEAD=0.
  13. Layers whose names match VLLM_DENSE_W4 go to NVFP4 instead (W4A16 through
  14. megadense4.cu, for up to 64 tokens where _W4_PLAN says so and 32 elsewhere;
  15. larger batches use an FP8 copy kept alongside). Half the bytes again, at a
  16. quality cost that depends on the layer: the KDA in_proj alone cost about as much NLL as FP8 on
  17. everything, all dense layers about 2-3x that. The DFlash drafter's layers
  18. only change acceptance, never the output.
  19. """
  20. import os
  21. import re
  22. import torch
  23. from torch.nn import Parameter
  24. from vllm import _custom_ops as ops
  25. from vllm.logger import init_logger
  26. from vllm.model_executor.layers.linear import LinearBase, LinearMethodBase, UnquantizedLinearMethod
  27. from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
  28. logger = init_logger(__name__)
  29. _ROWS = 2048
  30. # megadense4 launch per (N, K) weight shape and 8-row bucket of the batch (1-8,
  31. # 9-16, ..., 57-64): the variant (tens digit K split, units digit tiles per
  32. # block, 1 = 4 tiles unsplit), or None for the FP8 copy: the fastest of every
  33. # variant and CUTLASS FP8 on GB10 for each TP=4 shape. Past 24 rows every block
  34. # re-reads the whole batch, so fewer warps per tile and fewer blocks win. Other
  35. # shapes use megadense4's default launch up to 32 rows, and FP8 above.
  36. _W4_PLAN = {
  37. (6416, 4096): [81, 81, 41, 82, 41, 41, 41, 1], # KDA in_proj
  38. (4096, 4096): [81, 81, 81, 82, 21, 21, 21, 21], # MLA o_proj
  39. (4096, 2048): [42, 42, 21, 21, 21, 21, 21, 21], # KDA o_proj
  40. (1024, 4096): [82, 82, 82, None, None, None, None, None], # shared gate_up
  41. (4096, 512): [41, 21, 21, 21, 21, 21, None, None], # shared down
  42. }
  43. _L2_WEIGHT_BYTES = 16 << 20
  44. _EXCLUDE = re.compile(r"(^|\.)(gate|kv_b_proj|wk_weights_proj|index_kpool_compress_gate)$|(^|\.)visual\.")
  45. # Activations already quantized to per-token FP8 elsewhere (the SP gather),
  46. # keyed by the placeholder tensor handed to the layer in their place.
  47. _prequant: dict[int, tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
  48. def prequant(placeholder: torch.Tensor, xq: torch.Tensor, xs: torch.Tensor) -> None:
  49. """The next FP8 linear called on `placeholder` uses (xq, xs) as its input."""
  50. _prequant[placeholder.data_ptr()] = (placeholder, xq, xs)
  51. def _fp8_linear(x: torch.Tensor, weight: torch.Tensor, scale: torch.Tensor,
  52. bias: torch.Tensor | None) -> torch.Tensor:
  53. shape = x.shape
  54. x2 = x.reshape(-1, shape[-1])
  55. if x2.shape[0] == 0:
  56. return x.new_empty(*shape[:-1], weight.shape[0])
  57. pq = _prequant.pop(x.data_ptr(), None) if _prequant else None
  58. if pq is not None and pq[0] is x:
  59. xq, xs = pq[1], pq[2]
  60. else:
  61. xq, xs = ops.scaled_fp8_quant(x2.contiguous(), use_per_token_if_dynamic=True)
  62. M = xq.shape[0]
  63. if M <= _ROWS or weight.numel() <= _L2_WEIGHT_BYTES or weight.shape[0] % 16:
  64. out = ops.cutlass_scaled_mm(xq, weight.t(), xs, scale, x.dtype, bias)
  65. return out.reshape(*shape[:-1], weight.shape[0])
  66. # CUTLASS rasterizes along N, so every row of tiles rereads the whole
  67. # weight. Once it outgrows L2 that comes from DRAM: 16k x 6416 x 4096 runs
  68. # at 68 TFLOPS in one call and 169 in 2048-row calls.
  69. out = torch.empty(M, weight.shape[0], dtype=x.dtype, device=x.device)
  70. for m0 in range(0, M, _ROWS):
  71. torch.ops._C.cutlass_scaled_mm(out[m0:m0 + _ROWS], xq[m0:m0 + _ROWS], weight.t(),
  72. xs[m0:m0 + _ROWS], scale, bias)
  73. return out.reshape(*shape[:-1], weight.shape[0])
  74. class Fp8DenseLinearMethod(LinearMethodBase):
  75. """A converted layer's forward: per-token FP8 activations x FP8 weight.
  76. Not an UnquantizedLinearMethod: code that sees one assumes a bf16 weight it
  77. can use directly (the DFlash drafter concatenates its KV weights)."""
  78. def create_weights(self, *args, **kwargs):
  79. raise NotImplementedError("layers are converted after loading")
  80. def apply(self, layer, x, bias=None):
  81. return _trim(layer, _fp8_linear(x, layer.weight, layer.weight_scale, bias))
  82. def _trim(layer, y: torch.Tensor) -> torch.Tensor:
  83. """Drop the zero rows _pad_rows16 added (TP=3 KDA in_proj: N=8726)."""
  84. n = getattr(layer, "n_trim", None)
  85. return y if n is None else y[..., :n]
  86. def _pad_rows16(module: torch.nn.Module) -> None:
  87. """Zero-extend the output rows to a multiple of 16 so the layer can
  88. convert (megadense4 and the tiled layout need N % 16 == 0). Outputs are
  89. trimmed back in apply."""
  90. w = module.weight.data
  91. N = w.shape[0]
  92. pad = -N % 16
  93. if pad == 0:
  94. return
  95. module.weight = Parameter(torch.cat([w, w.new_zeros(pad, w.shape[1])]), requires_grad=False)
  96. module.n_trim = N
  97. class Fp8LMHeadMethod:
  98. """The lm_head's logits (the only path a ParallelLMHead takes at decode)."""
  99. def __init__(self, inner):
  100. self.inner = inner
  101. def apply(self, layer, x, bias=None):
  102. return _fp8_linear(x, layer.weight, layer.weight_scale, bias)
  103. def __getattr__(self, name):
  104. return getattr(self.inner, name)
  105. _W4_GRID = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
  106. _w4_ext = None
  107. def _jit_load(name, *args, **kwargs):
  108. """torch's cpp_extension.load(), safe after a build was killed midway (#53).
  109. torch marks a build with a `lock` file and waits forever on one it finds, so a
  110. build killed midway hangs every later load with nothing in the log. Builds here
  111. also hold an flock, which the kernel drops when its holder dies: whoever holds
  112. it is the only builder, and any `lock` file it finds is stale."""
  113. import fcntl
  114. from torch.utils.cpp_extension import _get_build_directory, load
  115. build = _get_build_directory(name, False)
  116. with open(os.path.join(build, "lock.flock"), "w") as f:
  117. fcntl.flock(f, fcntl.LOCK_EX)
  118. try:
  119. os.remove(os.path.join(build, "lock"))
  120. except FileNotFoundError:
  121. pass
  122. return load(name, *args, **kwargs)
  123. def _w4():
  124. global _w4_ext
  125. if _w4_ext is None:
  126. _w4_ext = _jit_load("megadense4", [os.environ.get("VLLM_MEGADENSE4_SRC", "/opt/megamoe/megadense4.cu")],
  127. extra_cuda_cflags=["-O3", "-gencode=arch=compute_121a,code=sm_121a"])
  128. return _w4_ext
  129. class W4DenseLinearMethod(LinearMethodBase):
  130. """A layer converted to NVFP4 in megadense4.cu's tiled layout."""
  131. def create_weights(self, *args, **kwargs):
  132. raise NotImplementedError("layers are converted after loading")
  133. def apply(self, layer, x, bias=None):
  134. shape = x.shape
  135. x2 = x.reshape(-1, shape[-1])
  136. N, K = layer.w4_shape
  137. if x2.shape[0] == 0:
  138. return _trim(layer, x.new_empty(*shape[:-1], N))
  139. M = x2.shape[0]
  140. plan = _W4_PLAN.get((N, K))
  141. variant = (plan[(M - 1) // 8] if M <= 64 else None) if plan else (0 if M <= 32 else None)
  142. if variant is None or x2.dtype != torch.bfloat16:
  143. return _trim(layer, _fp8_linear(x, layer.weight_fp8, layer.weight_fp8_scale, bias))
  144. y = torch.empty(M, N, dtype=torch.bfloat16, device=x.device)
  145. _w4_ext.gemm(x2.contiguous(), layer.weight, layer.weight_scale, layer.weight_scale_2, y, variant)
  146. if bias is not None:
  147. y += bias
  148. return _trim(layer, y.reshape(*shape[:-1], N))
  149. class W4LMHeadMethod(W4DenseLinearMethod):
  150. """The lm_head in NVFP4 (selected by VLLM_DENSE_W4 matching "lm_head")."""
  151. def __init__(self, inner):
  152. self.inner = inner
  153. def __getattr__(self, name):
  154. return getattr(self.inner, name)
  155. def _quantize_w4(module: torch.nn.Module) -> int:
  156. """NVFP4 with round to nearest: e4m3 scale per 16 k, one fp32 scale per
  157. tensor, packed into megadense4.cu's tiled layout. Also keeps an FP8 copy
  158. for the batch sizes where megadense4 loses to CUTLASS (see _W4_PLAN)."""
  159. w = module.weight.data
  160. N, K = w.shape
  161. wq8, ws8 = ops.scaled_fp8_quant(w.contiguous(), use_per_token_if_dynamic=True)
  162. module.weight_fp8 = Parameter(wq8, requires_grad=False)
  163. module.weight_fp8_scale = Parameter(ws8.view(1, -1).to(torch.float32).contiguous(), requires_grad=False)
  164. wf = w.float().view(N, K // 16, 16)
  165. gscale = (wf.abs().amax().clamp(min=1e-12) / (6.0 * 448.0)).item()
  166. bs = (wf.abs().amax(-1, keepdim=True) / 6.0 / gscale).to(torch.float8_e4m3fn).float()
  167. bs = torch.where(bs == 0, torch.ones_like(bs), bs)
  168. v = wf / (bs * gscale)
  169. grid = torch.tensor(_W4_GRID, device=w.device)
  170. idx = torch.bucketize(v.abs().clamp(max=6.0), (grid[1:] + grid[:-1]) / 2)
  171. codes = (idx + 8 * (v < 0)).to(torch.uint8).view(N, K)
  172. packed = codes[:, 0::2] | (codes[:, 1::2] << 4)
  173. wt = packed.view(N // 16, 2, 8, K // 128, 4, 16).permute(0, 3, 1, 2, 4, 5).contiguous().view(N, K // 2)
  174. st = (bs.to(torch.float8_e4m3fn).view(torch.uint8).view(N, K // 16).view(N // 16, 2, 8, K // 128, 4, 2)
  175. .permute(0, 3, 2, 4, 1, 5).contiguous().view(-1).view(torch.int32))
  176. module.weight = Parameter(wt, requires_grad=False)
  177. module.weight_scale = Parameter(st, requires_grad=False)
  178. # A tensor, so weight snapshots save and restore it with the weights.
  179. module.weight_scale_2 = Parameter(torch.tensor([gscale], dtype=torch.float32, device=w.device),
  180. requires_grad=False)
  181. module.w4_shape = (N, K)
  182. return w.numel() * w.element_size() - wt.numel() - st.numel() * 4 # bytes a decode step no longer reads
  183. def _quantize(module: torch.nn.Module) -> int:
  184. w = module.weight.data
  185. wq, ws = ops.scaled_fp8_quant(w.contiguous(), use_per_token_if_dynamic=True)
  186. module.weight = Parameter(wq, requires_grad=False)
  187. module.weight_scale = Parameter(ws.view(1, -1).to(torch.float32).contiguous(), requires_grad=False)
  188. return w.numel() * w.element_size() - wq.numel()
  189. @torch.no_grad()
  190. def convert(model: torch.nn.Module) -> None:
  191. """Convert every eligible bf16 linear in place. Idempotent."""
  192. if os.environ.get("VLLM_DENSE_FP8") != "1":
  193. return
  194. lm_head = os.environ.get("VLLM_DENSE_FP8_LM_HEAD", "1") == "1"
  195. w4 = re.compile(os.environ["VLLM_DENSE_W4"]) if os.environ.get("VLLM_DENSE_W4") else None
  196. saved, count, count4 = 0, 0, 0
  197. # Hand freed bf16 blocks back as we go: at TP=2 a rank holds ~91 GiB of
  198. # weights on a 121.6 GiB box, and the allocator would otherwise keep every
  199. # replaced weight reserved until the end and get the worker OOM-killed.
  200. torch.cuda.empty_cache()
  201. freed = 0
  202. for name, m in model.named_modules():
  203. if freed >= 1 << 30:
  204. torch.cuda.empty_cache()
  205. freed = 0
  206. w = getattr(m, "weight", None)
  207. if not isinstance(w, torch.Tensor) or w.dtype != torch.bfloat16 or w.dim() != 2:
  208. continue
  209. if w.shape[1] % 16 or _EXCLUDE.search(name):
  210. continue
  211. method = getattr(m, "quant_method", None)
  212. if w.shape[0] % 16:
  213. if not (isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod)
  214. and getattr(m, "bias", None) is None):
  215. continue
  216. _pad_rows16(m)
  217. w = m.weight
  218. if (w4 is not None and w4.search(name) and isinstance(m, LinearBase)
  219. and isinstance(method, UnquantizedLinearMethod) and w.shape[1] % 128 == 0):
  220. _w4()
  221. saved += _quantize_w4(m)
  222. freed += w.numel() * w.element_size()
  223. m.quant_method = W4DenseLinearMethod()
  224. count4 += 1
  225. elif isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod):
  226. saved += _quantize(m)
  227. freed += w.numel() * w.element_size()
  228. m.quant_method = Fp8DenseLinearMethod()
  229. count += 1
  230. elif (lm_head and isinstance(m, ParallelLMHead) and w4 is not None and w4.search(name)
  231. and not isinstance(method, (Fp8LMHeadMethod, W4LMHeadMethod)) and w.shape[1] % 128 == 0):
  232. _w4()
  233. saved += _quantize_w4(m)
  234. m.quant_method = W4LMHeadMethod(m.quant_method)
  235. count4 += 1
  236. elif lm_head and isinstance(m, ParallelLMHead) and not isinstance(method, (Fp8LMHeadMethod, W4LMHeadMethod)):
  237. saved += _quantize(m)
  238. m.quant_method = Fp8LMHeadMethod(m.quant_method)
  239. count += 1
  240. torch.cuda.empty_cache()
  241. logger.info("Dense FP8: converted %d linears to FP8 and %d to NVFP4, %.2f GiB less to read per step",
  242. count, count4, saved / 2**30)
  243. _FP4_GRID = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
  244. def _nvfp4_roundtrip(w: torch.Tensor) -> torch.Tensor:
  245. """w through NVFP4 (16-wide blocks along K, e4m3 block scales, one fp32
  246. global scale) and back to its dtype, rounding to nearest."""
  247. N, K = w.shape
  248. wf = w.float().view(N, K // 16, 16)
  249. gscale = wf.abs().amax().clamp(min=1e-12) / (6.0 * 448.0)
  250. bscale = (wf.abs().amax(-1, keepdim=True) / 6.0 / gscale).to(torch.float8_e4m3fn).float()
  251. bscale = torch.where(bscale == 0, torch.ones_like(bscale), bscale)
  252. v = wf / (bscale * gscale)
  253. grid = torch.tensor(_FP4_GRID, device=w.device)
  254. mid = (grid[1:] + grid[:-1]) / 2
  255. q = grid[torch.bucketize(v.abs().clamp(max=6.0), mid)] * v.sign()
  256. return (q * bscale * gscale).view(N, K).to(w.dtype)
  257. @torch.no_grad()
  258. def simulate_nvfp4(model: torch.nn.Module) -> None:
  259. """Diagnostic: round every eligible bf16 linear (not the lm_head) through
  260. NVFP4 in place, to measure what 4-bit dense weights would cost in quality."""
  261. if os.environ.get("VLLM_DENSE_NVFP4_SIM") != "1":
  262. return
  263. only = re.compile(os.environ.get("VLLM_DENSE_NVFP4_SIM_MATCH", "."))
  264. count = 0
  265. for name, m in model.named_modules():
  266. w = getattr(m, "weight", None)
  267. method = getattr(m, "quant_method", None)
  268. if not only.search(name):
  269. continue
  270. if (isinstance(m, LinearBase) and isinstance(method, UnquantizedLinearMethod)
  271. and isinstance(w, torch.Tensor) and w.dtype == torch.bfloat16 and w.dim() == 2
  272. and w.shape[1] % 16 == 0 and not _EXCLUDE.search(name)):
  273. w.copy_(_nvfp4_roundtrip(w))
  274. count += 1
  275. logger.warning("NVFP4 simulation: rounded %d linears through NVFP4", count)