File size: 15,381 Bytes
e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 82adbbb e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 e9c8366 056d296 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | """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:
# Per-head override for matching module names.
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 # type: ignore[attr-defined]
model._quantized_format = format # type: ignore[attr-defined]
# Log summary.
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
# ---------------------------------------------------------------------------
# Save / Load — persist a FULL quantized model (v3_hybrid_state format).
#
# save_model(model, path): saves QuantizedModule.to_dict() for quantized
# layers + native_state (all non-quantized params/buffers) for the rest.
#
# load_model(model, path): fills an ALREADY created + quantized model with
# weights from the file. Replaces QuantizedModule instances via from_dict
# (packed buffers are format-specific, load_state_dict cannot handle them).
# Fills native params/buffers via load_state_dict(strict=False).
#
# Format: "agiws_neural_quant_v3_hybrid_state" (breaking change from v2).
# v2 is rejected with a clear error (no backward compat).
# ---------------------------------------------------------------------------
# Module types registered as quantizable (used to distinguish weight buffers).
_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] = {}
# Parameters.
for name, param in model.named_parameters():
# Skip if this param belongs to a QuantizedModule.
if _belongs_to_quantized(name, quantized_paths):
continue
native[name] = param.detach().cpu().clone()
# Buffers.
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 from first QuantizedModule (all should match).
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"]
# 1. Replace QuantizedModule instances with from_dict versions.
for dotted_path, qm_dict in quantized_modules.items():
qm = QuantizedModule.from_dict(qm_dict)
_set_module_by_path(model, dotted_path, qm)
# 2. Fill native params/buffers.
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]:
# ModuleList indices are accessed via int indexing.
if part.isdigit():
parent = parent[int(part)]
else:
parent = getattr(parent, part)
last = parts[-1]
if last.isdigit():
parent[int(last)] = new_module # type: ignore[index]
else:
setattr(parent, last, new_module) |