Skip to content

Commit ca83c7f

Browse files
authored
Merge branch 'main' into u/vineetgarg/block_activations
2 parents e34a846 + 7ad2df3 commit ca83c7f

8 files changed

Lines changed: 185 additions & 12 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/_graph/_prepare_for_export.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,11 @@ def _get_weight_input_names(
137137
138138
Returns:
139139
Tuple of (module_name, param_name)
140-
- module_name: e.g., "conv", "layer1.0"
140+
- module_name: e.g., "conv", "layer1.0", or "" for a root-module parameter
141141
- param_name: e.g., "weight", "bias"
142142
143143
Raises:
144144
ValueError: If node is not a weight quantization node
145-
ValueError: If weight target path is invalid
146145
147146
"""
148147
if not _is_weight_fake_quant(fake_quant_node, module):
@@ -154,13 +153,10 @@ def _get_weight_input_names(
154153
# Extract module and parameter name from target path
155154
# e.g., "conv.weight" -> ("conv", "weight")
156155
# e.g., "layer1.0.weight" -> ("layer1.0", "weight")
156+
# e.g., "weight" -> ("", "weight") for a parameter on the root module
157157
target_path = str(input_node.target)
158158
last_dot_idx = target_path.rfind(".")
159-
if last_dot_idx == -1:
160-
msg = f"Invalid weight target path: {target_path}"
161-
raise ValueError(msg)
162-
163-
module_name = target_path[:last_dot_idx]
159+
module_name = target_path[:last_dot_idx] if last_dot_idx != -1 else ""
164160
param_name = target_path[last_dot_idx + 1 :]
165161

166162
return module_name, param_name

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.config.spec import CompressionTargetTensor as _CompressionTargetTensor
1617
from coreai_opt.quantization.spec.errors import _BlockSizeMismatchError
1718

@@ -123,9 +124,7 @@ def _resolve_axis(granularity: QuantizationGranularity, tensor_ndim: int) -> int
123124
axis = granularity.axis
124125
if axis is None:
125126
return None
126-
if axis < 0:
127-
axis += tensor_ndim
128-
return axis
127+
return _normalize_axis(axis, tensor_ndim)
129128

130129

131130
@QuantizationGranularity.register("per_tensor")

tests/export/test_pt2e_weight_only_mil_export.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,63 @@ def test_simple_model_weight_only_mil_export(
8383
export_backend=backend,
8484
prepared_model_output=prepared_model_output,
8585
)
86+
87+
88+
@pytest.mark.parametrize("dtype", ["int8", "uint8"])
89+
@pytest.mark.parametrize(
90+
"granularity",
91+
[
92+
PerTensorGranularity(),
93+
PerChannelGranularity(axis=0),
94+
PerBlockGranularity(axis=0, block_size=2),
95+
],
96+
)
97+
def test_root_module_weight_only_mil_export(
98+
dtype: str,
99+
granularity: PerTensorGranularity | PerChannelGranularity | PerBlockGranularity,
100+
) -> None:
101+
"""Test weight-only quantization export to CoreML for a root-module weight.
102+
103+
A bare ``nn.Linear`` owns its weight directly, so the fake-quant input target is the
104+
dot-less ``"weight"`` rather than ``"<submodule>.weight"``. The graph CoreML export
105+
path rejected that with ``Invalid weight target path: weight`` instead of resolving it
106+
to the root module.
107+
"""
108+
model = torch.nn.Linear(8, 4)
109+
model.eval()
110+
model_input = torch.randn(2, 8)
111+
112+
config = QuantizerConfig(
113+
global_config=ModuleQuantizerConfig(
114+
op_state_spec={
115+
"weight": QuantizationSpec(
116+
dtype=dtype,
117+
qscheme=QuantizationScheme.SYMMETRIC,
118+
granularity=granularity,
119+
fake_quantize_cls="default",
120+
qparam_calculator_cls="default",
121+
range_calculator_cls="minmax",
122+
),
123+
},
124+
op_input_spec=None,
125+
op_output_spec=None,
126+
),
127+
)
128+
129+
quantizer = Quantizer(model, config)
130+
prepared_model = quantizer.prepare((model_input,))
131+
132+
with torch.no_grad():
133+
prepared_model_output = prepared_model(model_input)
134+
135+
finalized_model = quantizer.finalize(backend=ExportBackend.CoreML)
136+
137+
export_utils.convert_and_verify(
138+
finalized_model=finalized_model,
139+
input_data=model_input,
140+
expected_ops={
141+
"constexpr_blockwise_shift_scale": 1,
142+
},
143+
export_backend=ExportBackend.CoreML,
144+
prepared_model_output=prepared_model_output,
145+
)

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)