Angshul commited on
Commit
cdfcfcf
·
verified ·
1 Parent(s): 40ce80b

Upload 11 files

Browse files
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()