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
Upload 11 files
Browse files- LICENSE +17 -0
- README.md +97 -0
- higher_order_pruner/__init__.py +17 -0
- higher_order_pruner/core.py +421 -0
- higher_order_pruner/hf.py +166 -0
- higher_order_pruner/pruner.py +118 -0
- pruner_config.json +14 -0
- pyproject.toml +28 -0
- requirements.txt +5 -0
- tests/test_core.py +33 -0
- upload_to_hub.py +36 -0
LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
Copyright 2026 Angshul Majumdar
|
| 6 |
+
|
| 7 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 8 |
+
you may not use this file except in compliance with the License.
|
| 9 |
+
You may obtain a copy of the License at
|
| 10 |
+
|
| 11 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 12 |
+
|
| 13 |
+
Unless required by applicable law or agreed to in writing, software
|
| 14 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 15 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 16 |
+
See the License for the specific language governing permissions and
|
| 17 |
+
limitations under the License.
|
README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
| 1 |
---
|
|
|
|
| 2 |
license: apache-2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
library_name: pytorch
|
| 3 |
license: apache-2.0
|
| 4 |
+
tags:
|
| 5 |
+
- pruning
|
| 6 |
+
- sparsity
|
| 7 |
+
- optimization
|
| 8 |
+
- higher-order
|
| 9 |
+
- transformers
|
| 10 |
---
|
| 11 |
+
|
| 12 |
+
# Higher-Order Pruner
|
| 13 |
+
|
| 14 |
+
A reusable Python pruning method hosted in a Hugging Face **model repository**.
|
| 15 |
+
This repository is not a pruned checkpoint and does not contain benchmark
|
| 16 |
+
results. It supplies a direct code API for pruning compatible unquantized
|
| 17 |
+
PyTorch models.
|
| 18 |
+
|
| 19 |
+
The method combines calibration-derived KFAC-diagonal second-order information
|
| 20 |
+
with a clipped diagonal higher-order finite-difference estimate, exact global
|
| 21 |
+
sparsity allocation, and monotone held-out refinement. It does not initialize
|
| 22 |
+
from Wanda, SparseGPT, magnitude pruning, or a previously pruned checkpoint.
|
| 23 |
+
|
| 24 |
+
## Install from this model repository
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
pip install "git+https://huggingface.co/OWNER/REPOSITORY"
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Then load the method configuration from the Hub and use it in code:
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
from higher_order_pruner import HigherOrderPruner
|
| 34 |
+
|
| 35 |
+
pruner = HigherOrderPruner.from_pretrained("OWNER/REPOSITORY")
|
| 36 |
+
|
| 37 |
+
result = pruner.prune_hf_model(
|
| 38 |
+
"Qwen/Qwen3-4B-Base",
|
| 39 |
+
calibration_texts=my_calibration_texts,
|
| 40 |
+
validation_texts=my_heldout_texts,
|
| 41 |
+
sparsity=0.50,
|
| 42 |
+
output_dir="./pruned-model",
|
| 43 |
+
sequence_length=512,
|
| 44 |
+
device="cuda",
|
| 45 |
+
)
|
| 46 |
+
print(result["report"]["achieved_sparsity"])
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
`pruned-model/checkpoint` is a normal Hugging Face checkpoint saved with
|
| 50 |
+
`save_pretrained` and safetensors.
|
| 51 |
+
|
| 52 |
+
## Generic PyTorch API
|
| 53 |
+
|
| 54 |
+
The core is not restricted to language models. Supply any differentiable
|
| 55 |
+
scalar loss and batches already placed on the desired device:
|
| 56 |
+
|
| 57 |
+
```python
|
| 58 |
+
from higher_order_pruner import HigherOrderPruner
|
| 59 |
+
|
| 60 |
+
pruner = HigherOrderPruner.from_pretrained("OWNER/REPOSITORY")
|
| 61 |
+
|
| 62 |
+
def loss_fn(model, batch):
|
| 63 |
+
x, target = batch
|
| 64 |
+
return (model(x) - target).square().mean()
|
| 65 |
+
|
| 66 |
+
report = pruner.prune(
|
| 67 |
+
model,
|
| 68 |
+
calibration_batches,
|
| 69 |
+
loss_fn,
|
| 70 |
+
sparsity=0.50,
|
| 71 |
+
validation_batches=heldout_batches,
|
| 72 |
+
progress_dir="./pruning-progress",
|
| 73 |
+
)
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
For nonstandard architectures, pass explicit `module_names=[...]`. The method
|
| 77 |
+
prunes unique floating-point `torch.nn.Linear.weight` tensors. It refuses
|
| 78 |
+
quantized weights and does not promise that arbitrary custom kernels, tied
|
| 79 |
+
non-Linear tensors, or remote-code-only architectures are compatible.
|
| 80 |
+
|
| 81 |
+
## Important behavior
|
| 82 |
+
|
| 83 |
+
- Calibration statistics and higher-order estimates are computed from the
|
| 84 |
+
caller's model and data at runtime; no Qwen/Falcon statistics are embedded.
|
| 85 |
+
- The requested zero count is enforced exactly across selected weights.
|
| 86 |
+
- Existing exact zeros are included in that budget.
|
| 87 |
+
- The HF adapter auto-tunes its measured curvature kernel for throughput under
|
| 88 |
+
a 95% VRAM ceiling. It does not require a specific GPU model.
|
| 89 |
+
- Unstructured zeros require an appropriate sparse inference kernel to produce
|
| 90 |
+
inference speedups.
|
| 91 |
+
|
| 92 |
+
## Local development
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
pip install -e ".[test]"
|
| 96 |
+
pytest -q
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
`upload_to_hub.py` creates a new Hugging Face **model** repository and uploads
|
| 100 |
+
this package. It refuses to overwrite an existing repository.
|
higher_order_pruner/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model-agnostic higher-order pruning for PyTorch linear layers."""
|
| 2 |
+
|
| 3 |
+
from .core import PruneConfig, PruneReport, discover_linear_groups, prune
|
| 4 |
+
from .hf import causal_lm_loss, load_causal_lm, prepare_texts, prune_hf_model
|
| 5 |
+
from .pruner import HigherOrderPruner
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"HigherOrderPruner",
|
| 9 |
+
"PruneConfig",
|
| 10 |
+
"PruneReport",
|
| 11 |
+
"discover_linear_groups",
|
| 12 |
+
"prune",
|
| 13 |
+
"prune_hf_model",
|
| 14 |
+
"load_causal_lm",
|
| 15 |
+
"prepare_texts",
|
| 16 |
+
"causal_lm_loss",
|
| 17 |
+
]
|
higher_order_pruner/core.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Higher-order, KFAC-diagonal, monotone layerwise pruning.
|
| 2 |
+
|
| 3 |
+
No model names, transformer attributes, dataset names, or evaluation tasks are
|
| 4 |
+
assumed here. The caller owns the loss and supplies calibration batches.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import gc
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import asdict, dataclass
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Callable, Iterable, Sequence
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from torch import nn
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
LossFn = Callable[[nn.Module, Any], torch.Tensor]
|
| 22 |
+
Group = list[tuple[str, nn.Linear]]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class PruneConfig:
|
| 27 |
+
sparsity: float
|
| 28 |
+
probes: int = 2
|
| 29 |
+
finite_difference_relative_step: float = 0.02
|
| 30 |
+
cubic_cap: float = 0.5
|
| 31 |
+
damping_ratio: float = 0.01
|
| 32 |
+
trust_ratio: float = 0.01
|
| 33 |
+
backtracking: tuple[float, ...] = (1.0, 0.5, 0.25, 0.125, 0.0625)
|
| 34 |
+
seed: int = 91000
|
| 35 |
+
|
| 36 |
+
def __post_init__(self) -> None:
|
| 37 |
+
if not 0 <= self.sparsity < 1:
|
| 38 |
+
raise ValueError("sparsity must lie in [0, 1)")
|
| 39 |
+
if self.probes < 1:
|
| 40 |
+
raise ValueError("probes must be positive")
|
| 41 |
+
if self.finite_difference_relative_step <= 0:
|
| 42 |
+
raise ValueError("finite_difference_relative_step must be positive")
|
| 43 |
+
if not 0 <= self.cubic_cap <= 1:
|
| 44 |
+
raise ValueError("cubic_cap must lie in [0, 1]")
|
| 45 |
+
if self.damping_ratio <= 0 or self.trust_ratio <= 0:
|
| 46 |
+
raise ValueError("damping_ratio and trust_ratio must be positive")
|
| 47 |
+
if not self.backtracking or any(a <= 0 for a in self.backtracking):
|
| 48 |
+
raise ValueError("backtracking must contain positive step sizes")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class PruneReport:
|
| 53 |
+
target_sparsity: float
|
| 54 |
+
achieved_sparsity: float
|
| 55 |
+
prunable_weights: int
|
| 56 |
+
exact_zero_weights: int
|
| 57 |
+
seconds: float
|
| 58 |
+
layers: list[dict[str, Any]]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def discover_linear_groups(model: nn.Module, *, module_names: Sequence[str] | None = None) -> list[Group]:
|
| 62 |
+
"""Prefer the model's repeated block stack; otherwise group linears singly.
|
| 63 |
+
|
| 64 |
+
module_names may explicitly select nn.Linear module paths. This is the
|
| 65 |
+
adapter for unusual architectures (including MoE expert placement).
|
| 66 |
+
"""
|
| 67 |
+
named = dict(model.named_modules())
|
| 68 |
+
if module_names is not None:
|
| 69 |
+
selected = []
|
| 70 |
+
for name in module_names:
|
| 71 |
+
if name not in named or not isinstance(named[name], nn.Linear):
|
| 72 |
+
raise ValueError(f"Not a Linear module: {name}")
|
| 73 |
+
selected.append((name, named[name]))
|
| 74 |
+
if len({name for name, _ in selected}) != len(selected):
|
| 75 |
+
raise ValueError("module_names contains duplicates")
|
| 76 |
+
if not selected:
|
| 77 |
+
raise ValueError("No Linear modules selected")
|
| 78 |
+
return [[item] for item in selected]
|
| 79 |
+
|
| 80 |
+
candidates: list[tuple[int, int, str, nn.ModuleList]] = []
|
| 81 |
+
for name, module in named.items():
|
| 82 |
+
if isinstance(module, nn.ModuleList) and len(module) >= 2:
|
| 83 |
+
counts = [sum(isinstance(x, nn.Linear) for x in block.modules()) for block in module]
|
| 84 |
+
if min(counts) > 0:
|
| 85 |
+
candidates.append((sum(counts), len(module), name, module))
|
| 86 |
+
if candidates:
|
| 87 |
+
_, _, stack_name, stack = max(candidates, key=lambda x: (x[0], x[1]))
|
| 88 |
+
groups: list[Group] = []
|
| 89 |
+
for i, block in enumerate(stack):
|
| 90 |
+
prefix = f"{stack_name}.{i}" if stack_name else str(i)
|
| 91 |
+
groups.append([(f"{prefix}.{name}" if name else prefix, layer)
|
| 92 |
+
for name, layer in block.named_modules() if isinstance(layer, nn.Linear)])
|
| 93 |
+
else:
|
| 94 |
+
groups = [[(name, layer)] for name, layer in named.items() if isinstance(layer, nn.Linear)]
|
| 95 |
+
|
| 96 |
+
seen: set[int] = set()
|
| 97 |
+
unique: list[Group] = []
|
| 98 |
+
for group in groups:
|
| 99 |
+
items = []
|
| 100 |
+
for name, layer in group:
|
| 101 |
+
ident = id(layer.weight)
|
| 102 |
+
if ident not in seen:
|
| 103 |
+
seen.add(ident)
|
| 104 |
+
items.append((name, layer))
|
| 105 |
+
if items:
|
| 106 |
+
unique.append(items)
|
| 107 |
+
if not unique:
|
| 108 |
+
raise ValueError("No unique nn.Linear weights found; select compatible modules explicitly")
|
| 109 |
+
return unique
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _loss(model: nn.Module, batch: Any, loss_fn: LossFn) -> torch.Tensor:
|
| 113 |
+
value = loss_fn(model, batch)
|
| 114 |
+
if not isinstance(value, torch.Tensor) or value.ndim != 0 or not torch.isfinite(value).item():
|
| 115 |
+
raise ValueError("loss_fn must return a finite scalar torch.Tensor")
|
| 116 |
+
return value
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _mean_loss(model: nn.Module, batches: Sequence[Any], loss_fn: LossFn) -> float:
|
| 120 |
+
with torch.no_grad():
|
| 121 |
+
return sum(float(_loss(model, b, loss_fn).detach()) for b in batches) / len(batches)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _gradient(model: nn.Module, group: Group, batches: Sequence[Any], loss_fn: LossFn,
|
| 125 |
+
base: dict[str, torch.Tensor], u: dict[str, torch.Tensor] | None = None,
|
| 126 |
+
v: dict[str, torch.Tensor] | None = None, us: int = 0, vs: int = 0,
|
| 127 |
+
steps: dict[str, float] | None = None) -> dict[str, torch.Tensor]:
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
for name, layer in group:
|
| 130 |
+
if u is not None and v is not None and steps is not None:
|
| 131 |
+
candidate = base[name].float() + steps[name] * (us * u[name].float() + vs * v[name].float())
|
| 132 |
+
layer.weight.copy_(candidate.to(layer.weight.dtype))
|
| 133 |
+
layer.weight.requires_grad_(True)
|
| 134 |
+
sums = {name: torch.zeros_like(layer.weight, dtype=torch.float32) for name, layer in group}
|
| 135 |
+
try:
|
| 136 |
+
for batch in batches:
|
| 137 |
+
model.zero_grad(set_to_none=True)
|
| 138 |
+
_loss(model, batch, loss_fn).backward()
|
| 139 |
+
for name, layer in group:
|
| 140 |
+
if layer.weight.grad is None:
|
| 141 |
+
raise RuntimeError(f"Selected module has no loss gradient: {name}")
|
| 142 |
+
sums[name].add_(layer.weight.grad.detach().float())
|
| 143 |
+
return {name: value / len(batches) for name, value in sums.items()}
|
| 144 |
+
finally:
|
| 145 |
+
model.zero_grad(set_to_none=True)
|
| 146 |
+
with torch.no_grad():
|
| 147 |
+
for name, layer in group:
|
| 148 |
+
layer.weight.copy_(base[name])
|
| 149 |
+
layer.weight.requires_grad_(False)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _kfac_diagonal(model: nn.Module, group: Group, batches: Sequence[Any],
|
| 153 |
+
loss_fn: LossFn) -> dict[str, tuple[torch.Tensor, torch.Tensor]]:
|
| 154 |
+
"""Collect E[output-gradient²] and E[input²] on the current model."""
|
| 155 |
+
stats: dict[str, dict[str, Any]] = {
|
| 156 |
+
name: {"a": torch.zeros(layer.in_features, device=layer.weight.device, dtype=torch.float32),
|
| 157 |
+
"g": torch.zeros(layer.out_features, device=layer.weight.device, dtype=torch.float32),
|
| 158 |
+
"na": 0, "ng": 0}
|
| 159 |
+
for name, layer in group
|
| 160 |
+
}
|
| 161 |
+
hooks = []
|
| 162 |
+
|
| 163 |
+
def make_hook(name: str):
|
| 164 |
+
def forward_hook(_module: nn.Module, inputs: tuple[Any, ...], output: torch.Tensor) -> None:
|
| 165 |
+
x = inputs[0].detach().reshape(-1, stats[name]["a"].numel()).float()
|
| 166 |
+
stats[name]["a"].add_(x.square().sum(dim=0))
|
| 167 |
+
stats[name]["na"] += x.shape[0]
|
| 168 |
+
|
| 169 |
+
def backward_hook(grad: torch.Tensor) -> None:
|
| 170 |
+
y = grad.detach().reshape(-1, stats[name]["g"].numel()).float()
|
| 171 |
+
stats[name]["g"].add_(y.square().sum(dim=0))
|
| 172 |
+
stats[name]["ng"] += y.shape[0]
|
| 173 |
+
|
| 174 |
+
output.register_hook(backward_hook)
|
| 175 |
+
return forward_hook
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
for name, layer in group:
|
| 179 |
+
layer.weight.requires_grad_(True)
|
| 180 |
+
hooks.append(layer.register_forward_hook(make_hook(name)))
|
| 181 |
+
for batch in batches:
|
| 182 |
+
model.zero_grad(set_to_none=True)
|
| 183 |
+
_loss(model, batch, loss_fn).backward()
|
| 184 |
+
answer = {}
|
| 185 |
+
for name, item in stats.items():
|
| 186 |
+
if not item["na"] or not item["ng"]:
|
| 187 |
+
raise RuntimeError(f"No activation/gradient statistics for {name}")
|
| 188 |
+
answer[name] = (item["g"] / item["ng"], item["a"] / item["na"])
|
| 189 |
+
return answer
|
| 190 |
+
finally:
|
| 191 |
+
for hook in hooks:
|
| 192 |
+
hook.remove()
|
| 193 |
+
model.zero_grad(set_to_none=True)
|
| 194 |
+
for _, layer in group:
|
| 195 |
+
layer.weight.requires_grad_(False)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _higher_order_diagonal(model: nn.Module, group: Group, batches: Sequence[Any], loss_fn: LossFn,
|
| 199 |
+
cfg: PruneConfig, group_index: int) -> dict[str, torch.Tensor]:
|
| 200 |
+
base = {name: layer.weight.detach().clone() for name, layer in group}
|
| 201 |
+
estimate = {name: torch.zeros_like(layer.weight, dtype=torch.float32) for name, layer in group}
|
| 202 |
+
gen = torch.Generator(device=group[0][1].weight.device)
|
| 203 |
+
gen.manual_seed(cfg.seed + group_index)
|
| 204 |
+
for _ in range(cfg.probes):
|
| 205 |
+
u = {name: torch.randint(0, 2, layer.weight.shape, device=layer.weight.device,
|
| 206 |
+
dtype=torch.int8, generator=gen).mul_(2).sub_(1)
|
| 207 |
+
for name, layer in group}
|
| 208 |
+
v = {name: torch.randint(0, 2, layer.weight.shape, device=layer.weight.device,
|
| 209 |
+
dtype=torch.int8, generator=gen).mul_(2).sub_(1)
|
| 210 |
+
for name, layer in group}
|
| 211 |
+
steps = {name: max(cfg.finite_difference_relative_step *
|
| 212 |
+
base[name].float().square().mean().sqrt().item(), 1e-6)
|
| 213 |
+
for name, _ in group}
|
| 214 |
+
for us, vs, sign in ((1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)):
|
| 215 |
+
grads = _gradient(model, group, batches, loss_fn, base, u, v, us, vs, steps)
|
| 216 |
+
for name in grads:
|
| 217 |
+
estimate[name].add_(grads[name] * u[name].float() * v[name].float(),
|
| 218 |
+
alpha=sign / (4 * steps[name] ** 2 * cfg.probes))
|
| 219 |
+
del grads
|
| 220 |
+
del u, v
|
| 221 |
+
for name in estimate:
|
| 222 |
+
sample = estimate[name].reshape(-1)[::max(1, estimate[name].numel() // 131072)]
|
| 223 |
+
limit = 10 * sample.abs().median().clamp_min(1e-12)
|
| 224 |
+
estimate[name].clamp_(-limit, limit)
|
| 225 |
+
return estimate
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _budgets(groups: list[Group], sparsity: float) -> list[int]:
|
| 229 |
+
sizes = [sum(layer.weight.numel() for _, layer in group) for group in groups]
|
| 230 |
+
wanted = round(sum(sizes) * sparsity)
|
| 231 |
+
existing = [sum(int((layer.weight.detach() == 0).sum().item())
|
| 232 |
+
for _, layer in group) for group in groups]
|
| 233 |
+
if sum(existing) > wanted:
|
| 234 |
+
raise ValueError(
|
| 235 |
+
f"The selected weights already contain {sum(existing)} exact zeros, "
|
| 236 |
+
f"which exceeds the requested global budget {wanted}."
|
| 237 |
+
)
|
| 238 |
+
exact = [size * sparsity for size in sizes]
|
| 239 |
+
quotas = [max(existing[i], math.floor(exact[i])) for i in range(len(groups))]
|
| 240 |
+
if sum(quotas) > wanted:
|
| 241 |
+
# A group with many pre-existing zeros borrows budget from groups that
|
| 242 |
+
# are still above their immutable lower bound.
|
| 243 |
+
order = sorted(range(len(groups)), key=lambda i: (quotas[i] - existing[i], i), reverse=True)
|
| 244 |
+
remaining = sum(quotas) - wanted
|
| 245 |
+
for i in order:
|
| 246 |
+
take = min(remaining, quotas[i] - existing[i])
|
| 247 |
+
quotas[i] -= take
|
| 248 |
+
remaining -= take
|
| 249 |
+
if remaining == 0:
|
| 250 |
+
break
|
| 251 |
+
else:
|
| 252 |
+
order = sorted(range(len(groups)), key=lambda i: (exact[i] - math.floor(exact[i]), -i), reverse=True)
|
| 253 |
+
remaining = wanted - sum(quotas)
|
| 254 |
+
for i in order:
|
| 255 |
+
if remaining == 0:
|
| 256 |
+
break
|
| 257 |
+
if quotas[i] < sizes[i]:
|
| 258 |
+
quotas[i] += 1
|
| 259 |
+
remaining -= 1
|
| 260 |
+
# For unusual pre-existing-zero distributions, the fractional pass
|
| 261 |
+
# may not consume the whole budget. Fill deterministically.
|
| 262 |
+
if remaining:
|
| 263 |
+
for i in range(len(groups)):
|
| 264 |
+
take = min(remaining, sizes[i] - quotas[i])
|
| 265 |
+
quotas[i] += take
|
| 266 |
+
remaining -= take
|
| 267 |
+
if remaining == 0:
|
| 268 |
+
break
|
| 269 |
+
if sum(quotas) != wanted:
|
| 270 |
+
raise RuntimeError("Could not distribute the exact global sparsity budget")
|
| 271 |
+
return quotas
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _mask_group(group: Group, factors: dict[str, tuple[torch.Tensor, torch.Tensor]],
|
| 275 |
+
higher_order: dict[str, torch.Tensor], quota: int, cfg: PruneConfig):
|
| 276 |
+
scores = []
|
| 277 |
+
existing = []
|
| 278 |
+
sizes = []
|
| 279 |
+
for name, layer in group:
|
| 280 |
+
w = layer.weight.detach().float()
|
| 281 |
+
g, a = factors[name]
|
| 282 |
+
h = g[:, None] * a[None, :]
|
| 283 |
+
quadratic = 0.5 * h * w.square()
|
| 284 |
+
cubic_raw = -higher_order[name] * w.pow(3) / 6
|
| 285 |
+
cubic_limit = cfg.cubic_cap * quadratic.abs()
|
| 286 |
+
cubic = torch.maximum(torch.minimum(cubic_raw, cubic_limit), -cubic_limit)
|
| 287 |
+
score = (quadratic + cubic).clamp_min_(0).reshape(-1)
|
| 288 |
+
score = torch.nan_to_num(score, nan=float("inf"), posinf=float("inf"))
|
| 289 |
+
scores.append(score)
|
| 290 |
+
existing.append((layer.weight.detach().reshape(-1) == 0))
|
| 291 |
+
sizes.append(layer.weight.numel())
|
| 292 |
+
joint = torch.cat(scores)
|
| 293 |
+
already = torch.cat(existing)
|
| 294 |
+
n_zero = int(already.sum().item())
|
| 295 |
+
if quota < n_zero:
|
| 296 |
+
raise ValueError(f"Target quota {quota} is below {n_zero} pre-existing exact zeros")
|
| 297 |
+
additional = quota - n_zero
|
| 298 |
+
available = torch.nonzero(~already).flatten()
|
| 299 |
+
if additional > available.numel():
|
| 300 |
+
raise RuntimeError("Insufficient nonzero weights to meet the sparsity target")
|
| 301 |
+
chosen = available[torch.topk(joint[available], k=additional, largest=False, sorted=False).indices] \
|
| 302 |
+
if additional else available[:0]
|
| 303 |
+
mask = already.clone()
|
| 304 |
+
mask[chosen] = True
|
| 305 |
+
support = {}
|
| 306 |
+
base = {}
|
| 307 |
+
offset = 0
|
| 308 |
+
with torch.no_grad():
|
| 309 |
+
for (name, layer), length in zip(group, sizes):
|
| 310 |
+
zero = mask[offset:offset + length].reshape_as(layer.weight)
|
| 311 |
+
layer.weight[zero] = 0
|
| 312 |
+
support[name] = ~zero
|
| 313 |
+
base[name] = layer.weight.detach().clone()
|
| 314 |
+
offset += length
|
| 315 |
+
return support, base
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _refine(model: nn.Module, group: Group, factors: dict[str, tuple[torch.Tensor, torch.Tensor]],
|
| 319 |
+
higher_order: dict[str, torch.Tensor], support: dict[str, torch.Tensor],
|
| 320 |
+
base: dict[str, torch.Tensor], train: Sequence[Any], validation: Sequence[Any],
|
| 321 |
+
loss_fn: LossFn, cfg: PruneConfig) -> tuple[float, float, float]:
|
| 322 |
+
before = _mean_loss(model, validation, loss_fn)
|
| 323 |
+
gradients = _gradient(model, group, train, loss_fn, base)
|
| 324 |
+
directions = {}
|
| 325 |
+
for name, layer in group:
|
| 326 |
+
g, a = factors[name]
|
| 327 |
+
h = g[:, None] * a[None, :]
|
| 328 |
+
h = h + cfg.damping_ratio * h.mean().clamp_min(1e-12)
|
| 329 |
+
t = higher_order[name]
|
| 330 |
+
grad = gradients[name]
|
| 331 |
+
discriminant = h.square() - 2 * t * grad
|
| 332 |
+
root = discriminant.clamp_min(0).sqrt()
|
| 333 |
+
linear_step = -grad / h
|
| 334 |
+
cubic_step = -2 * grad / (h + root).clamp_min(1e-20)
|
| 335 |
+
direction = torch.where((discriminant > 0) & (t.abs() > 1e-12) &
|
| 336 |
+
torch.isfinite(cubic_step), cubic_step, linear_step)
|
| 337 |
+
direction.masked_fill_(~support[name], 0)
|
| 338 |
+
radius = cfg.trust_ratio * layer.weight.detach().float()[support[name]].norm()
|
| 339 |
+
norm = direction[support[name]].norm()
|
| 340 |
+
if norm > radius:
|
| 341 |
+
direction.mul_(radius / norm.clamp_min(1e-12))
|
| 342 |
+
directions[name] = direction
|
| 343 |
+
accepted = 0.0
|
| 344 |
+
after = before
|
| 345 |
+
with torch.no_grad():
|
| 346 |
+
for alpha in cfg.backtracking:
|
| 347 |
+
for name, layer in group:
|
| 348 |
+
candidate = base[name].float() + alpha * directions[name]
|
| 349 |
+
candidate.masked_fill_(~support[name], 0)
|
| 350 |
+
layer.weight.copy_(candidate.to(layer.weight.dtype))
|
| 351 |
+
loss = _mean_loss(model, validation, loss_fn)
|
| 352 |
+
if loss <= before + 1e-5:
|
| 353 |
+
accepted, after = alpha, loss
|
| 354 |
+
break
|
| 355 |
+
if accepted == 0:
|
| 356 |
+
for name, layer in group:
|
| 357 |
+
layer.weight.copy_(base[name])
|
| 358 |
+
return before, after, accepted
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def prune(model: nn.Module, calibration_batches: Iterable[Any], loss_fn: LossFn,
|
| 362 |
+
config: PruneConfig, *, validation_batches: Iterable[Any] | None = None,
|
| 363 |
+
module_names: Sequence[str] | None = None, progress_dir: str | Path | None = None,
|
| 364 |
+
on_progress: Callable[[dict[str, Any]], None] | None = None) -> PruneReport:
|
| 365 |
+
"""Prune an arbitrary compatible torch model in place.
|
| 366 |
+
|
| 367 |
+
The caller defines the forward/loss via loss_fn. Only nn.Linear weights
|
| 368 |
+
are pruned; tensors and batches may live on CPU or GPU. The model must fit
|
| 369 |
+
on the chosen device for forward/backward and may not be quantized.
|
| 370 |
+
"""
|
| 371 |
+
train = list(calibration_batches)
|
| 372 |
+
validation = list(validation_batches) if validation_batches is not None else train[-1:]
|
| 373 |
+
if not train or not validation:
|
| 374 |
+
raise ValueError("Nonempty calibration and validation batches required")
|
| 375 |
+
groups = discover_linear_groups(model, module_names=module_names)
|
| 376 |
+
quotas = _budgets(groups, config.sparsity)
|
| 377 |
+
total = sum(layer.weight.numel() for group in groups for _, layer in group)
|
| 378 |
+
if any(not layer.weight.is_floating_point() for group in groups for _, layer in group):
|
| 379 |
+
raise ValueError("Pruning requires unquantized floating point nn.Linear weights")
|
| 380 |
+
old_training = model.training
|
| 381 |
+
original_flags = {id(p): p.requires_grad for p in model.parameters()}
|
| 382 |
+
model.eval()
|
| 383 |
+
model.requires_grad_(False)
|
| 384 |
+
report_rows = []
|
| 385 |
+
started = time.time()
|
| 386 |
+
dest = Path(progress_dir) if progress_dir is not None else None
|
| 387 |
+
if dest is not None:
|
| 388 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 389 |
+
try:
|
| 390 |
+
for i, (group, quota) in enumerate(zip(groups, quotas)):
|
| 391 |
+
layer_start = time.time()
|
| 392 |
+
factors = _kfac_diagonal(model, group, train, loss_fn)
|
| 393 |
+
higher_order = _higher_order_diagonal(model, group, train, loss_fn, config, i)
|
| 394 |
+
support, base = _mask_group(group, factors, higher_order, quota, config)
|
| 395 |
+
before, after, alpha = _refine(model, group, factors, higher_order, support, base,
|
| 396 |
+
train, validation, loss_fn, config)
|
| 397 |
+
row = {"group": i, "modules": [name for name, _ in group], "zeros": quota,
|
| 398 |
+
"loss_before": before, "loss_after": after, "accepted_step": alpha,
|
| 399 |
+
"seconds": time.time() - layer_start}
|
| 400 |
+
report_rows.append(row)
|
| 401 |
+
if dest is not None:
|
| 402 |
+
from safetensors.torch import save_file
|
| 403 |
+
save_file({name: layer.weight.detach().cpu().contiguous()
|
| 404 |
+
for name, layer in group}, str(dest / f"group_{i:04d}.safetensors"))
|
| 405 |
+
(dest / "progress.json").write_text(json.dumps({"groups_completed": i + 1,
|
| 406 |
+
"total_groups": len(groups), "layers": report_rows}, indent=2))
|
| 407 |
+
if on_progress is not None:
|
| 408 |
+
on_progress(row)
|
| 409 |
+
del factors, higher_order, support, base
|
| 410 |
+
gc.collect()
|
| 411 |
+
if torch.cuda.is_available():
|
| 412 |
+
torch.cuda.empty_cache()
|
| 413 |
+
actual = sum(int((layer.weight == 0).sum().item()) for group in groups for _, layer in group)
|
| 414 |
+
if actual != round(total * config.sparsity):
|
| 415 |
+
raise RuntimeError(f"Exact zero count mismatch: {actual} != {round(total * config.sparsity)}")
|
| 416 |
+
return PruneReport(config.sparsity, actual / total, total, actual,
|
| 417 |
+
time.time() - started, report_rows)
|
| 418 |
+
finally:
|
| 419 |
+
model.train(old_training)
|
| 420 |
+
for parameter in model.parameters():
|
| 421 |
+
parameter.requires_grad_(original_flags[id(parameter)])
|
higher_order_pruner/hf.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face CausalLM adapter for the generic pruning core."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gc
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Callable, Sequence
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 13 |
+
|
| 14 |
+
from .core import PruneConfig, _higher_order_diagonal, _kfac_diagonal, discover_linear_groups, prune
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_causal_lm(model_id: str, *, revision: str = "main", device: str = "cuda"):
|
| 18 |
+
"""Load an ordinary unquantized HF causal LM without remote Python code."""
|
| 19 |
+
if device.startswith("cuda") and not torch.cuda.is_available():
|
| 20 |
+
raise RuntimeError("A CUDA GPU is required for this requested device")
|
| 21 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id, revision=revision,
|
| 22 |
+
trust_remote_code=False, use_fast=True)
|
| 23 |
+
if tokenizer.pad_token_id is None:
|
| 24 |
+
if tokenizer.eos_token_id is None:
|
| 25 |
+
raise ValueError("The tokenizer needs a padding or EOS token")
|
| 26 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 27 |
+
dtype = torch.bfloat16 if device.startswith("cuda") and torch.cuda.is_bf16_supported() \
|
| 28 |
+
else (torch.float16 if device.startswith("cuda") else torch.float32)
|
| 29 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 30 |
+
model_id, revision=revision, dtype=dtype, trust_remote_code=False,
|
| 31 |
+
low_cpu_mem_usage=True, device_map={"": device}, use_safetensors=True,
|
| 32 |
+
)
|
| 33 |
+
if getattr(model, "is_quantized", False) or getattr(model, "hf_quantizer", None) is not None:
|
| 34 |
+
raise ValueError("This method requires an unquantized model")
|
| 35 |
+
model.eval()
|
| 36 |
+
if hasattr(model.config, "use_cache"):
|
| 37 |
+
model.config.use_cache = False
|
| 38 |
+
return model, tokenizer
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def prepare_texts(tokenizer, texts: Sequence[str], *, sequence_length: int = 256,
|
| 42 |
+
batch_size: int = 1, device: str = "cuda") -> list[dict[str, torch.Tensor]]:
|
| 43 |
+
if not texts or not all(isinstance(text, str) and text.strip() for text in texts):
|
| 44 |
+
raise ValueError("Supply nonempty calibration text strings")
|
| 45 |
+
if not 16 <= sequence_length <= 2048:
|
| 46 |
+
raise ValueError("sequence_length must lie between 16 and 2048")
|
| 47 |
+
if batch_size < 1:
|
| 48 |
+
raise ValueError("batch_size must be positive")
|
| 49 |
+
outputs = []
|
| 50 |
+
for offset in range(0, len(texts), batch_size):
|
| 51 |
+
batch = tokenizer(list(texts[offset:offset + batch_size]),
|
| 52 |
+
padding=True, truncation=True, max_length=sequence_length,
|
| 53 |
+
return_tensors="pt")
|
| 54 |
+
ids = batch["input_ids"]
|
| 55 |
+
mask = batch["attention_mask"]
|
| 56 |
+
labels = ids.clone()
|
| 57 |
+
labels[mask == 0] = -100
|
| 58 |
+
if (labels[:, 1:] != -100).sum().item() == 0:
|
| 59 |
+
raise ValueError("Calibration sample has fewer than two usable tokens")
|
| 60 |
+
outputs.append({"input_ids": ids.to(device), "attention_mask": mask.to(device),
|
| 61 |
+
"labels": labels.to(device)})
|
| 62 |
+
return outputs
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def causal_lm_loss(model, batch: dict[str, torch.Tensor]) -> torch.Tensor:
|
| 66 |
+
return model(**batch, use_cache=False).loss
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def tune_batch(model, tokenizer, texts: Sequence[str], *, sequence_length: int,
|
| 70 |
+
device: str, hard_fraction: float = 0.95,
|
| 71 |
+
candidates: tuple[int, ...] = (1, 2, 4, 8, 12, 16)) -> dict:
|
| 72 |
+
"""Measure actual KFAC and higher-order gradient kernels, not just a forward."""
|
| 73 |
+
if not device.startswith("cuda"):
|
| 74 |
+
return {"batch_size": 1, "measurements": [], "device": device}
|
| 75 |
+
groups = discover_linear_groups(model)
|
| 76 |
+
representative = max(groups, key=lambda group: sum(m.weight.numel() for _, m in group))
|
| 77 |
+
total = torch.cuda.get_device_properties(0).total_memory
|
| 78 |
+
measurements = []
|
| 79 |
+
for batch_size in candidates:
|
| 80 |
+
if len(texts) < batch_size:
|
| 81 |
+
continue
|
| 82 |
+
payload = None
|
| 83 |
+
try:
|
| 84 |
+
payload = prepare_texts(tokenizer, texts[:batch_size],
|
| 85 |
+
sequence_length=sequence_length,
|
| 86 |
+
batch_size=batch_size, device=device)
|
| 87 |
+
torch.cuda.empty_cache()
|
| 88 |
+
torch.cuda.reset_peak_memory_stats()
|
| 89 |
+
t0 = time.monotonic()
|
| 90 |
+
factors = _kfac_diagonal(model, representative, payload, causal_lm_loss)
|
| 91 |
+
# One Rademacher probe uses the same peak allocations as a
|
| 92 |
+
# production probe without repeating the full calibration corpus.
|
| 93 |
+
probe = _higher_order_diagonal(model, representative, payload, causal_lm_loss,
|
| 94 |
+
PruneConfig(sparsity=0.5, probes=1), 0)
|
| 95 |
+
torch.cuda.synchronize()
|
| 96 |
+
seconds = time.monotonic() - t0
|
| 97 |
+
peak = torch.cuda.max_memory_reserved()
|
| 98 |
+
measurements.append({"batch_size": batch_size, "seconds": seconds,
|
| 99 |
+
"peak_gib": peak / 2**30,
|
| 100 |
+
"safe": peak <= total * hard_fraction})
|
| 101 |
+
del factors, probe
|
| 102 |
+
except torch.OutOfMemoryError:
|
| 103 |
+
measurements.append({"batch_size": batch_size, "oom": True, "safe": False})
|
| 104 |
+
model.zero_grad(set_to_none=True)
|
| 105 |
+
finally:
|
| 106 |
+
del payload
|
| 107 |
+
gc.collect()
|
| 108 |
+
torch.cuda.empty_cache()
|
| 109 |
+
safe = [m for m in measurements if m.get("safe")]
|
| 110 |
+
if not safe:
|
| 111 |
+
raise RuntimeError("No safe batch size found; use a smaller model or shorter sequences")
|
| 112 |
+
# Prefer throughput; memory occupancy is not an optimization objective.
|
| 113 |
+
best = max(safe, key=lambda m: m["batch_size"] / m["seconds"])
|
| 114 |
+
return {"batch_size": best["batch_size"], "measurements": measurements,
|
| 115 |
+
"device": device, "sequence_length": sequence_length}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def prune_hf_model(model_id: str, calibration_texts: Sequence[str], *,
|
| 119 |
+
validation_texts: Sequence[str] | None = None, sparsity: float,
|
| 120 |
+
output_dir: str | Path, revision: str = "main",
|
| 121 |
+
sequence_length: int = 256, probes: int = 2,
|
| 122 |
+
module_names: Sequence[str] | None = None, device: str = "cuda",
|
| 123 |
+
on_progress: Callable[[dict], None] | None = None) -> dict:
|
| 124 |
+
"""Download, calibrate, prune, and save any supported HF causal LM.
|
| 125 |
+
|
| 126 |
+
'Supported' means that the model loads with AutoModelForCausalLM without
|
| 127 |
+
remote code, has nn.Linear modules and a differentiable language-model
|
| 128 |
+
loss, and fits with backward activations on the selected device.
|
| 129 |
+
"""
|
| 130 |
+
dest = Path(output_dir)
|
| 131 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
model, tokenizer = load_causal_lm(model_id, revision=revision, device=device)
|
| 133 |
+
try:
|
| 134 |
+
cfg = PruneConfig(sparsity=sparsity, probes=probes)
|
| 135 |
+
if module_names is None:
|
| 136 |
+
tuned = tune_batch(model, tokenizer, calibration_texts,
|
| 137 |
+
sequence_length=sequence_length, device=device)
|
| 138 |
+
else:
|
| 139 |
+
# A custom architecture selection can be much larger than a block;
|
| 140 |
+
# use a conservative batch rather than an unrelated probe.
|
| 141 |
+
tuned = {"batch_size": 1, "measurements": [], "custom_selection": True}
|
| 142 |
+
batch_size = tuned["batch_size"]
|
| 143 |
+
train = prepare_texts(tokenizer, calibration_texts, sequence_length=sequence_length,
|
| 144 |
+
batch_size=batch_size, device=device)
|
| 145 |
+
validation = prepare_texts(tokenizer, validation_texts or calibration_texts[-2:],
|
| 146 |
+
sequence_length=sequence_length, batch_size=1, device=device)
|
| 147 |
+
try:
|
| 148 |
+
report = prune(model, train, causal_lm_loss, cfg, validation_batches=validation,
|
| 149 |
+
module_names=module_names, progress_dir=dest / "progress",
|
| 150 |
+
on_progress=on_progress)
|
| 151 |
+
finally:
|
| 152 |
+
del train, validation
|
| 153 |
+
checkpoint = dest / "checkpoint"
|
| 154 |
+
model.save_pretrained(checkpoint, safe_serialization=True, max_shard_size="2GB")
|
| 155 |
+
tokenizer.save_pretrained(checkpoint)
|
| 156 |
+
result = {"status": "complete", "source_model": model_id, "revision": revision,
|
| 157 |
+
"checkpoint": str(checkpoint), "sparsity": sparsity,
|
| 158 |
+
"report": vars(report), "batch_tuning": tuned}
|
| 159 |
+
(dest / "report.json").write_text(json.dumps(result, indent=2))
|
| 160 |
+
return result
|
| 161 |
+
finally:
|
| 162 |
+
model.cpu()
|
| 163 |
+
del model
|
| 164 |
+
gc.collect()
|
| 165 |
+
if torch.cuda.is_available():
|
| 166 |
+
torch.cuda.empty_cache()
|
higher_order_pruner/pruner.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public, reusable pruning interface for Hugging Face model repositories."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import asdict, replace
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Callable, Iterable, Sequence
|
| 9 |
+
|
| 10 |
+
from huggingface_hub import hf_hub_download
|
| 11 |
+
from torch import nn
|
| 12 |
+
|
| 13 |
+
from .core import PruneConfig, PruneReport, prune
|
| 14 |
+
from .hf import prune_hf_model
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class HigherOrderPruner:
|
| 18 |
+
"""Configuration-backed API for higher-order pruning.
|
| 19 |
+
|
| 20 |
+
The object contains method defaults, not model weights. ``from_pretrained``
|
| 21 |
+
reads those defaults from a Hugging Face *model* repository. The same
|
| 22 |
+
instance can prune any compatible model; it is not tied to Qwen or Falcon.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
CONFIG_NAME = "pruner_config.json"
|
| 26 |
+
|
| 27 |
+
def __init__(self, config: PruneConfig | None = None):
|
| 28 |
+
self.config = config or PruneConfig(sparsity=0.5)
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
def from_pretrained(
|
| 32 |
+
cls,
|
| 33 |
+
repo_id: str,
|
| 34 |
+
*,
|
| 35 |
+
revision: str = "main",
|
| 36 |
+
token: str | bool | None = None,
|
| 37 |
+
cache_dir: str | Path | None = None,
|
| 38 |
+
local_files_only: bool = False,
|
| 39 |
+
) -> "HigherOrderPruner":
|
| 40 |
+
path = hf_hub_download(
|
| 41 |
+
repo_id=repo_id,
|
| 42 |
+
filename=cls.CONFIG_NAME,
|
| 43 |
+
revision=revision,
|
| 44 |
+
token=token,
|
| 45 |
+
cache_dir=cache_dir,
|
| 46 |
+
local_files_only=local_files_only,
|
| 47 |
+
)
|
| 48 |
+
payload = json.loads(Path(path).read_text())
|
| 49 |
+
if payload.get("format") != "higher-order-pruner-v1":
|
| 50 |
+
raise ValueError("Unsupported pruner configuration format")
|
| 51 |
+
values = dict(payload["defaults"])
|
| 52 |
+
if "backtracking" in values:
|
| 53 |
+
values["backtracking"] = tuple(values["backtracking"])
|
| 54 |
+
return cls(PruneConfig(**values))
|
| 55 |
+
|
| 56 |
+
def save_pretrained(self, directory: str | Path) -> Path:
|
| 57 |
+
destination = Path(directory)
|
| 58 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
path = destination / self.CONFIG_NAME
|
| 60 |
+
path.write_text(json.dumps({
|
| 61 |
+
"format": "higher-order-pruner-v1",
|
| 62 |
+
"method": "diagonal-higher-order-kfac",
|
| 63 |
+
"defaults": asdict(self.config),
|
| 64 |
+
}, indent=2) + "\n")
|
| 65 |
+
return path
|
| 66 |
+
|
| 67 |
+
def prune(
|
| 68 |
+
self,
|
| 69 |
+
model: nn.Module,
|
| 70 |
+
calibration_batches: Iterable[Any],
|
| 71 |
+
loss_fn: Callable[[nn.Module, Any], Any],
|
| 72 |
+
*,
|
| 73 |
+
sparsity: float | None = None,
|
| 74 |
+
validation_batches: Iterable[Any] | None = None,
|
| 75 |
+
module_names: Sequence[str] | None = None,
|
| 76 |
+
progress_dir: str | Path | None = None,
|
| 77 |
+
on_progress: Callable[[dict[str, Any]], None] | None = None,
|
| 78 |
+
) -> PruneReport:
|
| 79 |
+
config = self.config if sparsity is None else replace(self.config, sparsity=sparsity)
|
| 80 |
+
return prune(
|
| 81 |
+
model,
|
| 82 |
+
calibration_batches,
|
| 83 |
+
loss_fn,
|
| 84 |
+
config,
|
| 85 |
+
validation_batches=validation_batches,
|
| 86 |
+
module_names=module_names,
|
| 87 |
+
progress_dir=progress_dir,
|
| 88 |
+
on_progress=on_progress,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
def prune_hf_model(
|
| 92 |
+
self,
|
| 93 |
+
model_id: str,
|
| 94 |
+
calibration_texts: Sequence[str],
|
| 95 |
+
*,
|
| 96 |
+
output_dir: str | Path,
|
| 97 |
+
sparsity: float | None = None,
|
| 98 |
+
validation_texts: Sequence[str] | None = None,
|
| 99 |
+
revision: str = "main",
|
| 100 |
+
sequence_length: int = 256,
|
| 101 |
+
module_names: Sequence[str] | None = None,
|
| 102 |
+
device: str = "cuda",
|
| 103 |
+
on_progress: Callable[[dict], None] | None = None,
|
| 104 |
+
) -> dict:
|
| 105 |
+
selected = self.config.sparsity if sparsity is None else sparsity
|
| 106 |
+
return prune_hf_model(
|
| 107 |
+
model_id,
|
| 108 |
+
calibration_texts,
|
| 109 |
+
validation_texts=validation_texts,
|
| 110 |
+
sparsity=selected,
|
| 111 |
+
output_dir=output_dir,
|
| 112 |
+
revision=revision,
|
| 113 |
+
sequence_length=sequence_length,
|
| 114 |
+
probes=self.config.probes,
|
| 115 |
+
module_names=module_names,
|
| 116 |
+
device=device,
|
| 117 |
+
on_progress=on_progress,
|
| 118 |
+
)
|
pruner_config.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format": "higher-order-pruner-v1",
|
| 3 |
+
"method": "diagonal-higher-order-kfac",
|
| 4 |
+
"defaults": {
|
| 5 |
+
"sparsity": 0.5,
|
| 6 |
+
"probes": 2,
|
| 7 |
+
"finite_difference_relative_step": 0.02,
|
| 8 |
+
"cubic_cap": 0.5,
|
| 9 |
+
"damping_ratio": 0.01,
|
| 10 |
+
"trust_ratio": 0.01,
|
| 11 |
+
"backtracking": [1.0, 0.5, 0.25, 0.125, 0.0625],
|
| 12 |
+
"seed": 91000
|
| 13 |
+
}
|
| 14 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=70", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "higher-order-pruner"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Model-agnostic higher-order pruning for PyTorch and Hugging Face models"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = {text = "Apache-2.0"}
|
| 12 |
+
authors = [{name = "Angshul Majumdar"}]
|
| 13 |
+
dependencies = [
|
| 14 |
+
"torch>=2.4,<3",
|
| 15 |
+
"transformers>=4.57,<5",
|
| 16 |
+
"accelerate>=1.2,<2",
|
| 17 |
+
"safetensors>=0.5,<1",
|
| 18 |
+
"huggingface_hub>=0.34,<2",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[project.optional-dependencies]
|
| 22 |
+
test = ["pytest>=8,<10"]
|
| 23 |
+
|
| 24 |
+
[tool.setuptools.packages.find]
|
| 25 |
+
include = ["higher_order_pruner*"]
|
| 26 |
+
|
| 27 |
+
[tool.pytest.ini_options]
|
| 28 |
+
testpaths = ["tests"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.4,<3
|
| 2 |
+
transformers>=4.57,<5
|
| 3 |
+
accelerate>=1.2,<2
|
| 4 |
+
safetensors>=0.5,<1
|
| 5 |
+
huggingface_hub>=0.34,<2
|
tests/test_core.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny CPU smoke test; run with pytest after installing requirements."""
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from torch import nn
|
| 5 |
+
|
| 6 |
+
from higher_order_pruner import PruneConfig, discover_linear_groups, prune
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TinyNet(nn.Module):
|
| 10 |
+
def __init__(self):
|
| 11 |
+
super().__init__()
|
| 12 |
+
self.layers = nn.ModuleList([nn.Linear(5, 5), nn.Linear(5, 5)])
|
| 13 |
+
|
| 14 |
+
def forward(self, x):
|
| 15 |
+
return self.layers[1](self.layers[0](x).tanh())
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_exact_budget_and_general_loss():
|
| 19 |
+
torch.manual_seed(17)
|
| 20 |
+
model = TinyNet()
|
| 21 |
+
batches = [(torch.randn(4, 5), torch.randn(4, 5)) for _ in range(3)]
|
| 22 |
+
|
| 23 |
+
def mse(m, batch):
|
| 24 |
+
x, y = batch
|
| 25 |
+
return (m(x) - y).square().mean()
|
| 26 |
+
|
| 27 |
+
assert len(discover_linear_groups(model)) == 2
|
| 28 |
+
report = prune(model, batches[:2], mse, PruneConfig(sparsity=0.3, probes=1),
|
| 29 |
+
validation_batches=batches[2:])
|
| 30 |
+
assert report.prunable_weights == 50
|
| 31 |
+
assert report.exact_zero_weights == 15
|
| 32 |
+
assert sum(int((p.weight == 0).sum()) for p in model.layers) == 15
|
| 33 |
+
assert all(row["loss_after"] <= row["loss_before"] + 1e-5 for row in report.layers)
|
upload_to_hub.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create a new Hugging Face MODEL repository for this reusable method."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import HfApi
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
parser = argparse.ArgumentParser()
|
| 14 |
+
parser.add_argument("repo_id", help="For example: Angshul/higher-order-pruner")
|
| 15 |
+
parser.add_argument("--public", action="store_true", help="Create a public repository")
|
| 16 |
+
args = parser.parse_args()
|
| 17 |
+
token = os.environ.get("HF_TOKEN")
|
| 18 |
+
if not token:
|
| 19 |
+
raise SystemExit("Set a write-scoped HF_TOKEN environment variable")
|
| 20 |
+
api = HfApi(token=token)
|
| 21 |
+
if api.repo_exists(args.repo_id, repo_type="model"):
|
| 22 |
+
raise SystemExit(f"Refusing to overwrite existing model repository: {args.repo_id}")
|
| 23 |
+
api.create_repo(repo_id=args.repo_id, repo_type="model",
|
| 24 |
+
private=not args.public, exist_ok=False)
|
| 25 |
+
api.upload_folder(
|
| 26 |
+
repo_id=args.repo_id,
|
| 27 |
+
repo_type="model",
|
| 28 |
+
folder_path=str(Path(__file__).resolve().parent),
|
| 29 |
+
ignore_patterns=[".git/*", "__pycache__/*", "*.pyc", ".pytest_cache/*", "*.zip"],
|
| 30 |
+
commit_message="Publish reusable higher-order pruning API",
|
| 31 |
+
)
|
| 32 |
+
print(f"Published model repository: https://huggingface.co/{args.repo_id}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
if __name__ == "__main__":
|
| 36 |
+
main()
|