Instructions to use Angshul/higher-order-pruner with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Angshul/higher-order-pruner with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Angshul/higher-order-pruner", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download higher_order_pruner/core.py from Angshul/higher-order-pruner: direct link, hf CLI and curl.
- Browser
- Download file 19.4 kB
-
https://huggingface.co/Angshul/higher-order-pruner/resolve/main/higher_order_pruner/core.py
- Command line
-
hf download hf://Angshul/higher-order-pruner/higher_order_pruner/core.py
-
curl -L -o core.py https://huggingface.co/Angshul/higher-order-pruner/resolve/main/higher_order_pruner/core.py
19.4 kB
| """Higher-order, KFAC-diagonal, monotone layerwise pruning. | |
| No model names, transformer attributes, dataset names, or evaluation tasks are | |
| assumed here. The caller owns the loss and supplies calibration batches. | |
| """ | |
| from __future__ import annotations | |
| import gc | |
| import json | |
| import math | |
| import time | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| from typing import Any, Callable, Iterable, Sequence | |
| import torch | |
| from torch import nn | |
| LossFn = Callable[[nn.Module, Any], torch.Tensor] | |
| Group = list[tuple[str, nn.Linear]] | |
| class PruneConfig: | |
| sparsity: float | |
| probes: int = 2 | |
| finite_difference_relative_step: float = 0.02 | |
| cubic_cap: float = 0.5 | |
| damping_ratio: float = 0.01 | |
| trust_ratio: float = 0.01 | |
| backtracking: tuple[float, ...] = (1.0, 0.5, 0.25, 0.125, 0.0625) | |
| seed: int = 91000 | |
| def __post_init__(self) -> None: | |
| if not 0 <= self.sparsity < 1: | |
| raise ValueError("sparsity must lie in [0, 1)") | |
| if self.probes < 1: | |
| raise ValueError("probes must be positive") | |
| if self.finite_difference_relative_step <= 0: | |
| raise ValueError("finite_difference_relative_step must be positive") | |
| if not 0 <= self.cubic_cap <= 1: | |
| raise ValueError("cubic_cap must lie in [0, 1]") | |
| if self.damping_ratio <= 0 or self.trust_ratio <= 0: | |
| raise ValueError("damping_ratio and trust_ratio must be positive") | |
| if not self.backtracking or any(a <= 0 for a in self.backtracking): | |
| raise ValueError("backtracking must contain positive step sizes") | |
| class PruneReport: | |
| target_sparsity: float | |
| achieved_sparsity: float | |
| prunable_weights: int | |
| exact_zero_weights: int | |
| seconds: float | |
| layers: list[dict[str, Any]] | |
| def discover_linear_groups(model: nn.Module, *, module_names: Sequence[str] | None = None) -> list[Group]: | |
| """Prefer the model's repeated block stack; otherwise group linears singly. | |
| module_names may explicitly select nn.Linear module paths. This is the | |
| adapter for unusual architectures (including MoE expert placement). | |
| """ | |
| named = dict(model.named_modules()) | |
| if module_names is not None: | |
| selected = [] | |
| for name in module_names: | |
| if name not in named or not isinstance(named[name], nn.Linear): | |
| raise ValueError(f"Not a Linear module: {name}") | |
| selected.append((name, named[name])) | |
| if len({name for name, _ in selected}) != len(selected): | |
| raise ValueError("module_names contains duplicates") | |
| if not selected: | |
| raise ValueError("No Linear modules selected") | |
| return [[item] for item in selected] | |
| candidates: list[tuple[int, int, str, nn.ModuleList]] = [] | |
| for name, module in named.items(): | |
| if isinstance(module, nn.ModuleList) and len(module) >= 2: | |
| counts = [sum(isinstance(x, nn.Linear) for x in block.modules()) for block in module] | |
| if min(counts) > 0: | |
| candidates.append((sum(counts), len(module), name, module)) | |
| if candidates: | |
| _, _, stack_name, stack = max(candidates, key=lambda x: (x[0], x[1])) | |
| groups: list[Group] = [] | |
| for i, block in enumerate(stack): | |
| prefix = f"{stack_name}.{i}" if stack_name else str(i) | |
| groups.append([(f"{prefix}.{name}" if name else prefix, layer) | |
| for name, layer in block.named_modules() if isinstance(layer, nn.Linear)]) | |
| else: | |
| groups = [[(name, layer)] for name, layer in named.items() if isinstance(layer, nn.Linear)] | |
| seen: set[int] = set() | |
| unique: list[Group] = [] | |
| for group in groups: | |
| items = [] | |
| for name, layer in group: | |
| ident = id(layer.weight) | |
| if ident not in seen: | |
| seen.add(ident) | |
| items.append((name, layer)) | |
| if items: | |
| unique.append(items) | |
| if not unique: | |
| raise ValueError("No unique nn.Linear weights found; select compatible modules explicitly") | |
| return unique | |
| def _loss(model: nn.Module, batch: Any, loss_fn: LossFn) -> torch.Tensor: | |
| value = loss_fn(model, batch) | |
| if not isinstance(value, torch.Tensor) or value.ndim != 0 or not torch.isfinite(value).item(): | |
| raise ValueError("loss_fn must return a finite scalar torch.Tensor") | |
| return value | |
| def _mean_loss(model: nn.Module, batches: Sequence[Any], loss_fn: LossFn) -> float: | |
| with torch.no_grad(): | |
| return sum(float(_loss(model, b, loss_fn).detach()) for b in batches) / len(batches) | |
| def _gradient(model: nn.Module, group: Group, batches: Sequence[Any], loss_fn: LossFn, | |
| base: dict[str, torch.Tensor], u: dict[str, torch.Tensor] | None = None, | |
| v: dict[str, torch.Tensor] | None = None, us: int = 0, vs: int = 0, | |
| steps: dict[str, float] | None = None) -> dict[str, torch.Tensor]: | |
| with torch.no_grad(): | |
| for name, layer in group: | |
| if u is not None and v is not None and steps is not None: | |
| candidate = base[name].float() + steps[name] * (us * u[name].float() + vs * v[name].float()) | |
| layer.weight.copy_(candidate.to(layer.weight.dtype)) | |
| layer.weight.requires_grad_(True) | |
| sums = {name: torch.zeros_like(layer.weight, dtype=torch.float32) for name, layer in group} | |
| try: | |
| for batch in batches: | |
| model.zero_grad(set_to_none=True) | |
| _loss(model, batch, loss_fn).backward() | |
| for name, layer in group: | |
| if layer.weight.grad is None: | |
| raise RuntimeError(f"Selected module has no loss gradient: {name}") | |
| sums[name].add_(layer.weight.grad.detach().float()) | |
| return {name: value / len(batches) for name, value in sums.items()} | |
| finally: | |
| model.zero_grad(set_to_none=True) | |
| with torch.no_grad(): | |
| for name, layer in group: | |
| layer.weight.copy_(base[name]) | |
| layer.weight.requires_grad_(False) | |
| def _kfac_diagonal(model: nn.Module, group: Group, batches: Sequence[Any], | |
| loss_fn: LossFn) -> dict[str, tuple[torch.Tensor, torch.Tensor]]: | |
| """Collect E[output-gradient²] and E[input²] on the current model.""" | |
| stats: dict[str, dict[str, Any]] = { | |
| name: {"a": torch.zeros(layer.in_features, device=layer.weight.device, dtype=torch.float32), | |
| "g": torch.zeros(layer.out_features, device=layer.weight.device, dtype=torch.float32), | |
| "na": 0, "ng": 0} | |
| for name, layer in group | |
| } | |
| hooks = [] | |
| def make_hook(name: str): | |
| def forward_hook(_module: nn.Module, inputs: tuple[Any, ...], output: torch.Tensor) -> None: | |
| x = inputs[0].detach().reshape(-1, stats[name]["a"].numel()).float() | |
| stats[name]["a"].add_(x.square().sum(dim=0)) | |
| stats[name]["na"] += x.shape[0] | |
| def backward_hook(grad: torch.Tensor) -> None: | |
| y = grad.detach().reshape(-1, stats[name]["g"].numel()).float() | |
| stats[name]["g"].add_(y.square().sum(dim=0)) | |
| stats[name]["ng"] += y.shape[0] | |
| output.register_hook(backward_hook) | |
| return forward_hook | |
| try: | |
| for name, layer in group: | |
| layer.weight.requires_grad_(True) | |
| hooks.append(layer.register_forward_hook(make_hook(name))) | |
| for batch in batches: | |
| model.zero_grad(set_to_none=True) | |
| _loss(model, batch, loss_fn).backward() | |
| answer = {} | |
| for name, item in stats.items(): | |
| if not item["na"] or not item["ng"]: | |
| raise RuntimeError(f"No activation/gradient statistics for {name}") | |
| answer[name] = (item["g"] / item["ng"], item["a"] / item["na"]) | |
| return answer | |
| finally: | |
| for hook in hooks: | |
| hook.remove() | |
| model.zero_grad(set_to_none=True) | |
| for _, layer in group: | |
| layer.weight.requires_grad_(False) | |
| def _higher_order_diagonal(model: nn.Module, group: Group, batches: Sequence[Any], loss_fn: LossFn, | |
| cfg: PruneConfig, group_index: int) -> dict[str, torch.Tensor]: | |
| base = {name: layer.weight.detach().clone() for name, layer in group} | |
| estimate = {name: torch.zeros_like(layer.weight, dtype=torch.float32) for name, layer in group} | |
| gen = torch.Generator(device=group[0][1].weight.device) | |
| gen.manual_seed(cfg.seed + group_index) | |
| for _ in range(cfg.probes): | |
| u = {name: torch.randint(0, 2, layer.weight.shape, device=layer.weight.device, | |
| dtype=torch.int8, generator=gen).mul_(2).sub_(1) | |
| for name, layer in group} | |
| v = {name: torch.randint(0, 2, layer.weight.shape, device=layer.weight.device, | |
| dtype=torch.int8, generator=gen).mul_(2).sub_(1) | |
| for name, layer in group} | |
| steps = {name: max(cfg.finite_difference_relative_step * | |
| base[name].float().square().mean().sqrt().item(), 1e-6) | |
| for name, _ in group} | |
| for us, vs, sign in ((1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)): | |
| grads = _gradient(model, group, batches, loss_fn, base, u, v, us, vs, steps) | |
| for name in grads: | |
| estimate[name].add_(grads[name] * u[name].float() * v[name].float(), | |
| alpha=sign / (4 * steps[name] ** 2 * cfg.probes)) | |
| del grads | |
| del u, v | |
| for name in estimate: | |
| sample = estimate[name].reshape(-1)[::max(1, estimate[name].numel() // 131072)] | |
| limit = 10 * sample.abs().median().clamp_min(1e-12) | |
| estimate[name].clamp_(-limit, limit) | |
| return estimate | |
| def _budgets(groups: list[Group], sparsity: float) -> list[int]: | |
| sizes = [sum(layer.weight.numel() for _, layer in group) for group in groups] | |
| wanted = round(sum(sizes) * sparsity) | |
| existing = [sum(int((layer.weight.detach() == 0).sum().item()) | |
| for _, layer in group) for group in groups] | |
| if sum(existing) > wanted: | |
| raise ValueError( | |
| f"The selected weights already contain {sum(existing)} exact zeros, " | |
| f"which exceeds the requested global budget {wanted}." | |
| ) | |
| exact = [size * sparsity for size in sizes] | |
| quotas = [max(existing[i], math.floor(exact[i])) for i in range(len(groups))] | |
| if sum(quotas) > wanted: | |
| # A group with many pre-existing zeros borrows budget from groups that | |
| # are still above their immutable lower bound. | |
| order = sorted(range(len(groups)), key=lambda i: (quotas[i] - existing[i], i), reverse=True) | |
| remaining = sum(quotas) - wanted | |
| for i in order: | |
| take = min(remaining, quotas[i] - existing[i]) | |
| quotas[i] -= take | |
| remaining -= take | |
| if remaining == 0: | |
| break | |
| else: | |
| order = sorted(range(len(groups)), key=lambda i: (exact[i] - math.floor(exact[i]), -i), reverse=True) | |
| remaining = wanted - sum(quotas) | |
| for i in order: | |
| if remaining == 0: | |
| break | |
| if quotas[i] < sizes[i]: | |
| quotas[i] += 1 | |
| remaining -= 1 | |
| # For unusual pre-existing-zero distributions, the fractional pass | |
| # may not consume the whole budget. Fill deterministically. | |
| if remaining: | |
| for i in range(len(groups)): | |
| take = min(remaining, sizes[i] - quotas[i]) | |
| quotas[i] += take | |
| remaining -= take | |
| if remaining == 0: | |
| break | |
| if sum(quotas) != wanted: | |
| raise RuntimeError("Could not distribute the exact global sparsity budget") | |
| return quotas | |
| def _mask_group(group: Group, factors: dict[str, tuple[torch.Tensor, torch.Tensor]], | |
| higher_order: dict[str, torch.Tensor], quota: int, cfg: PruneConfig): | |
| scores = [] | |
| existing = [] | |
| sizes = [] | |
| for name, layer in group: | |
| w = layer.weight.detach().float() | |
| g, a = factors[name] | |
| h = g[:, None] * a[None, :] | |
| quadratic = 0.5 * h * w.square() | |
| cubic_raw = -higher_order[name] * w.pow(3) / 6 | |
| cubic_limit = cfg.cubic_cap * quadratic.abs() | |
| cubic = torch.maximum(torch.minimum(cubic_raw, cubic_limit), -cubic_limit) | |
| score = (quadratic + cubic).clamp_min_(0).reshape(-1) | |
| score = torch.nan_to_num(score, nan=float("inf"), posinf=float("inf")) | |
| scores.append(score) | |
| existing.append((layer.weight.detach().reshape(-1) == 0)) | |
| sizes.append(layer.weight.numel()) | |
| joint = torch.cat(scores) | |
| already = torch.cat(existing) | |
| n_zero = int(already.sum().item()) | |
| if quota < n_zero: | |
| raise ValueError(f"Target quota {quota} is below {n_zero} pre-existing exact zeros") | |
| additional = quota - n_zero | |
| available = torch.nonzero(~already).flatten() | |
| if additional > available.numel(): | |
| raise RuntimeError("Insufficient nonzero weights to meet the sparsity target") | |
| chosen = available[torch.topk(joint[available], k=additional, largest=False, sorted=False).indices] \ | |
| if additional else available[:0] | |
| mask = already.clone() | |
| mask[chosen] = True | |
| support = {} | |
| base = {} | |
| offset = 0 | |
| with torch.no_grad(): | |
| for (name, layer), length in zip(group, sizes): | |
| zero = mask[offset:offset + length].reshape_as(layer.weight) | |
| layer.weight[zero] = 0 | |
| support[name] = ~zero | |
| base[name] = layer.weight.detach().clone() | |
| offset += length | |
| return support, base | |
| def _refine(model: nn.Module, group: Group, factors: dict[str, tuple[torch.Tensor, torch.Tensor]], | |
| higher_order: dict[str, torch.Tensor], support: dict[str, torch.Tensor], | |
| base: dict[str, torch.Tensor], train: Sequence[Any], validation: Sequence[Any], | |
| loss_fn: LossFn, cfg: PruneConfig) -> tuple[float, float, float]: | |
| before = _mean_loss(model, validation, loss_fn) | |
| gradients = _gradient(model, group, train, loss_fn, base) | |
| directions = {} | |
| for name, layer in group: | |
| g, a = factors[name] | |
| h = g[:, None] * a[None, :] | |
| h = h + cfg.damping_ratio * h.mean().clamp_min(1e-12) | |
| t = higher_order[name] | |
| grad = gradients[name] | |
| discriminant = h.square() - 2 * t * grad | |
| root = discriminant.clamp_min(0).sqrt() | |
| linear_step = -grad / h | |
| cubic_step = -2 * grad / (h + root).clamp_min(1e-20) | |
| direction = torch.where((discriminant > 0) & (t.abs() > 1e-12) & | |
| torch.isfinite(cubic_step), cubic_step, linear_step) | |
| direction.masked_fill_(~support[name], 0) | |
| radius = cfg.trust_ratio * layer.weight.detach().float()[support[name]].norm() | |
| norm = direction[support[name]].norm() | |
| if norm > radius: | |
| direction.mul_(radius / norm.clamp_min(1e-12)) | |
| directions[name] = direction | |
| accepted = 0.0 | |
| after = before | |
| with torch.no_grad(): | |
| for alpha in cfg.backtracking: | |
| for name, layer in group: | |
| candidate = base[name].float() + alpha * directions[name] | |
| candidate.masked_fill_(~support[name], 0) | |
| layer.weight.copy_(candidate.to(layer.weight.dtype)) | |
| loss = _mean_loss(model, validation, loss_fn) | |
| if loss <= before + 1e-5: | |
| accepted, after = alpha, loss | |
| break | |
| if accepted == 0: | |
| for name, layer in group: | |
| layer.weight.copy_(base[name]) | |
| return before, after, accepted | |
| def prune(model: nn.Module, calibration_batches: Iterable[Any], loss_fn: LossFn, | |
| config: PruneConfig, *, validation_batches: Iterable[Any] | None = None, | |
| module_names: Sequence[str] | None = None, progress_dir: str | Path | None = None, | |
| on_progress: Callable[[dict[str, Any]], None] | None = None) -> PruneReport: | |
| """Prune an arbitrary compatible torch model in place. | |
| The caller defines the forward/loss via loss_fn. Only nn.Linear weights | |
| are pruned; tensors and batches may live on CPU or GPU. The model must fit | |
| on the chosen device for forward/backward and may not be quantized. | |
| """ | |
| train = list(calibration_batches) | |
| validation = list(validation_batches) if validation_batches is not None else train[-1:] | |
| if not train or not validation: | |
| raise ValueError("Nonempty calibration and validation batches required") | |
| groups = discover_linear_groups(model, module_names=module_names) | |
| quotas = _budgets(groups, config.sparsity) | |
| total = sum(layer.weight.numel() for group in groups for _, layer in group) | |
| if any(not layer.weight.is_floating_point() for group in groups for _, layer in group): | |
| raise ValueError("Pruning requires unquantized floating point nn.Linear weights") | |
| old_training = model.training | |
| original_flags = {id(p): p.requires_grad for p in model.parameters()} | |
| model.eval() | |
| model.requires_grad_(False) | |
| report_rows = [] | |
| started = time.time() | |
| dest = Path(progress_dir) if progress_dir is not None else None | |
| if dest is not None: | |
| dest.mkdir(parents=True, exist_ok=True) | |
| try: | |
| for i, (group, quota) in enumerate(zip(groups, quotas)): | |
| layer_start = time.time() | |
| factors = _kfac_diagonal(model, group, train, loss_fn) | |
| higher_order = _higher_order_diagonal(model, group, train, loss_fn, config, i) | |
| support, base = _mask_group(group, factors, higher_order, quota, config) | |
| before, after, alpha = _refine(model, group, factors, higher_order, support, base, | |
| train, validation, loss_fn, config) | |
| row = {"group": i, "modules": [name for name, _ in group], "zeros": quota, | |
| "loss_before": before, "loss_after": after, "accepted_step": alpha, | |
| "seconds": time.time() - layer_start} | |
| report_rows.append(row) | |
| if dest is not None: | |
| from safetensors.torch import save_file | |
| save_file({name: layer.weight.detach().cpu().contiguous() | |
| for name, layer in group}, str(dest / f"group_{i:04d}.safetensors")) | |
| (dest / "progress.json").write_text(json.dumps({"groups_completed": i + 1, | |
| "total_groups": len(groups), "layers": report_rows}, indent=2)) | |
| if on_progress is not None: | |
| on_progress(row) | |
| del factors, higher_order, support, base | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| actual = sum(int((layer.weight == 0).sum().item()) for group in groups for _, layer in group) | |
| if actual != round(total * config.sparsity): | |
| raise RuntimeError(f"Exact zero count mismatch: {actual} != {round(total * config.sparsity)}") | |
| return PruneReport(config.sparsity, actual / total, total, actual, | |
| time.time() - started, report_rows) | |
| finally: | |
| model.train(old_training) | |
| for parameter in model.parameters(): | |
| parameter.requires_grad_(original_flags[id(parameter)]) | |