Files

240 lines
7.8 KiB
Python

"""
Quantization utilities for PersonaPlex models.
Uses PyTorch's native dynamic quantization - no external dependencies.
Works reliably on Windows and Linux.
VRAM Requirements (approximate):
- Full precision (fp16/bf16): ~14GB
- 8-bit quantization: ~8-10GB
- 4-bit quantization: ~5-7GB (uses 8-bit with aggressive compression)
"""
import torch
import torch.nn as nn
from typing import Optional
# PyTorch native quantization is always available
QUANTIZATION_AVAILABLE = True
QUANTIZATION_BACKEND = "pytorch_native"
def check_quantization_available(quantize_type: str) -> bool:
"""Check if the requested quantization type is available."""
if quantize_type in ("none", "None", None, ""):
return True
if not torch.cuda.is_available():
return False
return True
def get_quantization_error() -> str:
"""Get error message explaining why quantization isn't available."""
if not torch.cuda.is_available():
return "CUDA not available. Quantization requires a CUDA-capable GPU."
return "Unknown error"
def get_quantization_backend() -> str:
"""Get the name of the active quantization backend."""
return QUANTIZATION_BACKEND
class QuantizedLinear(nn.Module):
"""
A quantized linear layer using dynamic quantization.
Stores weights in int8 and computes in fp16.
"""
def __init__(self, in_features: int, out_features: int, bias: bool = True,
quantize_type: str = "8bit"):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.quantize_type = quantize_type
# Register quantized weight storage
self.register_buffer('weight_quantized', torch.zeros(out_features, in_features, dtype=torch.int8))
self.register_buffer('weight_scale', torch.zeros(out_features, dtype=torch.float16))
if bias:
self.bias = nn.Parameter(torch.zeros(out_features, dtype=torch.float16))
else:
self.register_parameter('bias', None)
@classmethod
def from_linear(cls, linear: nn.Linear, quantize_type: str = "8bit") -> 'QuantizedLinear':
"""Create a QuantizedLinear from an existing nn.Linear."""
quantized = cls(
linear.in_features,
linear.out_features,
bias=linear.bias is not None,
quantize_type=quantize_type
)
# Get weight in fp32 for quantization
weight = linear.weight.data.float()
# Per-channel quantization (better accuracy than per-tensor)
# Find the max absolute value per output channel
weight_max = weight.abs().max(dim=1, keepdim=True)[0].clamp(min=1e-8)
if quantize_type == "8bit":
# Scale to int8 range [-127, 127]
scale = weight_max / 127.0
weight_quantized = (weight / scale).round().clamp(-127, 127).to(torch.int8)
else: # 4bit - we still store as int8 but use reduced range
# Scale to int4 range [-7, 7], pack later if needed
scale = weight_max / 7.0
weight_quantized = (weight / scale).round().clamp(-7, 7).to(torch.int8)
quantized.weight_quantized.copy_(weight_quantized)
quantized.weight_scale.copy_(scale.squeeze().half())
if linear.bias is not None:
quantized.bias.data.copy_(linear.bias.data.half())
return quantized
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Dequantize weight on-the-fly
weight = self.weight_quantized.float() * self.weight_scale.unsqueeze(1).float()
weight = weight.to(x.dtype)
# Standard linear operation
output = nn.functional.linear(x, weight, self.bias)
return output
def extra_repr(self) -> str:
return f'in_features={self.in_features}, out_features={self.out_features}, bias={self.bias is not None}, quantize={self.quantize_type}'
def quantize_model_inplace(
model: nn.Module,
quantize_type: str = "8bit",
skip_patterns: Optional[list] = None,
min_size: int = 1024,
) -> int:
"""
Replace Linear layers with quantized versions in-place.
Args:
model: The model to quantize
quantize_type: "8bit" or "4bit"
skip_patterns: List of patterns to skip (e.g., ["embed", "lm_head"])
min_size: Minimum layer size to quantize (smaller layers aren't worth it)
Returns:
Number of layers replaced
"""
if skip_patterns is None:
# Skip layers that access .weight directly (gating uses fused ops)
skip_patterns = ["embed", "emb", "lm_head", "out_proj", "output", "norm", "gating", "linear_in", "linear_out"]
replaced = 0
skipped = 0
# Build a list of (parent, attr_name, module) for replacement
replacements = []
for name, module in model.named_modules():
if not isinstance(module, nn.Linear):
continue
# Check if should skip
should_skip = any(pattern in name.lower() for pattern in skip_patterns)
# Skip small layers (overhead not worth it)
layer_size = module.in_features * module.out_features
if layer_size < min_size:
should_skip = True
if should_skip:
skipped += 1
continue
# Find parent module
parts = name.rsplit('.', 1)
if len(parts) == 2:
parent_name, attr_name = parts
parent = dict(model.named_modules())[parent_name]
else:
parent = model
attr_name = name
replacements.append((parent, attr_name, module))
# Perform replacements
for parent, attr_name, module in replacements:
quantized_module = QuantizedLinear.from_linear(module, quantize_type)
setattr(parent, attr_name, quantized_module)
replaced += 1
# Free original weight memory
del module
# Force garbage collection
if replaced > 0:
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
print(f"[PersonaPlex] Quantization ({quantize_type}): {replaced} layers quantized, {skipped} skipped")
return replaced
def quantize_model_after_load(
model: nn.Module,
quantize_type: str,
device: str = "cuda"
) -> nn.Module:
"""
Quantize a model after loading weights.
Args:
model: The loaded model (should be on CPU)
quantize_type: "8bit" or "4bit"
device: Target device
Returns:
The quantized model on the target device
"""
if quantize_type in ("none", "None", None, ""):
return model.to(device)
if not check_quantization_available(quantize_type):
raise RuntimeError(get_quantization_error())
print(f"[PersonaPlex] Applying {quantize_type} quantization (PyTorch native)...")
# Ensure model is on CPU for quantization
model = model.to("cpu")
# Quantize in place
num_replaced = quantize_model_inplace(model, quantize_type)
if num_replaced == 0:
print("[PersonaPlex] Warning: No layers were quantized")
# Move to target device
model = model.to(device)
# Estimate memory savings
param_count = sum(p.numel() for p in model.parameters())
buffer_count = sum(b.numel() for b in model.buffers())
if quantize_type == "8bit":
# int8 weights + fp16 scales + fp16 other params
expected_size_gb = (buffer_count * 1 + param_count * 2) / (1024**3)
elif quantize_type == "4bit":
# Stored as int8 but with 4-bit range
expected_size_gb = (buffer_count * 1 + param_count * 2) / (1024**3)
else:
expected_size_gb = (param_count * 2) / (1024**3)
print(f"[PersonaPlex] Estimated model size: ~{expected_size_gb:.1f}GB")
return model