Files
aigc-apps-VideoX-Fun/videox_fun/utils/fp4_optimization.py

319 lines
15 KiB
Python

import torch
import torch.nn as nn
from .fp8_optimization import _is_fsdp_managed
# E2M1: 1 sign bit, 2 exponent bits, 1 mantissa bit -> 8 magnitudes, 16 codes.
FLOAT4_MAX = 6.0
# NVFP4 blocks are 16 elements wide and scale them with a single E4M3 byte (plus one
# fp32 global scale per tensor); block 32 with an fp32 scale is the OCP MXFP4 flavour.
FLOAT4_BLOCK_SIZE = 16
FLOAT8_E4M3 = torch.float8_e4m3fn
FLOAT8_E4M3_MAX = torch.finfo(FLOAT8_E4M3).max # 448.0
FLOAT4_MAGNITUDES = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
# Mid-points between consecutive magnitudes; a value is rounded to the bucket this
# falls into. Boundaries are exclusive for the lower bucket (see _float4_round).
FLOAT4_BOUNDARIES = (0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0)
# Index = code (magnitude index | sign << 3).
FLOAT4_LUT = FLOAT4_MAGNITUDES + tuple(-v for v in FLOAT4_MAGNITUDES)
_FLOAT4_TABLES = {}
def _float4_tables(device):
"""The two constant tables, built once per device."""
tables = _FLOAT4_TABLES.get(device)
if tables is None:
tables = (
torch.tensor(FLOAT4_LUT, dtype=torch.float32, device=device),
torch.tensor(FLOAT4_MAGNITUDES, dtype=torch.float32, device=device),
torch.tensor(FLOAT4_BOUNDARIES, dtype=torch.float32, device=device),
)
_FLOAT4_TABLES[device] = tables
return tables
def _float4_scale_name(param_name):
return param_name + "_fp4_scale"
def _float4_global_scale_name(param_name):
# NVFP4's second-level, per-tensor scale (a single fp32 value), stored next to the
# per-block E4M3 scales so both move together under the offload hooks.
return param_name + "_fp4_global_scale"
def _float4_meta_name(param_name):
# Plain attribute (not a buffer): the unpacked shape and the block size are Python
# constants that never need to move between devices.
return param_name + "_fp4_meta"
def _float4_round(magnitudes, boundaries):
"""Round non-negative scaled values to the nearest E2M1 magnitude index.
``bucketize(..., right=True)`` is round-half-up; a value sitting exactly on a
boundary is then pushed down to the even code, which makes the whole thing
round-nearest-even like the hardware FP8 cast in fp8_optimization. Adjacent codes
always differ in parity, so on a tie either neighbour can be the even one and one
where covers both directions.
"""
idx = torch.bucketize(magnitudes, boundaries, right=True)
lower = (idx - 1).clamp(min=0)
# `lower` indexes the boundary between the two candidate codes, not a grid value.
tie = (magnitudes == boundaries[lower]) & (idx > 0)
return torch.where(tie & (lower % 2 == 0), lower, idx)
@torch.no_grad()
def quantize_weight_to_float4(tensor, block_size=FLOAT4_BLOCK_SIZE):
"""Return ``(packed_uint8, block_scale_fp8, global_scale)`` for a 2-D weight.
NVFP4's two-level scaling: the per-tensor ``global_scale`` maps the largest block
scale onto the E4M3 grid, and each 16-element block stores its scale as one E4M3
byte. The element codes are quantized with the *dequantized* block scale
(``block_scale_fp8.float() / global_scale``), so the stored nibbles reproduce
exactly what the unpack divides back out -- no scale mismatch between the two.
"""
rows, features = tensor.shape[0], tensor.shape[1]
x = tensor.detach().float().reshape(rows, features)
pad = (-features) % block_size
if pad:
# Only reachable for weights whose input dim is not a multiple of the block;
# the padding is dropped again by the unpack through the stored shape.
x = nn.functional.pad(x, (0, pad))
padded = x.shape[1]
blocks = x.reshape(-1, block_size)
# Second-level (per-tensor) scale: (E4M3_MAX * E2M1_MAX) / global_amax, so the
# widest block (amax == global_amax) scales to exactly E4M3_MAX and no block scale
# overflows fp8. global_amax is clamped so an all-zero weight yields codes of 0.
global_amax = x.abs().amax().clamp(min=1e-12)
global_scale = (FLOAT8_E4M3_MAX * FLOAT4_MAX) / global_amax
# First-level (per-block) scale, rounded to E4M3 (NVFP4's storage type) then read
# back, so quantization uses the identical scale the unpack will use. A block whose
# scale underflows to 0 in fp8 (or a zero-initialized control projection) is clamped
# instead of dividing by zero, keeping its codes at 0 rather than NaN.
block_amax = blocks.abs().amax(dim=1, keepdim=True)
block_scale_fp8 = ((block_amax / FLOAT4_MAX) * global_scale).to(FLOAT8_E4M3)
block_scale = (block_scale_fp8.float() / global_scale).clamp(min=1e-12)
_, _, boundaries = _float4_tables(x.device)
scaled = (blocks / block_scale).clamp(-FLOAT4_MAX, FLOAT4_MAX)
idx = _float4_round(scaled.abs().reshape(rows, padded), boundaries)
code = torch.where(scaled.reshape(rows, padded) < 0, idx + 8, idx).to(torch.uint8)
packed = code[:, 0::2] | (code[:, 1::2] << 4)
return packed, block_scale_fp8.reshape(rows, padded // block_size, 1), global_scale
@torch.no_grad()
def dequantize_weight_from_float4(packed, block_scale_fp8, global_scale, shape,
block_size=FLOAT4_BLOCK_SIZE, dtype=torch.bfloat16):
"""Rebuild the ``(out, in)`` weight from the nibbles and the two-level scales."""
rows, half = packed.shape
code = torch.stack((packed & 0x0F, packed >> 4), dim=-1).reshape(rows, half * 2)
lut, _, _ = _float4_tables(packed.device)
values = lut[code.long()]
# E4M3 block scale back to fp32, then undo the per-tensor global scale.
block_scale = block_scale_fp8.float() / global_scale
blocks = values.reshape(rows, block_scale_fp8.shape[1], block_size) * block_scale
return blocks.reshape(rows, -1)[:, :shape[1]].reshape(shape).to(dtype)
def _float4_pack_weight(module, device, block_size):
"""Pack ``module.weight`` (a 2-D floating tensor) into E2M1 nibbles + scale/meta buffers.
Single source of truth for the packing, shared by the whole-model
:func:`convert_model_weight_to_float4` and the per-layer
:func:`float4_quantize_linear` used around a LoRA merge.
"""
weight = module.weight
packed, block_scale_fp8, global_scale = quantize_weight_to_float4(weight.data, block_size)
to_device = (lambda t: t.to(device)) if device is not None else (lambda t: t)
module.register_buffer(_float4_scale_name("weight"), to_device(block_scale_fp8))
module.register_buffer(_float4_global_scale_name("weight"), to_device(global_scale))
setattr(module, _float4_meta_name("weight"), (tuple(weight.shape), block_size))
# A Parameter may only hold a non-floating tensor while it does not require
# grad, and a nibble-packed weight could not be trained through anyway.
weight.requires_grad_(False)
weight.data = to_device(packed)
def _float4_wrap_forward(module, origin_dtype):
"""Install the per-forward FP4 unpack on ONE module holding a packed weight."""
if getattr(module, _float4_meta_name("weight"), None) is None:
return
if hasattr(module, "original_forward_fp4"):
return
module.original_forward_fp4 = module.forward
setattr(
module,
"forward",
lambda *inputs, m=module, **kwargs: _float4_autocast_forward(m, origin_dtype, *inputs, **kwargs)
)
def convert_model_weight_to_float4(model, exclude_module_name=("modulation",),
device=None, block_size=FLOAT4_BLOCK_SIZE):
"""Pack every ``nn.Linear`` weight of ``model`` into E2M1 nibbles, in place."""
if _is_fsdp_managed(model):
raise NotImplementedError(
"GPU_memory_mode 'qfloat4' is not supported under FSDP: the managed "
"parameters are flat-storage views, so a block-scaled 4-bit repacking of "
"`weight` has no shape to keep. Use 'qfloat8' (which dequantizes on the "
"output side instead) or no quantization.")
for name, module in model.named_modules():
if not isinstance(module, nn.Linear):
continue
if any(keyword in name for keyword in exclude_module_name):
continue
weight = module.weight
if weight is None or weight.dtype == torch.uint8 or weight.dim() != 2:
continue
_float4_pack_weight(module, device, block_size)
return model
@torch.no_grad()
def _float4_autocast_forward(module, origin_dtype, *inputs, **kwargs):
"""Swap the packed weight for its bf16 reconstruction around one forward.
Unlike the FP8 wrapper this never re-quantizes on the way out: the packed nibbles
are simply put back, so the stored bits cannot drift across steps and the dequant
costs one gather plus one mul instead of a division.
"""
restored = []
for param_name, param in list(module.named_parameters(recurse=False)):
meta = getattr(module, _float4_meta_name(param_name), None)
if meta is None:
if param.is_floating_point() and param.dtype != origin_dtype:
# An unquantized fp32 bias next to a bf16 weight would make F.linear
# reject the pair; fp8_optimization normalizes them the same way.
restored.append((param, param.data))
param.data = param.data.to(origin_dtype)
continue
if param.dtype != torch.uint8:
# `module.to(dtype)` walks every parameter and would silently turn the
# packed bytes into the bf16 numbers 0..255. Refuse instead of generating
# with a destroyed weight.
raise RuntimeError(
"The FP4-packed parameter '%s' holds a %s tensor, not the packed uint8 "
"nibbles: something called .to(dtype) on a qfloat4 model. Apply the "
"memory mode after the dtype move, and move devices with "
"apply_gpu_memory_mode()/offload hooks, which keep the dtype."
% (param_name, param.dtype))
shape, block_size = meta
restored.append((param, param.data))
param.data = dequantize_weight_from_float4(
param.data, getattr(module, _float4_scale_name(param_name)),
getattr(module, _float4_global_scale_name(param_name)),
shape, block_size, origin_dtype)
# Integer tensors (indices) must keep their dtype or the kernel rejects them.
inputs = [input.to(origin_dtype) if torch.is_tensor(input) and input.is_floating_point()
else input for input in inputs]
kwargs = {key: value.to(origin_dtype) if torch.is_tensor(value) and value.is_floating_point()
else value for key, value in kwargs.items()}
try:
return module.original_forward_fp4(*inputs, **kwargs)
finally:
for param, data in restored:
param.data = data
def convert_float4_weight_dtype_wrapper(module, origin_dtype):
"""Install the per-forward unpack on every module holding a packed weight."""
for name, child in module.named_modules():
if name == "" or "embed_tokens" in name or isinstance(child, nn.Embedding):
continue
_float4_wrap_forward(child, origin_dtype)
return module
def undo_convert_float4_weight_dtype_wrapper(module, origin_dtype):
"""Drop the wrappers and unpack in place, leaving a plain full-precision module.
The mirror of the FP8 undo. Afterwards the model runs its own forwards again and no
per-forward reconstruction is needed -- at the cost of holding the weights in
`origin_dtype` again, so this is what to call before a LoRA merge or a save.
"""
for _, child in module.named_modules():
if not hasattr(child, "original_forward_fp4"):
continue
setattr(child, "forward", child.original_forward_fp4)
delattr(child, "original_forward_fp4")
meta = getattr(child, _float4_meta_name("weight"), None)
if meta is None:
continue
shape, block_size = meta
weight = child.weight
scale = getattr(child, _float4_scale_name("weight"), None)
global_scale = getattr(child, _float4_global_scale_name("weight"), None)
if weight is not None and weight.dtype == torch.uint8 and scale is not None:
weight.requires_grad_(False)
weight.data = dequantize_weight_from_float4(
weight.data, scale, global_scale, shape, block_size, origin_dtype)
delattr(child, _float4_meta_name("weight"))
if scale is not None:
delattr(child, _float4_scale_name("weight"))
if global_scale is not None:
delattr(child, _float4_global_scale_name("weight"))
return module
def float4_dequantize_linear(module, origin_dtype):
"""Unpack ONE FP4-packed ``nn.Linear`` back to ``origin_dtype``, in place.
Drops this layer's scale/meta buffers and forward wrapper, leaving a plain
full-precision Linear. Returns True if the layer was packed (so the caller knows to
re-pack it), False if it was already full precision and was left untouched.
Deliberately per layer rather than whole-model: a LoRA merge edits one layer at a
time, and materializing the *entire* transformer in ``origin_dtype`` would need the
very memory the 4-bit packing was chosen to avoid. Only this layer's weight is
dequantized, so the peak overhead is one layer.
"""
meta = getattr(module, _float4_meta_name("weight"), None)
if meta is None:
return False
if _is_fsdp_managed(module):
raise NotImplementedError(
"Cannot dequantize an FP4-packed weight under FSDP: the managed parameters "
"are flat-storage DTensor views with no shape to unpack. Merge the LoRA "
"before the FSDP sharding (or before apply_gpu_memory_mode).")
shape, block_size = meta
weight = module.weight
scale = getattr(module, _float4_scale_name("weight"), None)
global_scale = getattr(module, _float4_global_scale_name("weight"), None)
if hasattr(module, "original_forward_fp4"):
setattr(module, "forward", module.original_forward_fp4)
delattr(module, "original_forward_fp4")
if weight is not None and weight.dtype == torch.uint8 and scale is not None:
weight.requires_grad_(False)
weight.data = dequantize_weight_from_float4(
weight.data, scale, global_scale, shape, block_size, origin_dtype)
delattr(module, _float4_meta_name("weight"))
if scale is not None:
delattr(module, _float4_scale_name("weight"))
if global_scale is not None:
delattr(module, _float4_global_scale_name("weight"))
return True
def float4_quantize_linear(module, origin_dtype, device=None, block_size=FLOAT4_BLOCK_SIZE):
"""Re-pack ONE ``nn.Linear`` into FP4 and reinstall its forward wrapper.
The exact inverse of :func:`float4_dequantize_linear`, called after a LoRA delta has
been folded into the layer's full-precision weight so the model stays 4-bit for
generation. Returns True if the layer was packed.
"""
if not isinstance(module, nn.Linear):
return False
weight = module.weight
if weight is None or weight.dtype == torch.uint8 or weight.dim() != 2:
return False
_float4_pack_weight(module, device, block_size)
_float4_wrap_forward(module, origin_dtype)
return True