| """ |
| Frox AI Morph 1.1 β Quantization |
| |
| Morph 1.0's `_load_4bit()` / `_load_8bit()` in the inference engine were |
| stubs: they printed a message and then returned a full-precision model |
| regardless. This module makes quantized loading actually work. |
| |
| Two paths: |
| - bitsandbytes NF4 (QLoRA-style) β best for training/fine-tuning |
| - torchao / weight-only int8 β best for pure inference, no bnb dependency |
| |
| Both operate on an already-constructed MorphForCausalLM by replacing |
| nn.Linear layers in-place, since Morph is a custom architecture (not a |
| HuggingFace AutoModel) and can't go through `from_pretrained(..., |
| quantization_config=...)` directly. |
| """ |
| from __future__ import annotations |
|
|
| from typing import List, Optional |
|
|
| import torch |
| import torch.nn as nn |
|
|
|
|
| |
|
|
| def quantize_4bit( |
| model: nn.Module, |
| compute_dtype: torch.dtype = torch.float16, |
| skip_modules: Optional[List[str]] = None, |
| ) -> nn.Module: |
| """ |
| Replace nn.Linear layers with bitsandbytes Linear4bit (NF4). |
| Embedding and lm_head are skipped by default (quantizing the |
| vocab projection tanks quality for a tiny VRAM saving). |
| """ |
| try: |
| import bitsandbytes as bnb |
| except ImportError: |
| raise ImportError( |
| "bitsandbytes is required for 4-bit quantization. " |
| "Install with: pip install bitsandbytes" |
| ) |
|
|
| skip = set(skip_modules or ["lm_head", "embed_tokens"]) |
| replaced = 0 |
|
|
| def _replace(module: nn.Module, prefix: str = ""): |
| nonlocal replaced |
| for name, child in module.named_children(): |
| full_name = f"{prefix}.{name}" if prefix else name |
| if any(s in full_name for s in skip): |
| continue |
|
|
| if isinstance(child, nn.Linear): |
| new_layer = bnb.nn.Linear4bit( |
| child.in_features, |
| child.out_features, |
| bias=child.bias is not None, |
| compute_dtype=compute_dtype, |
| quant_type="nf4", |
| ) |
| new_layer.weight = bnb.nn.Params4bit( |
| child.weight.data.clone(), |
| requires_grad=False, |
| quant_type="nf4", |
| ) |
| if child.bias is not None: |
| new_layer.bias = nn.Parameter(child.bias.data.clone()) |
| setattr(module, name, new_layer) |
| replaced += 1 |
| else: |
| _replace(child, full_name) |
|
|
| _replace(model) |
| print(f"β 4-bit NF4 quantization applied to {replaced} linear layers") |
| return model |
|
|
|
|
| |
|
|
| class Int8Linear(nn.Module): |
| """ |
| Weight-only int8 linear layer. Weights stored as int8 + per-channel |
| scale; activations stay in the compute dtype. ~4x smaller weights |
| than fp16 with a small quality cost β good for inference on cards |
| without bitsandbytes support (e.g. some ARM / edge deployments). |
| """ |
|
|
| def __init__(self, in_features: int, out_features: int, bias: bool = False): |
| super().__init__() |
| self.in_features = in_features |
| self.out_features = out_features |
| self.register_buffer("weight_int8", torch.zeros(out_features, in_features, dtype=torch.int8)) |
| self.register_buffer("scale", torch.ones(out_features, dtype=torch.float32)) |
| self.bias = nn.Parameter(torch.zeros(out_features)) if bias else None |
|
|
| @classmethod |
| def from_linear(cls, linear: nn.Linear) -> "Int8Linear": |
| layer = cls(linear.in_features, linear.out_features, bias=linear.bias is not None) |
| w = linear.weight.data.float() |
| scale = w.abs().max(dim=1).values / 127.0 |
| scale = scale.clamp(min=1e-8) |
| w_int8 = (w / scale.unsqueeze(1)).round().clamp(-127, 127).to(torch.int8) |
| layer.weight_int8.copy_(w_int8) |
| layer.scale.copy_(scale) |
| if linear.bias is not None: |
| layer.bias.data.copy_(linear.bias.data) |
| return layer |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| w = self.weight_int8.to(x.dtype) * self.scale.unsqueeze(1).to(x.dtype) |
| out = torch.nn.functional.linear(x, w, self.bias) |
| return out |
|
|
|
|
| def quantize_8bit( |
| model: nn.Module, |
| skip_modules: Optional[List[str]] = None, |
| ) -> nn.Module: |
| """Replace nn.Linear layers with weight-only Int8Linear (no bnb needed).""" |
| skip = set(skip_modules or ["lm_head", "embed_tokens"]) |
| replaced = 0 |
|
|
| def _replace(module: nn.Module, prefix: str = ""): |
| nonlocal replaced |
| for name, child in module.named_children(): |
| full_name = f"{prefix}.{name}" if prefix else name |
| if any(s in full_name for s in skip): |
| continue |
|
|
| if isinstance(child, nn.Linear): |
| setattr(module, name, Int8Linear.from_linear(child)) |
| replaced += 1 |
| else: |
| _replace(child, full_name) |
|
|
| _replace(model) |
| print(f"β Weight-only int8 quantization applied to {replaced} linear layers") |
| return model |
|
|
|
|
| |
|
|
| def estimate_memory_footprint( |
| num_params: int, |
| dtype: str = "float16", |
| ) -> dict: |
| """Estimate model weight memory footprint at various precisions.""" |
| bytes_per_param = { |
| "float32": 4, "float16": 2, "bfloat16": 2, |
| "int8": 1, "nf4": 0.5, "int4": 0.5, |
| } |
| b = bytes_per_param.get(dtype, 2) |
| total_bytes = num_params * b |
| return { |
| "dtype": dtype, |
| "params_billions": round(num_params / 1e9, 3), |
| "weights_gb": round(total_bytes / (1024 ** 3), 2), |
| |
| "estimated_inference_vram_gb": round(total_bytes * 1.2 / (1024 ** 3), 2), |
| } |
|
|
|
|
| def print_quantization_report(model: nn.Module, dtype_label: str = "float16"): |
| total_params = sum(p.numel() for p in model.parameters()) |
| footprint = estimate_memory_footprint(total_params, dtype_label) |
| print("\nπ¦ Model Memory Footprint") |
| print(f" Parameters: {footprint['params_billions']}B") |
| print(f" Precision: {footprint['dtype']}") |
| print(f" Weight size: {footprint['weights_gb']} GB") |
| print(f" Est. inference: {footprint['estimated_inference_vram_gb']} GB " |
| f"(weights + activations/KV headroom)\n") |
|
|