| """dispatch — unified quantize_model router + recursive walker. |
| |
| quantize_model(model, format=..., skip_types=..., exclude_modules=..., |
| dual_path=False, teacher_format=None, chunk_size=1024, |
| adaptive=False, **kwargs) |
| |
| Routes format string → Quantizer preset + walker. Supports dual-path |
| (teacher QuantizedModule for cross-quantization distillation). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import fnmatch |
| from typing import Any |
|
|
| import torch |
| import torch.nn as nn |
|
|
| from agiws_neural_quant.base import QuantizedModule, SUPPORTED_MODULE_TYPES |
| from agiws_neural_quant.quantizer import Quantizer |
| from agiws_neural_quant.presets import get_preset |
|
|
|
|
| def make_quantizer(format: str, **kwargs) -> Quantizer: |
| """Build a Quantizer from a format string + kwargs.""" |
| preset_kwargs = get_preset(format, **kwargs) |
| return Quantizer(**preset_kwargs) |
|
|
|
|
| def _matches_any(name: str, patterns: set[str]) -> bool: |
| for p in patterns: |
| if fnmatch.fnmatch(name, p): |
| return True |
| return False |
|
|
|
|
| def _walk( |
| module: nn.Module, |
| quantizer: Quantizer, |
| teacher_quantizer: Quantizer | None, |
| skip_types: tuple[type, ...], |
| exclude_modules: set[str], |
| compute_dtype: str, |
| chunk_size: int | None, |
| adaptive: bool, |
| prefix: str, |
| replaced: list[str], |
| per_head_set: set[str] | None = None, |
| num_heads: int = 0, |
| head_dim: int = 0, |
| ) -> None: |
| for name, child in list(module.named_children()): |
| full = f"{prefix}.{name}" if prefix else name |
| if _matches_any(full, exclude_modules) or _matches_any(name, exclude_modules): |
| continue |
| if isinstance(child, skip_types): |
| continue |
| if isinstance(child, QuantizedModule): |
| continue |
| if isinstance(child, SUPPORTED_MODULE_TYPES) and hasattr(child, "weight") and child.weight is not None: |
| |
| use_quantizer = quantizer |
| use_teacher = teacher_quantizer |
| if per_head_set and (name in per_head_set or _matches_any(name, per_head_set)): |
| qcfg = quantizer.to_config() |
| qcfg["scale_mode"] = "per-head" |
| qcfg["num_heads"] = num_heads |
| qcfg["head_dim"] = head_dim |
| use_quantizer = Quantizer(**qcfg) |
| if teacher_quantizer is not None: |
| tcfg = teacher_quantizer.to_config() |
| tcfg["scale_mode"] = "per-head" |
| tcfg["num_heads"] = num_heads |
| tcfg["head_dim"] = head_dim |
| use_teacher = Quantizer(**tcfg) |
| teacher = None |
| if use_teacher is not None: |
| teacher = QuantizedModule.from_module( |
| child, use_teacher, compute_dtype=compute_dtype, |
| chunk_size=chunk_size, adaptive=adaptive, |
| ) |
| qm = QuantizedModule.from_module( |
| child, use_quantizer, compute_dtype=compute_dtype, |
| chunk_size=chunk_size, adaptive=adaptive, teacher=teacher, |
| ) |
| setattr(module, name, qm) |
| replaced.append(full) |
| continue |
| _walk(child, quantizer, teacher_quantizer, skip_types, exclude_modules, |
| compute_dtype, chunk_size, adaptive, full, replaced, |
| per_head_set, num_heads, head_dim) |
|
|
|
|
| def quantize_model( |
| model: nn.Module, |
| format: str = "int8", |
| skip_types: tuple[type, ...] | None = None, |
| exclude_modules: list[str] | None = None, |
| compute_dtype: str = "fp32", |
| chunk_size: int | None = 1024, |
| adaptive: bool = False, |
| dual_path: bool = False, |
| teacher_format: str | None = None, |
| per_head_modules: list[str] | None = None, |
| num_heads: int = 0, |
| head_dim: int = 0, |
| **kwargs, |
| ) -> nn.Module: |
| """Quantize a PyTorch model in-place via the unified QuantizedModule. |
| |
| Args: |
| model: any nn.Module (walked recursively). |
| format: quantization format string (see presets.FORMAT_PRESETS). |
| skip_types: module types to leave untouched. |
| exclude_modules: glob patterns of module names to skip. |
| compute_dtype: 'fp32' | 'fp16' | 'bf16' for dequantized matmul. |
| chunk_size: output-dim chunk for dequant (None = no chunking). |
| adaptive: if True, AdaptiveChunkSize monitor adjusts chunk_size. |
| dual_path: if True, create teacher QuantizedModule alongside student. |
| teacher_format: format string for teacher (if dual_path). If None and |
| dual_path=True, uses "fp16" (passthrough). |
| per_head_modules: module name patterns to use per-head scale_mode |
| (e.g. ["q_proj", "k_proj", "v_proj", "o_proj"]). |
| num_heads: number of attention heads (for per-head scale_mode). |
| head_dim: dimension per head (for per-head scale_mode). |
| **kwargs: forwarded to Quantizer (override preset values). |
| """ |
| quantizer = make_quantizer(format, **kwargs) |
| teacher_quantizer = None |
| if dual_path: |
| t_fmt = teacher_format or "fp16" |
| teacher_quantizer = make_quantizer(t_fmt) |
| skip_types_t = tuple(skip_types) if skip_types else () |
| exclude = set(exclude_modules) if exclude_modules else set() |
| per_head_set = set(per_head_modules) if per_head_modules else set() |
| replaced: list[str] = [] |
| _walk(model, quantizer, teacher_quantizer, skip_types_t, exclude, |
| compute_dtype, chunk_size, adaptive, "", replaced, |
| per_head_set, num_heads, head_dim) |
| model._quantized_replaced = replaced |
| model._quantized_format = format |
| |
| qinfo = quantizer.info() |
| print(f"[NeuralQuant] quantize_model: format={format} repr={qinfo['repr']} " |
| f"bits={qinfo['bits']} scale={qinfo['scale']} group={qinfo['group']} " |
| f"w=True a={qinfo['a']} learnable={qinfo['learnable']} " |
| f"replaced={len(replaced)} modules", flush=True) |
| if per_head_set: |
| ph_count = sum(1 for r in replaced if any(p in r for p in per_head_set)) |
| print(f"[NeuralQuant] per-head: {ph_count} modules (head_dim={head_dim}, " |
| f"num_heads={num_heads})", flush=True) |
| return model |
|
|
|
|
| def count_quantizable_layers(model: nn.Module) -> dict[str, int]: |
| counts: dict[str, int] = {} |
| for module in model.modules(): |
| if isinstance(module, SUPPORTED_MODULE_TYPES) and hasattr(module, "weight") and module.weight is not None: |
| key = type(module).__name__ |
| counts[key] = counts.get(key, 0) + 1 |
| return counts |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| _WEIGHT_MODULE_TYPES = (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d, |
| nn.ConvTranspose1d, nn.ConvTranspose2d, nn.ConvTranspose3d, |
| nn.Embedding, nn.LayerNorm, nn.Bilinear) |
|
|
|
|
| def _collect_quantized_paths(model: nn.Module) -> list[str]: |
| """Return dotted paths of all QuantizedModule instances in the model.""" |
| paths: list[str] = [] |
| for name, mod in model.named_modules(): |
| if isinstance(mod, QuantizedModule): |
| paths.append(name) |
| return paths |
|
|
|
|
| def _walk_quantized( |
| module: nn.Module, |
| prefix: str, |
| out: dict[str, dict], |
| ) -> None: |
| """Collect to_dict() for every QuantizedModule, keyed by dotted path.""" |
| for name, child in list(module.named_children()): |
| full = f"{prefix}.{name}" if prefix else name |
| if isinstance(child, QuantizedModule): |
| out[full] = child.to_dict() |
| else: |
| _walk_quantized(child, full, out) |
|
|
|
|
| def _collect_native_state(model: nn.Module, quantized_paths: set[str]) -> dict[str, torch.Tensor]: |
| """Collect all non-quantized parameters and buffers. |
| |
| Excludes any param/buffer that belongs to a QuantizedModule (those are |
| serialized separately in quantized_modules). Uses standard PyTorch |
| dotted-path keys (same as model.state_dict()). |
| """ |
| native: dict[str, torch.Tensor] = {} |
| |
| for name, param in model.named_parameters(): |
| |
| if _belongs_to_quantized(name, quantized_paths): |
| continue |
| native[name] = param.detach().cpu().clone() |
| |
| for name, buf in model.named_buffers(): |
| if _belongs_to_quantized(name, quantized_paths): |
| continue |
| if buf is None: |
| continue |
| native[name] = buf.detach().cpu().clone() |
| return native |
|
|
|
|
| def _belongs_to_quantized(dotted_name: str, quantized_paths: set[str]) -> bool: |
| """Check if a dotted-path param/buffer name belongs to a QuantizedModule. |
| |
| A param at "blocks.0.attn.q_proj.weight" belongs to the QuantizedModule at |
| "blocks.0.attn.q_proj" if that path is in quantized_paths. |
| """ |
| for qpath in quantized_paths: |
| if dotted_name == qpath or dotted_name.startswith(qpath + "."): |
| return True |
| return False |
|
|
|
|
| def save_model(model: nn.Module, path: str) -> None: |
| """Save a FULL quantized model to a .pt file (torch.save). |
| |
| Saves: |
| - quantized_modules: {dotted_path: QuantizedModule.to_dict()} for every |
| QuantizedModule in the model (packed weight buffers + meta + config). |
| - native_state: {dotted_path: tensor} for all non-quantized parameters |
| and buffers (custom layers, embeddings, positional encodings, etc.). |
| - quantized_paths: list of dotted paths of all QuantizedModule instances. |
| - quant_format: the format string used for quantization. |
| - compute_dtype: target compute dtype (from first QuantizedModule). |
| |
| Format: "agiws_neural_quant_v3_hybrid_state". |
| |
| Args: |
| model: a quantized model (after quantize_model). |
| path: output .pt file path. |
| """ |
| quantized: dict[str, dict] = {} |
| _walk_quantized(model, "", quantized) |
| quantized_paths = _collect_quantized_paths(model) |
| native_state = _collect_native_state(model, set(quantized_paths)) |
|
|
| |
| compute_dtype = "fp32" |
| if quantized: |
| first_path = next(iter(quantized)) |
| compute_dtype = quantized[first_path].get("compute_dtype", "fp32") |
|
|
| quant_format = getattr(model, "_quantized_format", "unknown") |
|
|
| payload = { |
| "format": "agiws_neural_quant_v3_hybrid_state", |
| "quant_format": quant_format, |
| "compute_dtype": compute_dtype, |
| "quantized_paths": quantized_paths, |
| "quantized_modules": quantized, |
| "native_state": native_state, |
| "model_config": getattr(model, "_nq_model_config", None), |
| } |
| torch.save(payload, path) |
|
|
|
|
| def load_model(model: nn.Module, path: str) -> None: |
| """Load weights from a save file into an ALREADY created + quantized model. |
| |
| The model must be created by the user (via their factory) and quantized |
| via quantize_model() BEFORE calling load_model. This function: |
| 1. Replaces each QuantizedModule in the model with QuantizedModule.from_dict() |
| from the file (correct packed weight buffers). |
| 2. Fills non-quantized parameters/buffers via load_state_dict(native_state, |
| strict=False) and reports missing/unexpected keys. |
| |
| Why from_dict + setattr instead of load_state_dict into existing QuantizedModule: |
| QuantizedModule stores packed weight buffers (int4 codes, FP8 scale codes, |
| codebook indices, etc.) — these are NOT raw float weights. load_state_dict |
| cannot reconstruct the packed format from a state_dict. from_dict rebuilds |
| the QuantizedModule with the exact buffers + meta + quantizer config from |
| the file, producing identical dequantized weights. |
| |
| Args: |
| model: an already-created, already-quantized model. Will be modified |
| in-place (QuantizedModule instances replaced, native params filled). |
| path: .pt file saved by save_model or convert_model. |
| |
| Raises: |
| ValueError: if the file is not a v3_hybrid_state file, or if it's an |
| old v2 file (with guidance to re-quantize). |
| """ |
| payload = torch.load(path, map_location="cpu", weights_only=False) |
| if not isinstance(payload, dict): |
| raise ValueError(f"load_model: not a valid NeuralQuant save file: {path}") |
| fmt = payload.get("format") |
| if fmt == "agiws_neural_quant_v2": |
| raise ValueError( |
| f"load_model: file {path} uses old format 'agiws_neural_quant_v2' " |
| f"(pre-0.3.0). This format is no longer supported. " |
| f"Re-quantize your model with NeuralQuant >= 0.3.0 and save again." |
| ) |
| if fmt != "agiws_neural_quant_v3_hybrid_state": |
| raise ValueError( |
| f"load_model: unsupported format {fmt!r}. " |
| f"Expected 'agiws_neural_quant_v3_hybrid_state'." |
| ) |
|
|
| quantized_modules = payload["quantized_modules"] |
| native_state = payload["native_state"] |
|
|
| |
| for dotted_path, qm_dict in quantized_modules.items(): |
| qm = QuantizedModule.from_dict(qm_dict) |
| _set_module_by_path(model, dotted_path, qm) |
|
|
| |
| if native_state: |
| missing, unexpected = model.load_state_dict(native_state, strict=False) |
| if missing: |
| print(f"[NeuralQuant] load_model: {len(missing)} missing keys " |
| f"(not in saved native_state): {missing[:5]}{'...' if len(missing) > 5 else ''}", |
| flush=True) |
| if unexpected: |
| print(f"[NeuralQuant] load_model: {len(unexpected)} unexpected keys " |
| f"(in saved file but not in model): {unexpected[:5]}{'...' if len(unexpected) > 5 else ''}", |
| flush=True) |
|
|
|
|
| def _set_module_by_path(root: nn.Module, dotted_path: str, new_module: nn.Module) -> None: |
| """Set a module at a dotted path within root (e.g. 'blocks.0.attn.q_proj'). |
| |
| Uses standard PyTorch dotted-path convention: split by '.', traverse |
| parent modules, setattr on the parent. |
| """ |
| parts = dotted_path.split(".") |
| parent = root |
| for part in parts[:-1]: |
| |
| if part.isdigit(): |
| parent = parent[int(part)] |
| else: |
| parent = getattr(parent, part) |
| last = parts[-1] |
| if last.isdigit(): |
| parent[int(last)] = new_module |
| else: |
| setattr(parent, last, new_module) |