tolivert's picture
deploy: financial_rag streamlit app
4e316d6
Raw
History Blame Contribute Delete
7.37 kB
"""
NormalFloat4 (NF4) Block-Wise Weight Quantization.
NF4 is the quantization format from the QLoRA paper. Instead of spacing
quantization levels evenly (like INT8 absmax), NF4 places its 16 levels
at quantiles of the standard normal distribution. Since neural network
weights are approximately normal, this puts more levels near zero (where
most weights cluster) and fewer in the tails — capturing more information
per bit than uniform spacing.
Block-wise quantization computes one absmax scale per block of 64 weights,
finer than per-channel, which reduces quantization error. Each weight is
normalized by its block's absmax, then snapped to the nearest NF4 level.
Two 4-bit indices are packed into one uint8 byte, so the packed tensor is
half the size of the original element count. Combined with 4-bit precision,
NF4 uses ~4.5 bits per weight (4 bits + amortized scale overhead), roughly
a 4x compression vs float16.
"""
import math
import torch
import torch.nn as nn
# 16 quantization levels from the QLoRA paper, computed as quantiles of N(0,1)
# mapped to [-1, 1]. Asymmetric: 8 non-positive (including 0) + 8 positive.
NF4_LEVELS = [
-1.0,
-0.6961928009986877,
-0.5250730514526367,
-0.39491748809814453,
-0.28444138169288635,
-0.18477343022823334,
-0.09105003625154495,
0.0,
0.07958029955625534,
0.16093020141124725,
0.24611230194568634,
0.33791524171829224,
0.44070982933044434,
0.5626170039176941,
0.7229568362236023,
1.0,
]
def _snap_to_nf4(normalized):
"""Snap each value in normalized tensor (in [-1, 1]) to the nearest NF4 level index."""
levels = torch.tensor(NF4_LEVELS, dtype=normalized.dtype, device=normalized.device)
# Shape: (*normalized.shape, 16) — distance to each level
distances = (normalized.unsqueeze(-1) - levels).abs()
return distances.argmin(dim=-1).to(torch.uint8)
def _pack_uint4(indices):
"""Pack pairs of 4-bit indices into uint8. Input length must be even."""
# Cast to int32 before bitwise ops — MPS does not support uint8 bit-shifts.
indices = indices.reshape(-1).to(torch.int32)
high = (indices[0::2] << 4) & 0xFF
low = indices[1::2] & 0xFF
return (high | low).to(torch.uint8)
def _unpack_uint4(packed, num_elements):
"""Unpack uint8 into 4-bit indices."""
# Cast to int32 before bitwise ops — MPS does not support uint8 bit-shifts.
packed_i32 = packed.to(torch.int32)
high = (packed_i32 >> 4) & 0x0F
low = packed_i32 & 0x0F
# Interleave high and low
unpacked = torch.stack([high, low], dim=-1).reshape(-1).to(torch.uint8)
return unpacked[:num_elements]
def nf4_quantize(tensor, block_size=64):
"""
Quantize a float tensor to NF4 using block-wise absmax scaling.
Args:
tensor: Float tensor (typically a weight matrix)
block_size: Number of elements per quantization block
Returns:
(packed_uint8, scales)
- packed_uint8: Two 4-bit NF4 indices packed per byte
- scales: One absmax scale per block
"""
if block_size % 2 != 0:
raise ValueError("block_size must be even for uint4 packing")
original_shape = tensor.shape
flat = tensor.reshape(-1).float()
n = flat.numel()
# Pad to multiple of block_size
pad = (block_size - n % block_size) % block_size
if pad > 0:
flat = torch.cat([flat, torch.zeros(pad, device=flat.device)])
# Reshape into blocks
blocks = flat.reshape(-1, block_size)
# One absmax scale per block
scales = blocks.abs().amax(dim=1)
scales = torch.clamp(scales, min=1e-8)
# Normalize each block to [-1, 1]
normalized = blocks / scales.unsqueeze(1)
# Snap to nearest NF4 level
indices = _snap_to_nf4(normalized)
# Pack pairs of 4-bit indices into uint8
packed = _pack_uint4(indices)
return packed, scales
def nf4_dequantize(packed, scales, original_shape, block_size=64):
"""
Reconstruct float tensor from NF4 packed data + scales.
Args:
packed: uint8 tensor with two 4-bit NF4 indices per byte
scales: One absmax scale per block
original_shape: Original tensor shape before quantization
block_size: Block size used during quantization
Returns:
Reconstructed float tensor with original_shape
"""
n = math.prod(original_shape)
# Pad count to match what was used during quantization
pad = (block_size - n % block_size) % block_size
total = n + pad
# Unpack indices
indices = _unpack_uint4(packed, total)
# Look up NF4 levels
levels = torch.tensor(NF4_LEVELS, dtype=torch.float32, device=packed.device)
values = levels[indices.long()]
# Reshape into blocks, multiply by scales
blocks = values.reshape(-1, block_size)
dequantized = blocks * scales.unsqueeze(1)
# Flatten and trim padding
return dequantized.reshape(-1)[:n].reshape(original_shape)
class NF4Linear(nn.Module):
"""Drop-in replacement for nn.Linear that stores NF4-packed weights and dequantizes on forward."""
def __init__(self, in_features, out_features, packed_weight, scales, weight_shape, block_size, bias=None):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.register_buffer("_weight_shape", torch.tensor(list(weight_shape), dtype=torch.long))
self.register_buffer("_block_size", torch.tensor(block_size, dtype=torch.long))
self.register_buffer("packed_weight", packed_weight)
self.register_buffer("scales", scales)
if bias is not None:
self.register_buffer("bias", bias)
else:
self.bias = None
@classmethod
def from_linear(cls, linear, block_size=64):
"""Create an NF4Linear from an existing nn.Linear."""
weight = linear.weight.data.float()
packed, scales = nf4_quantize(weight, block_size=block_size)
bias = linear.bias.data if linear.bias is not None else None
return cls(linear.in_features, linear.out_features, packed, scales, weight.shape, block_size, bias)
def forward(self, x):
w = nf4_dequantize(self.packed_weight, self.scales, tuple(self._weight_shape.tolist()), self._block_size.item()).to(x.dtype)
return nn.functional.linear(x, w, self.bias)
def quantize_model_nf4(model, block_size=64):
"""Walk the model and replace all nn.Linear layers with NF4Linear.
Skips Linear layers whose weights are tied to an Embedding (e.g. a
shared output head), since quantizing them would break the tie and
produce a mismatch between embedding lookups and output projections.
"""
# Collect data_ptrs of all Embedding weights so we can detect ties
tied_ptrs = {
m.weight.data_ptr()
for m in model.modules()
if isinstance(m, nn.Embedding)
}
_quantize_nf4_recursive(model, block_size, tied_ptrs)
def _quantize_nf4_recursive(module, block_size, tied_ptrs=frozenset()):
for name, child in module.named_children():
if isinstance(child, nn.Linear):
if child.weight.data_ptr() in tied_ptrs:
continue # skip weight-tied layers
setattr(module, name, NF4Linear.from_linear(child, block_size=block_size))
else:
_quantize_nf4_recursive(child, block_size, tied_ptrs)