Files
2026-07-13 13:33:03 +08:00

360 lines
13 KiB
Python

import torch
import numpy as np
def get_available_memory(device):
"""Get available GPU memory in bytes."""
if device.type == 'cuda':
torch.cuda.synchronize()
free, total = torch.cuda.mem_get_info()
return free
else:
# return a default number: 16 GB
return 16 * 1024 * 1024 * 1024
def estimate_hqq_memory(num_elements, compute_dtype=torch.float32, symmetric=False):
"""
Estimate the memory needed for HQQ quantization optimization.
The optimization process creates multiple temporary tensors:
- W_f: copy of weights in compute_dtype
- W_q: quantized weights
- W_r: reconstructed weights
- W_e: error tensor
- W_prime: additional tensor for symmetric quantization
- Additional temporary tensors in _shrink_lp_op
Returns estimated memory in bytes.
"""
dtype_size = 4 if compute_dtype == torch.float32 else 2
# Main working tensors
num_tensors = 4 if not symmetric else 5
main_memory = num_elements * dtype_size * num_tensors
# Temporary tensors during _shrink_lp_op (can create 2-3 additional tensors)
temp_memory = num_elements * dtype_size * 3
# Scale and zero tensors (smaller, proportional to num_groups)
# Assuming group_size = 64, num_groups = num_elements / 64
scale_zero_memory = (num_elements // 64) * dtype_size * 2
# Add 20% safety margin for PyTorch memory allocator overhead
total_memory = (main_memory + temp_memory + scale_zero_memory) * 1.2
return int(total_memory)
class HQQQuantizer:
def __init__(self,
weight,
bit,
group_size,
sym=False,
compute_dtype: torch.dtype = torch.float32,
device: torch.device = torch.device("cpu"),
quant_config: dict = None,
auto_chunk: bool = True,
memory_safety_factor: float = 0.7):
self.weight = weight
self.bit = bit
self.group_size = group_size
self.sym = sym
self.compute_dtype = compute_dtype
self.device = device
self.auto_chunk = auto_chunk
self.memory_safety_factor = memory_safety_factor
def quant(self):
if self.auto_chunk and self.device.type in ('cuda', 'mps'):
self._quantize_chunked()
else:
self._quantize()
def _quantize_chunked(self):
"""Quantize with automatic chunking to avoid OOM."""
num_elements = self.weight.numel()
estimated_memory = estimate_hqq_memory(num_elements, self.compute_dtype, self.sym)
available_memory = get_available_memory(self.device)
safe_memory = available_memory * self.memory_safety_factor
if estimated_memory <= safe_memory:
# No chunking needed
self._quantize()
return
# Calculate number of chunks needed
num_chunks = int(np.ceil(estimated_memory / safe_memory))
original_shape = self.weight.shape
# Split along the first dimension (output channels)
chunk_size = max(1, original_shape[0] // num_chunks)
num_chunks = (original_shape[0] + chunk_size - 1) // chunk_size
# Collect results
W_q_list = []
scale_list = []
zero_list = []
for i in range(num_chunks):
start_idx = i * chunk_size
end_idx = min((i + 1) * chunk_size, original_shape[0])
chunk_weight = self.weight[start_idx:end_idx].contiguous()
# Quantize this chunk
chunk_quantizer = HQQQuantizer(
chunk_weight,
self.bit,
self.group_size,
self.sym,
self.compute_dtype,
self.device,
auto_chunk=False # Disable recursive chunking
)
chunk_quantizer._quantize()
W_q_list.append(chunk_quantizer.W_q)
scale_list.append(chunk_quantizer.meta['scale'])
if not self.sym:
zero_list.append(chunk_quantizer.meta['zero'])
# Clean up chunk memory
del chunk_weight, chunk_quantizer
if self.device.type == 'cuda':
torch.cuda.empty_cache()
elif self.device.type == 'mps':
torch.mps.empty_cache()
# Concatenate results
self.W_q = torch.cat(W_q_list, dim=0)
self.meta = {
"nbits": self.bit,
"group_size": self.group_size,
"shape": original_shape,
"scale": torch.cat(scale_list, dim=0),
"zero": torch.cat(zero_list, dim=0) if not self.sym else None,
"axis": 1,
}
# Clean up
del W_q_list, scale_list, zero_list
if self.device.type == 'cuda':
torch.cuda.empty_cache()
elif self.device.type == 'mps':
torch.mps.empty_cache()
@torch.inference_mode()
def _quantize(
self,
channel_wise: bool = True,
axis: int = 1,
) -> tuple:
if self.group_size is not None:
assert self.weight.numel() % self.group_size == 0, (
"group_size should be divisble by the total tensor dimensions. shape: "
+ str(self.weight.shape)
+ ", group_size: "
+ str(self.group_size)
)
W = self.weight.to(self.compute_dtype).float()
shape = W.shape
# Reshape for grouping
if (self.group_size is not None) and channel_wise:
W = (
W.reshape([-1, self.group_size])
if (axis == 1)
else W.reshape([self.group_size, -1])
)
# Get min/max values
if not channel_wise:
_min, _max = W.min(), W.max()
else:
_min = W.min(axis=axis, keepdim=True)[0]
_max = W.max(axis=axis, keepdim=True)[0]
if self.sym:
max_v = 2**(self.bit-1) - 1 # 4bit: 7
min_v = -2**(self.bit-1) # 4bit: -8
min_max = [min_v, max_v] # [-8, 7]
max_abs = torch.max(torch.abs(_min), torch.abs(_max))
scale = max_v / max_abs
scale = torch.where(max_abs <= 1e-4, torch.full_like(scale, 1.0), scale)
scale = scale.clamp(max=2e4)
zero = None
else:
max_v = round(2**self.bit - 1) # 4bit: 15
min_v = 0 # 4bit: 0
min_max = [min_v, max_v] # [0, 15]
denom = (_max - _min)
scale = (max_v / denom)
scale = torch.where(denom.abs() <= 1e-4, torch.full_like(scale, 1.0), scale)
scale = scale.clamp(max=2e4)
zero = -_min * scale
zero = torch.round(zero)
W_q, scale, zero = self._optimize_weights(
W,
scale,
zero,
min_max=min_max,
axis=axis,
)
#W_q = (W * scale).round_().clamp_(min_max[0], min_max[1])
# cleanup
del W, _min, _max
# Store meta-data (we invert the scale for dequantization)
scale = 1.0 / scale
meta = {
"nbits": self.bit,
"group_size": self.group_size,
"shape": shape,
"scale": scale,
"zero": zero,
"axis": axis,
}
W_q = W_q.to(self.weight.dtype)
if self.device == torch.device('cuda'):
torch.cuda.empty_cache()
elif self.device == torch.device('mps'):
torch.mps.empty_cache()
self.W_q = W_q
self.meta = meta
@torch.inference_mode()
def _optimize_weights(
self,
W: torch.Tensor,
scale: torch.Tensor,
zero: torch.Tensor,
min_max: list,
axis: int = 0,
opt_params: dict = {"lp_norm": 0.7, "beta": 1e1, "kappa": 1.01, "iters": 20},
verbose: bool = False,
) -> tuple:
lp_norm, beta, kappa, iters = (
opt_params["lp_norm"],
opt_params["beta"],
opt_params["kappa"],
opt_params["iters"],
)
dtype = torch.float32
W_f = W.to(dtype=dtype, device=self.device)
scale = scale.to(dtype=dtype, device=self.device)
if not self.sym:
zero = zero.to(dtype=dtype, device=self.device)
best_error = torch.tensor(torch.inf, dtype=torch.float32, device=self.device)
best_scale = scale.clone()
best_zero = zero.clone() if not self.sym else None
W_q = torch.empty_like(W_f)
W_r = torch.empty_like(W_f)
W_e = torch.empty_like(W_f)
W_prime = torch.empty_like(W_f) if self.sym else None
for i in range(iters):
if not self.sym:
self._optimize_weights_proximal_legacy_step(W_f, scale, zero, min_max, beta, lp_norm, axis, W_q, W_r, W_e)
else:
self._optimize_weights_proximal_scale_only(W_f, scale, min_max, beta, lp_norm, axis, W_q, W_r, W_e, W_prime)
current_error = torch.abs(W_f - W_r).mean().float()
if verbose:
print(i, current_error.cpu())
# Check for NaN in error or scale - restore best and stop
if torch.isnan(current_error) or torch.any(torch.isnan(scale)) or (not self.sym and torch.any(torch.isnan(zero))):
scale.copy_(best_scale)
if not self.sym:
zero.copy_(best_zero)
break
if current_error < best_error:
best_error = current_error
best_scale.copy_(scale)
if not self.sym:
best_zero.copy_(zero)
else:
scale.copy_(best_scale)
if not self.sym:
zero.copy_(best_zero)
break
scale = scale.to(W.device)
if not self.sym:
zero = zero.to(W.device)
del W_f, W_q, W_r, best_scale, best_zero
if self.device.type == 'cuda':
torch.cuda.empty_cache()
elif self.device.type == 'mps':
torch.mps.empty_cache()
if not self.sym:
W_q = torch.round(W * scale + zero).clamp_(min_max[0], min_max[1])
else:
W_q = torch.round(W * scale).clamp_(min_max[0], min_max[1])
return W_q, scale, zero
@torch.inference_mode()
def _optimize_weights_proximal_legacy_step(self, W_f, scale, zero, min_max, beta, lp_norm, axis, W_q, W_r, W_e):
_eps = 1e-8
torch.mul(W_f, scale, out=W_q)
torch.add(W_q, zero, out=W_q)
torch.round(W_q, out=W_q).clamp_(min_max[0], min_max[1])
torch.sub(W_q, zero, out=W_r)
# Guard against division by zero
safe_scale = torch.where(scale.abs() < _eps, torch.full_like(scale, _eps), scale)
torch.div(W_r, safe_scale, out=W_r)
torch.sub(W_f, W_r, out=W_e)
self._shrink_lp_op(W_e, beta, lp_norm, out=W_e)
torch.sub(W_f, W_e, out=W_r)
torch.mul(W_r, scale, out=W_r)
torch.sub(W_q, W_r, out=W_r)
torch.mean(W_r, axis=axis, keepdim=True, out=zero)
@torch.inference_mode()
def _optimize_weights_proximal_scale_only(self, W_f, scale, min_max, beta, lp_norm, axis, W_q, W_r, W_e, W_prime):
_eps = 1e-8
torch.mul(W_f, scale, out=W_q)
torch.round(W_q, out=W_q).clamp_(min_max[0], min_max[1])
# Guard against division by zero: replace zero scale with eps
safe_scale = torch.where(scale.abs() < _eps, torch.full_like(scale, _eps), scale)
torch.div(W_q, safe_scale, out=W_r)
torch.sub(W_f, W_r, out=W_e)
self._shrink_lp_op(W_e, beta, lp_norm, out=W_e)
torch.sub(W_f, W_e, out=W_prime)
w_prime_dot_w_q = torch.sum(W_prime * W_q, axis=axis, keepdim=True)
w_q_norm_sq = torch.sum(W_q**2, axis=axis, keepdim=True)
torch.add(w_prime_dot_w_q, _eps, out=w_prime_dot_w_q)
torch.div(w_q_norm_sq, w_prime_dot_w_q, out=scale)
# Clamp scale to prevent zero or negative values that cause NaN in next iteration
scale.clamp_(min=_eps)
# Shrinking operator
@torch.inference_mode()
def _shrink_lp_op(self, x: torch.Tensor, beta: float, lp_norm: float, out: torch.Tensor) -> torch.Tensor:
if lp_norm == 1:
#torch.sign(x) * torch.nn.functional.relu(torch.abs(x) - 1.0 / beta)
torch.abs(x, out=out)
out.sub_(1.0 / beta).clamp_min_(0.0)
out.mul_(torch.sign(x))
return out
else:
# Original formula: sign(x) * relu(|x| - (1/beta) * |x|^(lp_norm-1))
# Note: This formula inherently requires a temporary tensor for lp_norm != 1
# because we need both |x| and |x|^(lp_norm-1) simultaneously
torch.abs(x, out=out)
temp = out.pow(lp_norm - 1)
temp.mul_(1.0 / beta)
out.sub_(temp).clamp_min_(0.0)
out.mul_(torch.sign(x))
del temp
return out