Skip to content

Commit 3cd7891

Browse files
authored
fix(pruning): normalize a negative channel axis (#45)
* fix(pruning): normalize a negative channel axis ChannelStructured(axis=-1) pruned the wrong channels. _compute_channel_mask compared each dim index against the raw axis when building its reduce list, so a negative axis excluded nothing and the per-channel L1 norms collapsed to a scalar. Normalize the axis first, matching PerChannelGranularity, and reject an out-of-range axis with ValueError rather than letting it reach the reduction. * refactor: move axis normalization into a shared util `_compute_channel_mask` and `QuantizationGranularity._resolve_axis` both resolved a negative axis against the tensor rank. The shared helper lives in `torch_utils`. It resolves the axis and nothing else, so the range check stays where pruning already had it and the quantization path is unchanged.
1 parent 9cec497 commit 3cd7891

6 files changed

Lines changed: 122 additions & 5 deletions

File tree

src/coreai_opt/_utils/torch_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,23 @@ def flatten_tensors_to_list(obj: Any) -> list[torch.Tensor]:
7171
return []
7272

7373

74+
def normalize_axis(axis: int, ndim: int) -> int:
75+
"""Resolve a negative axis against a tensor rank.
76+
77+
A non-negative axis is returned unchanged. The caller must validate the range,
78+
since an axis below ``-ndim`` stays negative here.
79+
80+
Args:
81+
axis (int): Axis in standard Python style indexing.
82+
ndim (int): Rank of the tensor the axis refers to.
83+
84+
Returns:
85+
int: ``axis + ndim`` when axis is negative, otherwise axis unchanged.
86+
87+
"""
88+
return axis + ndim if axis < 0 else axis
89+
90+
7491
def get_module_name(model: torch.nn.Module, module: torch.nn.Module) -> str | None:
7592
"""Find the fully qualified name of a module within a model.
7693

src/coreai_opt/pruning/spec/prune.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
PartialConstructor as _PartialConstructor,
1818
with_args as _with_args,
1919
)
20+
from coreai_opt._utils.torch_utils import normalize_axis as _normalize_axis
2021
from coreai_opt.config.spec import CompressionSimulatorBase
2122

2223
from .scheme import ChannelStructured, PruningScheme
@@ -170,6 +171,12 @@ def _compute_channel_mask(
170171
Channel importance is measured by L1 norm. The least-important
171172
channels are pruned entirely.
172173
"""
174+
if not (-weight.ndim <= axis < weight.ndim):
175+
raise ValueError(
176+
f"Invalid axis. Should be in range [{-weight.ndim}, {weight.ndim}), but got {axis}"
177+
)
178+
axis = _normalize_axis(axis, weight.ndim)
179+
173180
num_channels = weight.shape[axis]
174181
num_prune = math.floor(num_channels * sparsity)
175182

src/coreai_opt/pruning/spec/scheme.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ class ChannelStructured(PruningScheme):
7070
Entire channels (slices along ``axis``) are pruned or kept together.
7171
Channel importance is determined by the pruning algorithm (e.g. L1 norm
7272
of each channel for magnitude-based pruning).
73+
74+
Note:
75+
``axis`` can be negatively indexed as per standard Python style indexing.
7376
"""
7477

7578
axis: int = Field(default=0, description="Axis along which channels are pruned.")

src/coreai_opt/quantization/spec/granularity.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pydantic import BaseModel, ConfigDict, Field, model_serializer
1313

1414
from coreai_opt._utils.registry_utils import ConfigRegistryMixin as _ConfigRegistryMixin
15+
from coreai_opt._utils.torch_utils import normalize_axis as _normalize_axis
1516
from coreai_opt.quantization.spec.errors import _BlockSizeMismatchError
1617

1718

@@ -104,9 +105,7 @@ def _resolve_axis(granularity: QuantizationGranularity, tensor_ndim: int) -> int
104105
axis = granularity.axis
105106
if axis is None:
106107
return None
107-
if axis < 0:
108-
axis += tensor_ndim
109-
return axis
108+
return _normalize_axis(axis, tensor_ndim)
110109

111110

112111
@QuantizationGranularity.register("per_tensor")

tests/pruning/test_magnitude_pruner.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,8 @@ def test_channel_structured_pruning_hand(self) -> None:
369369
)
370370
assert torch.equal(model.weight.detach(), expected)
371371

372-
def test_channel_structured_conv2d(self) -> None:
372+
@pytest.mark.parametrize("axis", [0, -4])
373+
def test_channel_structured_conv2d(self, axis: int) -> None:
373374
"""Channel-structured pruning on Conv2d zeros entire output filters."""
374375
torch.manual_seed(42)
375376
model = nn.Conv2d(3, 8, kernel_size=3, bias=False)
@@ -379,7 +380,7 @@ def test_channel_structured_conv2d(self) -> None:
379380
op_state_spec={
380381
"weight": PruningSpec(
381382
target_sparsity=0.5,
382-
pruning_scheme=ChannelStructured(axis=0),
383+
pruning_scheme=ChannelStructured(axis=axis),
383384
)
384385
}
385386
)
@@ -395,6 +396,72 @@ def test_channel_structured_conv2d(self) -> None:
395396
filt = weight[i]
396397
assert filt.eq(0).all() or filt.ne(0).all(), f"Filter {i} is partially pruned"
397398

399+
@pytest.mark.parametrize("target_sparsity", [0.5, 0.75])
400+
@pytest.mark.parametrize(
401+
"negative_axis,positive_axis",
402+
[(-1, 1), (-2, 0)],
403+
ids=["last-dim", "first-dim"],
404+
)
405+
def test_channel_structured_negative_axis(
406+
self, negative_axis: int, positive_axis: int, target_sparsity: float
407+
) -> None:
408+
"""A negative axis prunes the same channels as its positive equivalent."""
409+
410+
def prune_along(axis: int) -> torch.Tensor:
411+
model = nn.Linear(4, 4, bias=False)
412+
with torch.no_grad():
413+
model.weight.copy_(
414+
torch.tensor(
415+
[
416+
[1.0, 2.0, 3.0, 4.0],
417+
[5.0, 6.0, 7.0, 8.0],
418+
[9.0, 10.0, 11.0, 12.0],
419+
[13.0, 14.0, 15.0, 16.0],
420+
]
421+
)
422+
)
423+
424+
config = MagnitudePrunerConfig(
425+
global_config=ModuleMagnitudePrunerConfig(
426+
op_state_spec={
427+
"weight": PruningSpec(
428+
target_sparsity=target_sparsity,
429+
pruning_scheme=ChannelStructured(axis=axis),
430+
)
431+
}
432+
)
433+
)
434+
pruner = MagnitudePruner(model, config)
435+
pruner.prepare((torch.randn(1, 4),))
436+
return model.weight.detach()
437+
438+
pruned = prune_along(negative_axis)
439+
assert torch.equal(pruned, prune_along(positive_axis))
440+
441+
# L1 norms increase with index along both axes, so the highest indices survive.
442+
num_keep = 4 - int(4 * target_sparsity)
443+
kept = [i for i in range(4) if pruned.select(positive_axis, i).ne(0).any()]
444+
assert kept == list(range(4 - num_keep, 4))
445+
446+
@pytest.mark.parametrize("axis", [-3, 2], ids=["below-range", "above-range"])
447+
def test_channel_structured_axis_out_of_range(self, axis: int) -> None:
448+
"""An axis outside [-ndim, ndim) raises ValueError."""
449+
model = nn.Linear(4, 4, bias=False)
450+
config = MagnitudePrunerConfig(
451+
global_config=ModuleMagnitudePrunerConfig(
452+
op_state_spec={
453+
"weight": PruningSpec(
454+
target_sparsity=0.5,
455+
pruning_scheme=ChannelStructured(axis=axis),
456+
)
457+
}
458+
)
459+
)
460+
pruner = MagnitudePruner(model, config)
461+
462+
with pytest.raises(ValueError, match="Invalid axis"):
463+
pruner.prepare((torch.randn(1, 4),))
464+
398465
def test_linear_unstructured_conv2d_channel_structured(self) -> None:
399466
"""Apply unstructured to Linear and channel-structured to Conv2d in same model."""
400467

tests/test_utils/test_torch_utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
mmap_module_state_dict,
1717
move_model_to_eval,
1818
move_model_to_train,
19+
normalize_axis,
1920
)
2021

2122

@@ -93,6 +94,29 @@ def test_normalize_module_fqn(raw: str, expected: str) -> None:
9394
assert normalize_module_fqn(raw) == expected
9495

9596

97+
class TestNormalizeAxis:
98+
"""Test normalize_axis resolution of negative axes."""
99+
100+
@staticmethod
101+
@pytest.mark.parametrize(
102+
("axis", "ndim", "expected"),
103+
[
104+
(0, 2, 0),
105+
(1, 2, 1),
106+
(-1, 2, 1),
107+
(-2, 2, 0),
108+
(-4, 4, 0),
109+
(-1, 1, 0),
110+
# Out of range passes through unchanged; the caller validates.
111+
(-5, 2, -3),
112+
(3, 2, 3),
113+
],
114+
)
115+
def test_resolves_to_non_negative(axis: int, ndim: int, expected: int) -> None:
116+
"""A negative axis resolves to its non-negative equivalent."""
117+
assert normalize_axis(axis, ndim) == expected
118+
119+
96120
class TestMmapModuleStateDict:
97121
"""Test mmap_module_state_dict serialization and reload."""
98122

0 commit comments

Comments
 (0)