void0x14 commited on
Commit
6466ca1
·
unverified ·
1 Parent(s): c2e902a

feat: add qwen35 text checkpoint pruner

Browse files
MVP/qwen35_prune.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import copy
5
+ import json
6
+ import math
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ from safetensors import safe_open
12
+ from safetensors.torch import save_file
13
+
14
+
15
+ HYBRID_BLOCK = ("linear_attention", "linear_attention", "linear_attention", "full_attention")
16
+ DEFAULT_MINIMUM = 330_000_000
17
+ DEFAULT_MAXIMUM = 350_000_000
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ParameterReport:
22
+ embedding_params: int
23
+ layer_params: tuple[int, ...]
24
+ layer_types: tuple[str, ...]
25
+ final_norm_params: int
26
+ all_named_params: int
27
+
28
+ @property
29
+ def text_backbone_params(self) -> int:
30
+ return self.embedding_params + sum(self.layer_params) + self.final_norm_params
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PrefixChoice:
35
+ layer_count: int
36
+ parameter_count: int
37
+
38
+
39
+ def load_live_config(path: str | Path) -> dict:
40
+ with Path(path).open(encoding="utf-8") as handle:
41
+ return json.load(handle)
42
+
43
+
44
+ def _numel(shape: Iterable[int]) -> int:
45
+ return math.prod(int(dimension) for dimension in shape)
46
+
47
+
48
+ def _layer_index(key: str) -> int | None:
49
+ prefix = "model.language_model.layers."
50
+ if not key.startswith(prefix):
51
+ return None
52
+ remainder = key[len(prefix):]
53
+ index_text = remainder.split(".", 1)[0]
54
+ return int(index_text)
55
+
56
+
57
+ def count_parameter_groups(safetensors_path: str | Path, config: dict) -> ParameterReport:
58
+ text_config = config.get("text_config", config)
59
+ layer_types = tuple(text_config["layer_types"])
60
+ layer_params = [0 for _ in layer_types]
61
+ embedding_params = 0
62
+ final_norm_params = 0
63
+ all_named_params = 0
64
+
65
+ with safe_open(str(safetensors_path), framework="pt", device="cpu") as handle:
66
+ for key in handle.keys():
67
+ shape = handle.get_slice(key).get_shape()
68
+ params = _numel(shape)
69
+ all_named_params += params
70
+ if key == "model.language_model.embed_tokens.weight":
71
+ embedding_params += params
72
+ elif key == "model.language_model.norm.weight":
73
+ final_norm_params += params
74
+ else:
75
+ index = _layer_index(key)
76
+ if index is not None:
77
+ if index >= len(layer_params):
78
+ raise ValueError(f"tensor layer index {index} exceeds config layer count")
79
+ layer_params[index] += params
80
+
81
+ return ParameterReport(
82
+ embedding_params=embedding_params,
83
+ layer_params=tuple(layer_params),
84
+ layer_types=layer_types,
85
+ final_norm_params=final_norm_params,
86
+ all_named_params=all_named_params,
87
+ )
88
+
89
+
90
+ def _is_complete_prefix(layer_types: tuple[str, ...], layer_count: int) -> bool:
91
+ if layer_count == 0 or layer_count % len(HYBRID_BLOCK) != 0:
92
+ return False
93
+ return layer_types[:layer_count] == HYBRID_BLOCK * (layer_count // len(HYBRID_BLOCK))
94
+
95
+
96
+ def choose_prefix(
97
+ report: ParameterReport,
98
+ minimum: int,
99
+ maximum: int,
100
+ requested_layers: int | None = None,
101
+ ) -> PrefixChoice:
102
+ if minimum > maximum:
103
+ raise ValueError("minimum parameter bound exceeds maximum")
104
+ limit = min(len(report.layer_params), len(report.layer_types))
105
+ candidates = [requested_layers] if requested_layers is not None else range(
106
+ len(HYBRID_BLOCK), limit + 1, len(HYBRID_BLOCK)
107
+ )
108
+
109
+ choices: list[PrefixChoice] = []
110
+ for layer_count in candidates:
111
+ if layer_count is None or layer_count > limit:
112
+ continue
113
+ if not _is_complete_prefix(report.layer_types, layer_count):
114
+ if requested_layers is not None:
115
+ raise ValueError("requested layer count is not a complete hybrid block")
116
+ continue
117
+ parameter_count = (
118
+ report.embedding_params
119
+ + report.final_norm_params
120
+ + sum(report.layer_params[:layer_count])
121
+ )
122
+ if minimum <= parameter_count <= maximum:
123
+ choices.append(PrefixChoice(layer_count, parameter_count))
124
+
125
+ if not choices:
126
+ raise ValueError("no complete hybrid prefix fits the parameter interval")
127
+ return max(choices, key=lambda choice: choice.layer_count)
128
+
129
+
130
+ def translate_text_key(key: str) -> str | None:
131
+ if key.startswith("model.visual.") or key.startswith("mtp."):
132
+ return None
133
+ prefix = "model.language_model."
134
+ if key.startswith(prefix):
135
+ return "model." + key[len(prefix):]
136
+ if key == "lm_head.weight":
137
+ return None
138
+ return None
139
+
140
+
141
+ def build_text_config(full_config: dict, layer_count: int) -> dict:
142
+ source = copy.deepcopy(full_config.get("text_config", full_config))
143
+ layer_types = list(source.get("layer_types", []))
144
+ if layer_count <= 0 or layer_count > len(layer_types):
145
+ raise ValueError("layer count is outside the live text config")
146
+ if not _is_complete_prefix(tuple(layer_types), layer_count):
147
+ raise ValueError("layer count is not a complete hybrid block")
148
+ source["model_type"] = "qwen3_5_text"
149
+ source["num_hidden_layers"] = layer_count
150
+ source["layer_types"] = layer_types[:layer_count]
151
+ source["tie_word_embeddings"] = bool(
152
+ full_config.get("tie_word_embeddings", source.get("tie_word_embeddings", False))
153
+ )
154
+ source["architectures"] = ["Qwen3_5ForCausalLM"]
155
+ source.pop("vision_config", None)
156
+ source.pop("mtp_config", None)
157
+ return source
158
+
159
+
160
+ def _keep_source_key(key: str, layer_count: int) -> bool:
161
+ if key == "model.language_model.embed_tokens.weight":
162
+ return True
163
+ if key == "model.language_model.norm.weight":
164
+ return True
165
+ index = _layer_index(key)
166
+ return index is not None and index < layer_count
167
+
168
+
169
+ def prune_checkpoint(
170
+ input_weights: str | Path,
171
+ input_config: str | Path,
172
+ output_dir: str | Path,
173
+ layer_count: int,
174
+ ) -> dict:
175
+ full_config = load_live_config(input_config)
176
+ text_config = build_text_config(full_config, layer_count)
177
+ output_path = Path(output_dir)
178
+ output_path.mkdir(parents=True, exist_ok=True)
179
+ tensors = {}
180
+
181
+ with safe_open(str(input_weights), framework="pt", device="cpu") as handle:
182
+ for key in handle.keys():
183
+ if not _keep_source_key(key, layer_count):
184
+ continue
185
+ output_key = translate_text_key(key)
186
+ if output_key is None:
187
+ raise ValueError(f"source tensor cannot be translated: {key}")
188
+ tensors[output_key] = handle.get_tensor(key)
189
+
190
+ output_weights = output_path / "model.safetensors"
191
+ save_file(tensors, str(output_weights), metadata={"format": "pt"})
192
+ output_config = output_path / "config.json"
193
+ output_config.write_text(json.dumps(text_config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
194
+
195
+ from MVP.validate_checkpoint import validate_checkpoint
196
+
197
+ report = validate_checkpoint(
198
+ output_config,
199
+ output_weights,
200
+ DEFAULT_MINIMUM,
201
+ DEFAULT_MAXIMUM,
202
+ )
203
+ return asdict(report)
204
+
205
+
206
+ def _main() -> None:
207
+ parser = argparse.ArgumentParser(description="Measure and prune Qwen3.5 text backbone tensors")
208
+ subparsers = parser.add_subparsers(dest="command", required=True)
209
+
210
+ measure = subparsers.add_parser("measure")
211
+ measure.add_argument("--weights", required=True)
212
+ measure.add_argument("--config", required=True)
213
+
214
+ prune = subparsers.add_parser("prune")
215
+ prune.add_argument("--weights", required=True)
216
+ prune.add_argument("--config", required=True)
217
+ prune.add_argument("--output", required=True)
218
+ prune.add_argument("--layers", type=int, required=True)
219
+
220
+ validate = subparsers.add_parser("validate")
221
+ validate.add_argument("--weights", required=True)
222
+ validate.add_argument("--config", required=True)
223
+ validate.add_argument("--minimum", type=int, default=DEFAULT_MINIMUM)
224
+ validate.add_argument("--maximum", type=int, default=DEFAULT_MAXIMUM)
225
+
226
+ args = parser.parse_args()
227
+ if args.command == "measure":
228
+ report = count_parameter_groups(args.weights, load_live_config(args.config))
229
+ print(json.dumps(asdict(report) | {"text_backbone_params": report.text_backbone_params}, indent=2))
230
+ elif args.command == "prune":
231
+ print(json.dumps(prune_checkpoint(args.weights, args.config, args.output, args.layers), indent=2))
232
+ else:
233
+ from MVP.validate_checkpoint import validate_checkpoint
234
+
235
+ print(json.dumps(asdict(validate_checkpoint(args.config, args.weights, args.minimum, args.maximum)), indent=2))
236
+
237
+
238
+ if __name__ == "__main__":
239
+ _main()
MVP/tests/test_pruning_contract.py CHANGED
@@ -2,10 +2,12 @@ import json
2
  from pathlib import Path
3
 
4
  import pytest
 
5
 
6
  from MVP.qwen35_prune import (
7
  ParameterReport,
8
  build_text_config,
 
9
  choose_prefix,
10
  translate_text_key,
11
  )
@@ -116,3 +118,38 @@ def test_validator_rejects_vision_mtp_and_duplicate_tied_head():
116
  with pytest.raises(ValueError, match="vision|MTP|tied"):
117
  validate_state_dict_keys(keys, config, 330_000_000, 350_000_000)
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from pathlib import Path
3
 
4
  import pytest
5
+ from safetensors.torch import save_file
6
 
7
  from MVP.qwen35_prune import (
8
  ParameterReport,
9
  build_text_config,
10
+ count_parameter_groups,
11
  choose_prefix,
12
  translate_text_key,
13
  )
 
118
  with pytest.raises(ValueError, match="vision|MTP|tied"):
119
  validate_state_dict_keys(keys, config, 330_000_000, 350_000_000)
120
 
121
+
122
+ def test_metadata_counter_uses_tensor_shapes_without_loading_model():
123
+ data = load_fixture()
124
+ config = {
125
+ "text_config": {
126
+ "layer_types": list(data["layer_types"][:4]),
127
+ }
128
+ }
129
+ tensors = {
130
+ "model.language_model.embed_tokens.weight": (2, 3),
131
+ "model.language_model.layers.0.input_layernorm.weight": (2,),
132
+ "model.language_model.layers.1.input_layernorm.weight": (3,),
133
+ "model.language_model.layers.2.input_layernorm.weight": (4,),
134
+ "model.language_model.layers.3.input_layernorm.weight": (5,),
135
+ "model.language_model.norm.weight": (2,),
136
+ "model.visual.patch_embed.proj.weight": (7,),
137
+ "mtp.fc.weight": (11,),
138
+ }
139
+
140
+ def make_tensor(shape):
141
+ import torch
142
+
143
+ return torch.zeros(shape, dtype=torch.float32)
144
+
145
+ path = FIXTURE.parent / "counter-fixture.safetensors"
146
+ try:
147
+ save_file({key: make_tensor(shape) for key, shape in tensors.items()}, str(path))
148
+ report = count_parameter_groups(path, config)
149
+ finally:
150
+ path.unlink(missing_ok=True)
151
+
152
+ assert report.embedding_params == 6
153
+ assert report.layer_params == (2, 3, 4, 5)
154
+ assert report.final_norm_params == 2
155
+ assert report.all_named_params == 40
MVP/validate_checkpoint.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Mapping, Sequence
8
+
9
+ from safetensors import safe_open
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ValidationReport:
14
+ parameter_count: int
15
+ tensor_count: int
16
+ layer_count: int
17
+ visual_tensor_count: int
18
+ mtp_tensor_count: int
19
+ tied_lm_head_present: bool
20
+ valid: bool
21
+
22
+
23
+ def _numel(shape: Sequence[int]) -> int:
24
+ return math.prod(int(dimension) for dimension in shape)
25
+
26
+
27
+ def _layer_indices(keys: Sequence[str]) -> set[int]:
28
+ indices = set()
29
+ prefix = "model.layers."
30
+ for key in keys:
31
+ if key.startswith(prefix):
32
+ indices.add(int(key[len(prefix):].split(".", 1)[0]))
33
+ return indices
34
+
35
+
36
+ def validate_state_dict_keys(
37
+ shapes: Mapping[str, Sequence[int]],
38
+ config: Mapping[str, object],
39
+ minimum: int,
40
+ maximum: int,
41
+ ) -> ValidationReport:
42
+ keys = tuple(shapes.keys())
43
+ visual_count = sum(key.startswith("model.visual.") for key in keys)
44
+ mtp_count = sum(key.startswith("mtp.") for key in keys)
45
+ tied_head = "lm_head.weight" in shapes and bool(config.get("tie_word_embeddings", False))
46
+ if visual_count or mtp_count or tied_head:
47
+ raise ValueError("vision/MTP tensors or a duplicate tied head are present")
48
+ if config.get("model_type") != "qwen3_5_text":
49
+ raise ValueError("standalone text config must use model_type qwen3_5_text")
50
+
51
+ layer_count = int(config["num_hidden_layers"])
52
+ expected_indices = set(range(layer_count))
53
+ actual_indices = _layer_indices(keys)
54
+ if actual_indices != expected_indices:
55
+ raise ValueError(f"layer indices are not contiguous: expected {expected_indices}, got {actual_indices}")
56
+ layer_types = tuple(config["layer_types"])
57
+ if len(layer_types) != layer_count:
58
+ raise ValueError("config layer_types length does not match num_hidden_layers")
59
+ parameter_count = sum(_numel(shape) for shape in shapes.values())
60
+ if not minimum <= parameter_count <= maximum:
61
+ raise ValueError(f"parameter count {parameter_count} is outside [{minimum}, {maximum}]")
62
+ return ValidationReport(
63
+ parameter_count=parameter_count,
64
+ tensor_count=len(keys),
65
+ layer_count=layer_count,
66
+ visual_tensor_count=visual_count,
67
+ mtp_tensor_count=mtp_count,
68
+ tied_lm_head_present=tied_head,
69
+ valid=True,
70
+ )
71
+
72
+
73
+ def validate_checkpoint(
74
+ config_path: str | Path,
75
+ weights_path: str | Path,
76
+ minimum: int,
77
+ maximum: int,
78
+ ) -> ValidationReport:
79
+ config = json.loads(Path(config_path).read_text(encoding="utf-8"))
80
+ shapes = {}
81
+ with safe_open(str(weights_path), framework="pt", device="cpu") as handle:
82
+ for key in handle.keys():
83
+ shapes[key] = tuple(handle.get_slice(key).get_shape())
84
+ report = validate_state_dict_keys(shapes, config, minimum, maximum)
85
+ Path(config_path).with_name("validation.json").write_text(
86
+ json.dumps(asdict(report), indent=2, sort_keys=True) + "\n", encoding="utf-8"
87
+ )
88
+ return report
89
+