已合并
[feature] 权重转换 #537
caishengcheng创建于 6月10日
[feature] 权重转换 #537
已合并
caishengcheng创建于 6月10日
50 个文件变更+4524-153
@@ -112,6 +112,7 @@ service_oriented = msmodelslim.infra.service_oriented_evaluate_service:get_plugi
112[Plugin:quant_service]112[Plugin:quant_service]
113modelslim_v0 = msmodelslim.core.quant_service.modelslim_v0.quant_service:get_plugin113modelslim_v0 = msmodelslim.core.quant_service.modelslim_v0.quant_service:get_plugin
114modelslim_v1 = msmodelslim.core.quant_service.modelslim_v1.quant_service:get_plugin114modelslim_v1 = msmodelslim.core.quant_service.modelslim_v1.quant_service:get_plugin
115+modelslim_convert = msmodelslim.core.quant_service.modelslim_convert.quant_service:get_plugin
115multimodal_sd_modelslim_v1 = msmodelslim.core.quant_service.multimodal_sd_v1.quant_service:get_plugin116multimodal_sd_modelslim_v1 = msmodelslim.core.quant_service.multimodal_sd_v1.quant_service:get_plugin
116multimodal_vlm_modelslim_v1 = msmodelslim.core.quant_service.multimodal_vlm_v1.quant_service:get_plugin117multimodal_vlm_modelslim_v1 = msmodelslim.core.quant_service.multimodal_vlm_v1.quant_service:get_plugin
117 118 
@@ -0,0 +1,27 @@
1+apiversion: modelslim_convert
2+ 
3+# 权重结构(Qwen3-8B dense bf16 checkpoint):
4+# - 前缀 model.layers.*(36 层 layer 0~35)
5+# - 每层 7 个 linear:self_attn.{q,k,v,o}_proj + mlp.{gate,up,down}_proj
6+# - 源格式 bf16(FLOAT),仅 .weight,无 weight_scale_inv
7+# - q_norm / k_norm / layernorm 不纳入 linears
8+# - embed_tokens / lm_head / model.norm 等非 linears 权重自动原样保留并落盘
9+spec:
10+ linears:
11+ - match:
12+ - "model.layers.*.self_attn.q_proj"
13+ - "model.layers.*.self_attn.k_proj"
14+ - "model.layers.*.self_attn.v_proj"
15+ - "model.layers.*.self_attn.o_proj"
16+ - "model.layers.*.mlp.gate_proj"
17+ - "model.layers.*.mlp.up_proj"
18+ - "model.layers.*.mlp.down_proj"
19+ target: W8A8_MXFP8
R

【review】此处的target与route的最后一个项是始终冗余的。

likedislike
20+ route: auto
21+ 
22+ save:
23+ - type: ascend_v1
24+ part_file_size: 4
25+ 
26+ parallel:
27+ workers: 8
@@ -0,0 +1,25 @@
1+apiversion: modelslim_convert
2+ 
3+# 权重结构(依据 qwen3-8b-fp8 model.safetensors.index.json 校准):
4+# - 与 mxfp8 配置相同的 7×36=252 个 FP8_BLOCK linear
5+# - 仅反量化到 FLOAT(bf16),不走 MXFP8 量化
6+# - 落盘为 HuggingFace / compressed_tensors 格式,供 HF 侧推理
7+spec:
8+ linears:
9+ - match:
10+ - "model.layers.*.self_attn.q_proj"
11+ - "model.layers.*.self_attn.k_proj"
12+ - "model.layers.*.self_attn.v_proj"
13+ - "model.layers.*.self_attn.o_proj"
14+ - "model.layers.*.mlp.gate_proj"
15+ - "model.layers.*.mlp.up_proj"
16+ - "model.layers.*.mlp.down_proj"
17+ target: FLOAT
18+ route: auto
19+ 
20+ save:
21+ - type: huggingface
22+ part_file_size: 4
23+ 
24+ parallel:
25+ workers: 8
@@ -0,0 +1,28 @@
1+apiversion: modelslim_convert
2+ 
3+# 权重结构(依据 qwen3-8b-fp8 model.safetensors.index.json 校准):
4+# - 前缀 model.layers.*(dense,36 层 layer 0~35)
5+# - 每层 7 个 FP8_BLOCK linear(带 .weight_scale_inv):
6+# self_attn.{q,k,v,o}_proj + mlp.{gate,up,down}_proj
7+# - q_norm / k_norm / layernorm 为 bf16,无 weight_scale_inv,不纳入 linears
8+# - embed_tokens / lm_head 等非 linears 权重自动原样保留并落盘
9+# - 共 2 个 safetensors 分片(model-00001/00002-of-00002)
10+spec:
11+ linears:
12+ - match:
13+ - "model.layers.*.self_attn.q_proj"
14+ - "model.layers.*.self_attn.k_proj"
15+ - "model.layers.*.self_attn.v_proj"
16+ - "model.layers.*.self_attn.o_proj"
17+ - "model.layers.*.mlp.gate_proj"
18+ - "model.layers.*.mlp.up_proj"
19+ - "model.layers.*.mlp.down_proj"
20+ target: W8A8_MXFP8
21+ route: auto
22+ 
23+ save:
24+ - type: ascend_v1
25+ part_file_size: 4
26+ 
27+ parallel:
28+ workers: 8
@@ -18,6 +18,7 @@ MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
18See the Mulan PSL v2 for more details.18See the Mulan PSL v2 for more details.
19-------------------------------------------------------------------------19-------------------------------------------------------------------------
20"""20"""
21+ 
21import re22import re
22from enum import Enum23from enum import Enum
23from pathlib import Path24from pathlib import Path
@@ -37,7 +38,7 @@ from msmodelslim.utils.security import yaml_safe_load
37from msmodelslim.utils.validation.conversion import (38from msmodelslim.utils.validation.conversion import (
38 convert_to_readable_file,39 convert_to_readable_file,
39 convert_to_writable_dir,40 convert_to_writable_dir,
40- convert_to_readable_dir41+ convert_to_readable_dir,
41)42)
42from msmodelslim.utils.validation.value import validate_str_length43from msmodelslim.utils.validation.value import validate_str_length
43from .model_info_interface import ModelInfoInterface44from .model_info_interface import ModelInfoInterface
@@ -55,6 +56,7 @@ class TipsType(str, Enum):
55 C:量化方式是否更改 C1-更改;C0-未更改56 C:量化方式是否更改 C1-更改;C0-未更改
56 B:是否是最佳实践 B1-是最佳实践;B0-非最佳实践57 B:是否是最佳实践 B1-是最佳实践;B0-非最佳实践
57 """58 """
59+ 
58 Q0C0B0 = "Q0_C0_B0" # 未指定量化方式,未更改量化方式,非最佳实践,未指定量化方式场景不存在量化方式变更场景60 Q0C0B0 = "Q0_C0_B0" # 未指定量化方式,未更改量化方式,非最佳实践,未指定量化方式场景不存在量化方式变更场景
59 Q0C0B1 = "Q0_C0_B1" # 未指定量化方式,未更改量化方式,是最佳实践,未指定量化方式场景不存在量化方式变更场景61 Q0C0B1 = "Q0_C0_B1" # 未指定量化方式,未更改量化方式,是最佳实践,未指定量化方式场景不存在量化方式变更场景
60 Q1C0B0 = "Q1_C0_B0" # 已指定量化方式,未更改量化方式,非最佳实践62 Q1C0B0 = "Q1_C0_B0" # 已指定量化方式,未更改量化方式,非最佳实践
@@ -75,24 +77,34 @@ def _build_quant_tips(tips_type: TipsType, model_type: str, quant_type: QuantTyp
75 """77 """
76 78 
77 if tips_type == TipsType.Q0C0B0:79 if tips_type == TipsType.Q0C0B0:
78- return (f"No quant_type or config_path provided. Default quant_type:{DEFAULT_QUANT_TYPE} will be used."80+ return (
79- f"The default practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used.")81+ f"No quant_type or config_path provided. Default quant_type:{DEFAULT_QUANT_TYPE} will be used."
82+ f"The default practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used."
83+ )
80 elif tips_type == TipsType.Q0C0B1:84 elif tips_type == TipsType.Q0C0B1:
81- return (f"No quant_type or config_path provided. Default quant_type:{DEFAULT_QUANT_TYPE} will be used."85+ return (
82- f"The best practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used.")86+ f"No quant_type or config_path provided. Default quant_type:{DEFAULT_QUANT_TYPE} will be used."
87+ f"The best practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used."
88+ )
83 elif tips_type == TipsType.Q1C0B0:89 elif tips_type == TipsType.Q1C0B0:
84- return (f"No best practice found for model_type={model_type} and quant_type={quant_type}. "90+ return (
85- f"The default practice:{config_id} for {quant_type} will be used.")91+ f"No best practice found for model_type={model_type} and quant_type={quant_type}. "
92+ f"The default practice:{config_id} for {quant_type} will be used."
93+ )
86 elif tips_type == TipsType.Q1C0B1:94 elif tips_type == TipsType.Q1C0B1:
87 return ""95 return ""
88 elif tips_type == TipsType.Q1C1B0:96 elif tips_type == TipsType.Q1C1B0:
89- return (f"No best practice found for model_type={model_type} and quant_type={quant_type}. "97+ return (
90- f"The default practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used.")98+ f"No best practice found for model_type={model_type} and quant_type={quant_type}. "
99+ f"The default practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used."
100+ )
91 elif tips_type == TipsType.Q1C1B1:101 elif tips_type == TipsType.Q1C1B1:
92- return (f"No best practice found for model_type={model_type} and quant_type={quant_type}. "102+ return (
93- f"The best practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used.")103+ f"No best practice found for model_type={model_type} and quant_type={quant_type}. "
104+ f"The best practice:{config_id} for {DEFAULT_QUANT_TYPE} will be used."
105+ )
94 else:106 else:
95- raise UnsupportedError(f"Get best practice error", action="Please use the correct msmodelslim version.")107+ raise UnsupportedError("Get best practice error", action="Please use the correct msmodelslim version.")
96 108 
97 109 
98def validate_device_index(device_index: Optional[List[int]], device_type: DeviceType):110def validate_device_index(device_index: Optional[List[int]], device_type: DeviceType):
@@ -111,16 +123,13 @@ def validate_device_index(device_index: Optional[List[int]], device_type: Device
111 if any(idx < 0 for idx in device_index):123 if any(idx < 0 for idx in device_index):
112 negative_indices = [idx for idx in device_index if idx < 0]124 negative_indices = [idx for idx in device_index if idx < 0]
113 raise SchemaValidateError(125 raise SchemaValidateError(
114- f"Device indices must be non-negative integers, "126+ f"Device indices must be non-negative integers, but got negative values: {negative_indices}"
115- f"but got negative values: {negative_indices}"
116 )127 )
117 128 
118 # Value validation: check for duplicates129 # Value validation: check for duplicates
119 if len(device_index) != len(set(device_index)):130 if len(device_index) != len(set(device_index)):
120 duplicates = [idx for idx in set(device_index) if device_index.count(idx) > 1]131 duplicates = [idx for idx in set(device_index) if device_index.count(idx) > 1]
121- raise SchemaValidateError(132+ raise SchemaValidateError(f"Device indices must be unique, but found duplicates: {duplicates}")
122- f"Device indices must be unique, but found duplicates: {duplicates}"
123- )
124 133 
125 # CPU does not support multi-device134 # CPU does not support multi-device
126 if device_type == DeviceType.CPU and len(device_index) > 1:135 if device_type == DeviceType.CPU and len(device_index) > 1:
@@ -149,13 +158,12 @@ def validate_device_index(device_index: Optional[List[int]], device_type: Device
149 158 
150@logger_setter('msmodelslim.app.naive_quantization')159@logger_setter('msmodelslim.app.naive_quantization')
151class NaiveQuantizationApplication:160class NaiveQuantizationApplication:
152- 
153 def __init__(161 def __init__(
154- self,162+ self,
155- practice_manager: PracticeManagerInfra,163+ practice_manager: PracticeManagerInfra,
156- quant_service: IQuantService,164+ quant_service: IQuantService,
157- model_factory: IModelFactory,165+ model_factory: IModelFactory,
158- quant_config_export_infra: Optional[QuantConfigExportInfra] = None,166+ quant_config_export_infra: Optional[QuantConfigExportInfra] = None,
159 ):167 ):
160 self.practice_manager = practice_manager168 self.practice_manager = practice_manager
161 self.quant_service = quant_service169 self.quant_service = quant_service
@@ -164,11 +172,11 @@ class NaiveQuantizationApplication:
164 172 
165 @staticmethod173 @staticmethod
166 def check_config(174 def check_config(
167- config: PracticeConfig,175+ config: PracticeConfig,
168- model_type: str,176+ model_type: str,
169- quant_type: QuantType,177+ quant_type: QuantType,
170- scenario_tags: Optional[List[str]] = None,178+ scenario_tags: Optional[List[str]] = None,
171- is_default=False179+ is_default=False,
172 ) -> ScenarioTagMatch:180 ) -> ScenarioTagMatch:
173 label = config.metadata.label181 label = config.metadata.label
174 # Parse quant_type parameters182 # Parse quant_type parameters
@@ -182,7 +190,7 @@ class NaiveQuantizationApplication:
182 use_fa_quant = suffix == 'f8'190 use_fa_quant = suffix == 'f8'
183 is_sparse = bool(match_result.group(4))191 is_sparse = bool(match_result.group(4))
184 192 
185- """Check if the label matches the quantization parameters"""193+ # Check if the label matches the quantization parameters
186 if label.get('w_bit') != w_bit:194 if label.get('w_bit') != w_bit:
187 return ScenarioTagMatch.NO_MATCH195 return ScenarioTagMatch.NO_MATCH
188 if label.get('a_bit') != a_bit:196 if label.get('a_bit') != a_bit:
@@ -204,12 +212,13 @@ class NaiveQuantizationApplication:
204 212 
205 return config.matches_scenario_tags(model_type, scenario_tags)213 return config.matches_scenario_tags(model_type, scenario_tags)
206 214 
207- def get_best_practice(self,215+ def get_best_practice(
208- model_adapter: IModel,216+ self,
209- quant_type: Optional[QuantType] = None,217+ model_adapter: IModel,
210- config_path: Optional[Path] = None,218+ quant_type: Optional[QuantType] = None,
211- tag: Optional[List[str]] = None219+ config_path: Optional[Path] = None,
212- ) -> PracticeConfig:220+ tag: Optional[List[str]] = None,
221+ ) -> PracticeConfig:
213 """222 """
214 获取最佳实践匹配规则如下:223 获取最佳实践匹配规则如下:
215 场景1:指定config_path配置文件,直接采用,忽略quant_type配置224 场景1:指定config_path配置文件,直接采用,忽略quant_type配置
@@ -222,22 +231,25 @@ class NaiveQuantizationApplication:
222 if config_path is not None:231 if config_path is not None:
223 config_dict = yaml_safe_load(str(config_path))232 config_dict = yaml_safe_load(str(config_path))
224 config = PracticeConfig.model_validate(config_dict)233 config = PracticeConfig.model_validate(config_dict)
225- get_logger().info(f"Naive Quant apply config_path: {config_path}")234+ get_logger().info("Naive Quant apply config_path: %s", config_path)
226 return config235 return config
227 236 
228 if not isinstance(model_adapter, ModelInfoInterface):237 if not isinstance(model_adapter, ModelInfoInterface):
229- raise ToDoError(f"Model adapter {model_adapter.__class__.__name__} "238+ raise ToDoError(
230- f"does NOT implement ModelInfoInterface",239+ f"Model adapter {model_adapter.__class__.__name__} does NOT implement ModelInfoInterface",
231- action="Please implement ModelInfoInterface to support get best practice.")240+ action="Please implement ModelInfoInterface to support get best practice.",
241+ )
232 242 
233 model_type = model_adapter.get_model_type()243 model_type = model_adapter.get_model_type()
234 model_pedigree = model_adapter.get_model_pedigree()244 model_pedigree = model_adapter.get_model_pedigree()
235 245 
236 # Handle unknown model246 # Handle unknown model
237 if model_pedigree not in self.practice_manager:247 if model_pedigree not in self.practice_manager:
238- raise ToDoError(f"model_pedigree {model_pedigree} does NOT exist",248+ raise ToDoError(
239- action=f"Maybe you need change model_pedigree of model_adapter "249+ f"model_pedigree {model_pedigree} does NOT exist",
240- f"or add {model_pedigree} in lab_practice.")250+ action=f"Maybe you need change model_pedigree of model_adapter "
251+ f"or add {model_pedigree} in lab_practice.",
252+ )
241 253 
242 config, tips = self.get_config(model_pedigree, model_type, quant_type, tag)254 config, tips = self.get_config(model_pedigree, model_type, quant_type, tag)
243 255 
@@ -251,19 +263,20 @@ class NaiveQuantizationApplication:
251 return config263 return config
252 264 
253 def get_config(265 def get_config(
254- self,266+ self,
255- model_pedigree: str,267+ model_pedigree: str,
256- model_type: str,268+ model_type: str,
257- quant_type: Optional[QuantType] = None,269+ quant_type: Optional[QuantType] = None,
258- tag: Optional[List[str]] = None270+ tag: Optional[List[str]] = None,
259 ):271 ):
260- has_quant_type = True if quant_type is not None else False272+ has_quant_type = quant_type is not None
261 use_quant_type = quant_type if quant_type is not None else DEFAULT_QUANT_TYPE273 use_quant_type = quant_type if quant_type is not None else DEFAULT_QUANT_TYPE
262 standby_configs: List[PracticeConfig] = []274 standby_configs: List[PracticeConfig] = []
263- is_default = True if model_pedigree==DEFAULT_PEDIGREE else False275+ is_default = model_pedigree == DEFAULT_PEDIGREE
264 276 
265- def _check(config: PracticeConfig, model_type: str, qt: QuantType, tag: Optional[List[str]] = None,277+ def _check(
266- is_default=False):278+ config: PracticeConfig, model_type: str, qt: QuantType, tag: Optional[List[str]] = None, is_default=False
279+ ):
267 return self.check_config(config, model_type, qt, tag, is_default)280 return self.check_config(config, model_type, qt, tag, is_default)
268 281 
269 def _build_return(config: PracticeConfig, tips_type: TipsType, qt: QuantType):282 def _build_return(config: PracticeConfig, tips_type: TipsType, qt: QuantType):
@@ -302,7 +315,7 @@ class NaiveQuantizationApplication:
302 return _build_return(config, tips_type, use_quant_type)315 return _build_return(config, tips_type, use_quant_type)
303 316 
304 if use_quant_type == DEFAULT_QUANT_TYPE or not has_quant_type:317 if use_quant_type == DEFAULT_QUANT_TYPE or not has_quant_type:
305- raise UnsupportedError(f"Get best practice error", action="Please use the correct msmodelslim version.")318+ raise UnsupportedError("Get best practice error", action="Please use the correct msmodelslim version.")
306 319 
307 # 场景3:【默认量化方式】在模型适配器的最佳实践目录搜索默认量化类型的最佳实践320 # 场景3:【默认量化方式】在模型适配器的最佳实践目录搜索默认量化类型的最佳实践
308 for config in self.practice_manager.iter_config(model_pedigree):321 for config in self.practice_manager.iter_config(model_pedigree):
@@ -330,23 +343,25 @@ class NaiveQuantizationApplication:
330 continue343 continue
331 return _build_return(config, TipsType.Q1C1B0, DEFAULT_QUANT_TYPE)344 return _build_return(config, TipsType.Q1C1B0, DEFAULT_QUANT_TYPE)
332 345 
333- raise UnsupportedError(f"Get best practice error", action="Please use the correct msmodelslim version.")346+ raise UnsupportedError("Get best practice error", action="Please use the correct msmodelslim version.")
334 347 
335 @exception_catcher348 @exception_catcher
336- def quant(self,349+ def quant(
337- model_type: str,350+ self,
338- model_path: str,351+ model_type: Optional[str],
339- save_path: str,352+ model_path: str,
340- device_type: DeviceType = DeviceType.NPU,353+ save_path: str,
341- device_index: Optional[List[int]] = None,354+ device_type: DeviceType = DeviceType.NPU,
342- quant_type: Optional[QuantType] = None,355+ device_index: Optional[List[int]] = None,
343- config_path: Optional[str] = None,356+ quant_type: Optional[QuantType] = None,
344- trust_remote_code: bool = False,357+ config_path: Optional[str] = None,
345- tag: Optional[List[str]] = None):358+ trust_remote_code: bool = False,
359+ tag: Optional[List[str]] = None,
360+ ):
346 """361 """
347 Run the naive quantization application.362 Run the naive quantization application.
348 Args:363 Args:
349- model_type: str, the type of the model364+ model_type: Optional[str], the type of the model; omit when config_path uses apiversion modelslim_convert
350 model_path: str, the path of the model365 model_path: str, the path of the model
351 save_path: str, the path to save the quantized model366 save_path: str, the path to save the quantized model
352 device_type: DeviceType, the type of device (e.g., DeviceType.NPU, DeviceType.CPU)367 device_type: DeviceType, the type of device (e.g., DeviceType.NPU, DeviceType.CPU)
@@ -360,15 +375,14 @@ class NaiveQuantizationApplication:
360 tag: Optional[List[str]], e.g. ['vLLM-Ascend','Atlas_A2_Inference'], tags to match configs with verified_tags375 tag: Optional[List[str]], e.g. ['vLLM-Ascend','Atlas_A2_Inference'], tags to match configs with verified_tags
361 """376 """
362 # 字符串类型与长度校验377 # 字符串类型与长度校验
363- str_params = [378+ for param_name, value in [("model_path", model_path), ("save_path", save_path)]:
364- ("model_type", model_type),
365- ("model_path", model_path),
366- ("save_path", save_path)
367- ]
368- for param_name, value in str_params:
369 if not isinstance(value, str):379 if not isinstance(value, str):
370 raise SchemaValidateError(f"{param_name} must be a string, but got {type(value)}")380 raise SchemaValidateError(f"{param_name} must be a string, but got {type(value)}")
371 validate_str_length(input_str=value, str_name=param_name)381 validate_str_length(input_str=value, str_name=param_name)
382+ if model_type is not None:
383+ if not isinstance(model_type, str):
384+ raise SchemaValidateError(f"model_type must be a string, but got {type(model_type)}")
385+ validate_str_length(input_str=model_type, str_name="model_type")
372 386 
373 model_path = convert_to_readable_dir(model_path)387 model_path = convert_to_readable_dir(model_path)
374 if not isinstance(model_path, Path):388 if not isinstance(model_path, Path):
@@ -385,13 +399,18 @@ class NaiveQuantizationApplication:
385 config_path = convert_to_readable_file(config_path)399 config_path = convert_to_readable_file(config_path)
386 # 允许quant_type和config_path均为空的场景400 # 允许quant_type和config_path均为空的场景
387 if quant_type is not None and config_path is not None:401 if quant_type is not None and config_path is not None:
388- raise SchemaValidateError(f"quant_type and config_path only one can be provided")402+ raise SchemaValidateError("quant_type and config_path only one can be provided")
389 if quant_type is not None and not isinstance(quant_type, QuantType):403 if quant_type is not None and not isinstance(quant_type, QuantType):
390- raise SchemaValidateError(f"quant_type must be a QuantType")404+ raise SchemaValidateError("quant_type must be a QuantType")
391 if config_path is not None and not isinstance(config_path, Path):405 if config_path is not None and not isinstance(config_path, Path):
392 raise SchemaValidateError(f"config_path must be a Path, but got {type(config_path)}")406 raise SchemaValidateError(f"config_path must be a Path, but got {type(config_path)}")
407+ if model_type is None:
408+ from msmodelslim.core.quant_service.modelslim_convert.config_detect import is_modelslim_convert_config
409+ 
410+ if config_path is None or not is_modelslim_convert_config(config_path):
411+ raise SchemaValidateError("model_type is required unless config_path uses apiversion modelslim_convert")
393 if not isinstance(trust_remote_code, bool):412 if not isinstance(trust_remote_code, bool):
394- raise SchemaValidateError(f"trust_remote_code must be a bool")413+ raise SchemaValidateError("trust_remote_code must be a bool")
395 if tag is not None:414 if tag is not None:
396 if not isinstance(tag, list):415 if not isinstance(tag, list):
397 raise SchemaValidateError(f"tag must be a list or None, but got {type(tag)}")416 raise SchemaValidateError(f"tag must be a list or None, but got {type(tag)}")
@@ -399,73 +418,87 @@ class NaiveQuantizationApplication:
399 tag = None418 tag = None
400 419 
401 # Log parameters420 # Log parameters
402- get_logger().info(f'quantization with following parameters:')421+ get_logger().info("quantization with following parameters:")
403- get_logger().info(f"model_type: {model_type}")422+ get_logger().info("model_type: %s", model_type)
404- get_logger().info(f"model_path: {model_path}")423+ get_logger().info("model_path: %s", model_path)
405- get_logger().info(f"save_path: {save_path}")424+ get_logger().info("save_path: %s", save_path)
406- get_logger().info(f"device_type: {device_type}")425+ get_logger().info("device_type: %s", device_type)
407 if device_index is not None and len(device_index) > 1:426 if device_index is not None and len(device_index) > 1:
408 device_list = ','.join(map(str, device_index))427 device_list = ','.join(map(str, device_index))
409 get_logger().info(428 get_logger().info(
410- f"using {len(device_index)} devices: {device_type.value}:{device_list}"429+ "using %d devices: %s:%s",
430+ len(device_index),
431+ device_type.value,
432+ device_list,
411 )433 )
412 elif device_index is not None and len(device_index) == 1:434 elif device_index is not None and len(device_index) == 1:
413- get_logger().info(f"using single device: {device_type.value}:{device_index[0]}")435+ get_logger().info("using single device: %s:%s", device_type.value, device_index[0])
414 else:436 else:
415- get_logger().info(f"using single device (default): {device_type.value}")437+ get_logger().info("using single device (default): %s", device_type.value)
416 if quant_type is not None:438 if quant_type is not None:
417- get_logger().info(f"quant_type: {quant_type}")439+ get_logger().info("quant_type: %s", quant_type)
418 if config_path is not None:440 if config_path is not None:
419- get_logger().info(f"config_path: {config_path}")441+ get_logger().info("config_path: %s", config_path)
420- get_logger().info(f"trust_remote_code: {trust_remote_code}")442+ get_logger().info("trust_remote_code: %s", trust_remote_code)
421 if tag:443 if tag:
422- get_logger().info(f"tag: {tag}")444+ get_logger().info("tag: %s", tag)
423 445 
424 self._quant(446 self._quant(
425- model_type, model_path, save_path, device_type,447+ model_type,
426- device_index, quant_type, config_path, trust_remote_code, tag448+ model_path,
449+ save_path,
450+ device_type,
451+ device_index,
452+ quant_type,
453+ config_path,
454+ trust_remote_code,
455+ tag,
427 )456 )
428 457 
429 def _quant(458 def _quant(
430- self,459+ self,
431- model_type: str,460+ model_type: Optional[str],
432- model_path: Path,461+ model_path: Path,
433- save_path: Path,462+ save_path: Path,
434- device_type: DeviceType = DeviceType.NPU,463+ device_type: DeviceType = DeviceType.NPU,
435- device_index: Optional[List[int]] = None,464+ device_index: Optional[List[int]] = None,
436- quant_type: Optional[QuantType] = None,465+ quant_type: Optional[QuantType] = None,
437- config_path: Optional[Path] = None,466+ config_path: Optional[Path] = None,
438- trust_remote_code: bool = False,467+ trust_remote_code: bool = False,
439- tag: Optional[List[str]] = None,468+ tag: Optional[List[str]] = None,
440 ):469 ):
441- get_logger().info(f"===========ANALYSE MODEL===========")470+ get_logger().info("===========ANALYSE MODEL===========")
442- model_adapter = self.model_factory.create(471+ from msmodelslim.core.quant_service.modelslim_convert.config_detect import is_modelslim_convert_config
443- model_type, model_path, trust_remote_code472+ from msmodelslim.model.base import BaseModelAdapter
444- )
445- get_logger().info(f"Using model adapter {model_adapter.__class__.__name__}.")
446 473 
447- get_logger().info(f"===========GET BEST PRACTICE===========")474+ use_convert_adapter = config_path is not None and is_modelslim_convert_config(config_path)
475+ if use_convert_adapter:
476+ model_adapter = BaseModelAdapter(
477+ model_type=model_type or "convert",
478+ model_path=model_path,
479+ trust_remote_code=trust_remote_code,
480+ )
481+ get_logger().info("Using BaseModelAdapter for modelslim_convert (no model code load).")
482+ else:
483+ model_adapter = self.model_factory.create(model_type, model_path, trust_remote_code)
484+ get_logger().info("Using model adapter %s.", model_adapter.__class__.__name__)
485+ 
486+ get_logger().info("===========GET BEST PRACTICE===========")
448 practice_config = self.get_best_practice(487 practice_config = self.get_best_practice(
449- model_adapter=model_adapter,488+ model_adapter=model_adapter, quant_type=quant_type, config_path=config_path, tag=tag
450- quant_type=quant_type,
451- config_path=config_path,
452- tag=tag
453 )489 )
454 # 使用量化配置导出基础设施导出配置490 # 使用量化配置导出基础设施导出配置
455- self.quant_config_export_infra.export_quant_config(491+ export_model_type = model_type or "convert"
456- practice_config,492+ self.quant_config_export_infra.export_quant_config(practice_config, export_model_type, save_path)
457- model_type,
458- save_path
459- )
460 493 
461- get_logger().info(f"Get best practice {practice_config.metadata.config_id} success.")494+ get_logger().info("Get best practice %s success.", practice_config.metadata.config_id)
462 495 
463- get_logger().info(f"===========QUANTIZE MODEL===========")496+ get_logger().info("===========QUANTIZE MODEL===========")
464 self.quant_service.quantize(497 self.quant_service.quantize(
465 quant_config=practice_config.extract_quant_config(),498 quant_config=practice_config.extract_quant_config(),
466 model_adapter=model_adapter,499 model_adapter=model_adapter,
467 save_path=save_path,500 save_path=save_path,
468 device=device_type,501 device=device_type,
469- device_indices=device_index502+ device_indices=device_index,
470 )503 )
471- get_logger().info(f"===========SUCCESS===========")504+ get_logger().info("===========SUCCESS===========")
@@ -123,7 +123,11 @@ def main():
123 # Quant command123 # Quant command
124 quant_parser = subparsers.add_parser('quant', help='Model quantization')124 quant_parser = subparsers.add_parser('quant', help='Model quantization')
125 quant_parser.add_argument(125 quant_parser.add_argument(
126- '--model_type', required=True, help="Type of model to quantize (e.g. 'Qwen2.5-7B-Instruct', 'Qwen-QwQ-32B')"126+ '--model_type',
127+ required=False,
128+ default=None,
129+ help="Type of model to quantize (e.g. 'Qwen2.5-7B-Instruct'). "
130+ "Optional when --config_path uses apiversion modelslim_convert (weight convert needs only model_path).",
127 )131 )
128 quant_parser.add_argument('--model_path', required=True, type=str, help="Path to the original model")132 quant_parser.add_argument('--model_path', required=True, type=str, help="Path to the original model")
129 quant_parser.add_argument('--save_path', required=True, type=str, help="Path to save quantized model")133 quant_parser.add_argument('--save_path', required=True, type=str, help="Path to save quantized model")
@@ -0,0 +1,81 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Offline weight conversion: core types, configuration schemas, and protocols.
6+ 
7+This package defines the stable contracts used by ``app/convert`` and
8+``processor/convert``. Implementations are added incrementally; callers
9+should depend on these interfaces rather than concrete orchestration code.
10+"""
11+ 
12+from msmodelslim.core.convert.catalog import (
13+ DependencyMap,
14+ PreprocessResult,
15+ RestoreRule,
16+ TensorCatalog,
17+ TensorEntry,
18+)
19+from msmodelslim.core.convert.config import (
20+ ConvertConfig,
21+ ConvertDefaults,
22+ ConvertRule,
23+ ModuleRule,
24+ ParallelConfig,
25+ WeightMappingRule,
26+ WeightOpConfig,
27+)
28+from msmodelslim.core.convert.protocol import (
29+ ConvertContext,
30+ ICheckpointReader,
31+ IConvertExecutor,
32+ IIRTransformProcessor,
33+ IPreprocessExecutor,
34+ IRTaskBuilder,
35+ ISaveProcessorAdapter,
36+ IVirtualModelTreeBuilder,
37+)
38+from msmodelslim.core.convert.edges import RouteConstraints, TransformEdge
39+from msmodelslim.core.convert.router import IRRouter
40+from msmodelslim.core.convert.tasks import IRResult, IRTask, RoutedTask
41+from msmodelslim.core.convert.types import (
42+ IRKind,
43+ LossLevel,
44+ SourceIR,
45+ TensorRef,
46+ TensorRole,
47+)
48+ 
49+__all__ = [
50+ "ConvertConfig",
51+ "ConvertDefaults",
52+ "ConvertRule",
53+ "ModuleRule",
54+ "ParallelConfig",
55+ "WeightMappingRule",
56+ "WeightOpConfig",
57+ "DependencyMap",
58+ "PreprocessResult",
59+ "RestoreRule",
60+ "TensorCatalog",
61+ "TensorEntry",
62+ "ConvertContext",
63+ "ICheckpointReader",
64+ "IConvertExecutor",
65+ "IIRTransformProcessor",
66+ "IPreprocessExecutor",
67+ "IRTaskBuilder",
68+ "ISaveProcessorAdapter",
69+ "IVirtualModelTreeBuilder",
70+ "RouteConstraints",
71+ "IRRouter",
72+ "TransformEdge",
73+ "IRResult",
74+ "IRTask",
75+ "RoutedTask",
76+ "IRKind",
77+ "LossLevel",
78+ "SourceIR",
79+ "TensorRef",
80+ "TensorRole",
81+]
@@ -0,0 +1,38 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Global optimal IR routes for ``route: auto``.
6+ 
7+Given source IR (inferred from virtual-tree tensor dtypes) and target IR
8+(from convert rules), return the canonical transform chain.
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+from msmodelslim.core.convert.types import IRKind
14+from msmodelslim.utils.exception import UnsupportedError
15+ 
16+# (src_ir, dst_ir) -> full route including both endpoints
17+_BEST_ROUTES: dict[tuple[IRKind, IRKind], list[IRKind]] = {
18+ (IRKind.FP8_BLOCK, IRKind.FLOAT): [IRKind.FP8_BLOCK, IRKind.FLOAT],
19+ (IRKind.FP8_BLOCK, IRKind.W8A8_MXFP8): [
20+ IRKind.FP8_BLOCK,
21+ IRKind.FLOAT,
22+ IRKind.W8A8_MXFP8,
23+ ],
24+ (IRKind.FLOAT, IRKind.W8A8_MXFP8): [IRKind.FLOAT, IRKind.W8A8_MXFP8],
25+}
26+ 
27+ 
28+def resolve_auto_route(src_ir: IRKind, dst_ir: IRKind) -> list[IRKind]:
29+ """Look up the optimal route for (src_ir, dst_ir); raise if unsupported."""
30+ if src_ir == dst_ir:
31+ return [src_ir]
32+ route = _BEST_ROUTES.get((src_ir, dst_ir))
33+ if route is None:
34+ raise UnsupportedError(
35+ f"No auto route from {src_ir.value} to {dst_ir.value}. "
36+ "Specify an explicit route in convert_rules or extend auto_routes.",
37+ )
38+ return list(route)
@@ -0,0 +1,139 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+预处理产物:逻辑 TensorCatalog、依赖图、恢复计划(convert_design.md §6、§6.4)。
6+ 
7+``TensorCatalog`` 是虚拟树与任务调度的唯一权重索引源;逻辑 key 可与 index.json 物理 key 不同
8+(例如 preprocess 生成的 per-expert gate_proj.weight,实际数据仍在 fused gate_up_proj shard 内)。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+from dataclasses import dataclass, field
14+from typing import Iterator
15+ 
16+from msmodelslim.core.convert.config import WeightOpConfig
17+ 
18+ 
19+@dataclass
20+class TensorEntry:
21+ """Catalog 中单条逻辑张量的元数据(载荷延迟加载)。"""
22+ 
23+ key: str
24+ shard: str
25+ dtype: str
26+ shape: tuple[int, ...]
27+ meta: dict = field(default_factory=dict)
28+ 
29+ 
30+class TensorCatalog:
31+ """key -> TensorEntry;预处理后可包含仅存在于 meta 的虚拟 key。"""
32+ 
33+ def __init__(self) -> None:
34+ self._entries: dict[str, TensorEntry] = {}
35+ 
36+ def add(self, entry: TensorEntry) -> None:
37+ self._entries[entry.key] = entry
38+ 
39+ def get(self, key: str) -> TensorEntry | None:
40+ return self._entries.get(key)
41+ 
42+ def keys(self) -> Iterator[str]:
43+ return iter(self._entries)
44+ 
45+ def items(self) -> Iterator[tuple[str, TensorEntry]]:
46+ return iter(self._entries.items())
47+ 
48+ def __len__(self) -> int:
49+ return len(self._entries)
50+ 
51+ def to_weight_map(self) -> dict[str, str]:
52+ """逻辑 key -> shard 文件名(与 index.json 同构,供 DependencyMap 使用)。"""
53+ return {k: v.shard for k, v in self._entries.items()}
54+ 
55+ @classmethod
56+ def from_raw_weight_map(
57+ cls,
58+ weight_map: dict[str, str],
59+ header_by_key: dict[str, tuple[str, tuple[int, ...]]] | None = None,
60+ ) -> TensorCatalog:
61+ """从原始 weight_map 引导 catalog(可选附带 header 缓存)。"""
62+ catalog = cls()
63+ header_by_key = header_by_key or {}
64+ for key, shard in weight_map.items():
65+ dtype, shape = header_by_key.get(key, ("UNKNOWN", ()))
66+ catalog.add(TensorEntry(key=key, shard=shard, dtype=dtype, shape=shape))
67+ return catalog
68+ 
69+ 
70+@dataclass
71+class RestoreRule:
72+ """
73+ 保存前反向结构变换规则(例如 per-expert → fused gate_up_proj)。
74+ 
75+ ``when`` 通常为 ``before_save``;``merge_gate_up`` 算子尚未在 SaveProcessorAdapter 实现。
76+ """
77+ 
78+ id: str
79+ source_patterns: list[str]
80+ target_pattern: str
81+ ops: list[WeightOpConfig] = field(default_factory=list)
82+ when: str = "before_save"
83+ 
84+ 
85+class DependencyMap:
86+ """
87+ IR 任务加载依赖(类比 model_free_ptq 的 inverse_weight_map 规划)。
88+ 
89+ - ``add_owner``:逻辑/物理 key 所在 shard
90+ - ``add_dependency``:逻辑 key 依赖的其它 key(如 fused_from)
91+ - ``inverse_load_map``:单任务需要打开的 shard -> tensor 名列表
92+ """
93+ 
94+ def __init__(self) -> None:
95+ self._owners: dict[str, str] = {}
96+ self._deps: dict[str, set[str]] = {}
97+ 
98+ def add_owner(self, key: str, shard: str) -> None:
99+ self._owners[key] = shard
100+ 
101+ def add_dependency(self, owner: str, dependency: str) -> None:
102+ self._deps.setdefault(owner, set()).add(dependency)
103+ 
104+ def dependencies_of(self, key: str) -> set[str]:
105+ return set(self._deps.get(key, ()))
106+ 
107+ def inverse_load_map(self, keys: list[str]) -> dict[str, list[str] | None]:
108+ shard_to_names: dict[str, set[str]] = {}
109+ for key in keys:
110+ shard = self._owners.get(key)
111+ if shard is None:
112+ continue
113+ shard_to_names.setdefault(shard, set()).add(key)
114+ return {shard: sorted(names) if names else None for shard, names in shard_to_names.items()}
115+ 
116+ 
117+@dataclass
118+class PreprocessResult:
119+ """PreprocessExecutor 输出,挂到 ``ConvertContext.preprocess_result``。"""
120+ 
121+ catalog: TensorCatalog
122+ dependency_map: DependencyMap = field(default_factory=DependencyMap)
123+ applied_rules: list[str] = field(default_factory=list)
124+ 
125+ 
126+def build_dependency_map(
127+ weight_map: dict[str, str],
128+ catalog: TensorCatalog | None = None,
129+) -> DependencyMap:
130+ """构建默认依赖图;``fused_from`` meta 会写入 ``add_dependency``。"""
131+ deps = DependencyMap()
132+ for key, shard in weight_map.items():
133+ deps.add_owner(key, shard)
134+ if catalog is not None:
135+ for key, entry in catalog.items():
136+ fused = entry.meta.get("fused_from")
137+ if fused and fused in weight_map:
138+ deps.add_dependency(key, fused)
139+ return deps
@@ -0,0 +1,150 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Pydantic configuration schemas for ``msmodelslim convert``.
6+ 
7+Three rule families are kept separate on purpose:
8+ - ``preprocess_rules``: structural changes to the weight map
9+ - ``module_rules``: virtual tree construction and tensor bindings
10+ - ``convert_rules``: per-layer target IR and routing constraints
11+"""
12+ 
13+from __future__ import annotations
14+ 
15+from typing import Any, Literal
16+ 
17+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
18+ 
19+from msmodelslim.core.convert.types import IRKind
20+ 
21+# 产品约束:W8A8_MXFP8 权重仅在昇腾 NPU 上运行,落盘须走 AscendV1,不用 HF/compressed_tensors。
22+_MXFP8_TARGET_IR = IRKind.W8A8_MXFP8
23+_ASCENDV1_DST_FORMATS = frozenset({"ascendv1", "ascendv1_saver"})
24+ 
25+ 
26+class WeightOpConfig(BaseModel):
27+ """Declarative weight-map operation (chunk, concat, rename, ...)."""
28+ 
29+ model_config = ConfigDict(extra="forbid")
30+ 
31+ type: str
32+ params: dict[str, Any] = Field(default_factory=dict)
33+ 
34+ 
35+class WeightMappingRule(BaseModel):
36+ """
37+ Preprocess rule: map checkpoint keys to logical keys via structural ops.
38+ 
39+ Inspired by HuggingFace ``WeightConverter`` / ``ConversionOps``; applied
40+ to the catalog before virtual tree construction.
41+ """
42+ 
43+ model_config = ConfigDict(extra="forbid")
44+ 
45+ id: str
46+ source_patterns: list[str]
47+ target_patterns: list[str]
48+ ops: list[WeightOpConfig] = Field(default_factory=list)
49+ module_kind: str = "linear"
50+ reversible: bool = True
51+ 
52+ 
53+class ModuleRule(BaseModel):
54+ """
55+ Virtual-tree rule: which modules exist and how checkpoint keys bind to IR fields.
56+ """
57+ 
58+ model_config = ConfigDict(extra="forbid")
59+ 
60+ match: str
61+ module_kind: str = "linear"
62+ source_format: str | None = None
63+ source_ir: IRKind | None = None
64+ tensor_map: dict[str, str] = Field(default_factory=dict)
65+ convert: bool = True
66+ defaults: dict[str, Any] = Field(default_factory=dict)
67+ 
68+ 
69+class ConvertRule(BaseModel):
70+ """Per-layer conversion target and optional explicit route."""
71+ 
72+ model_config = ConfigDict(extra="forbid")
73+ 
74+ match: str
75+ target_ir: IRKind
76+ route: list[IRKind] | Literal["auto"] = "auto"
77+ action: Literal["transform", "passthrough", "skip"] = "transform"
78+ 
79+ 
80+class ConvertDefaults(BaseModel):
81+ """Global defaults when rules omit fields."""
82+ 
83+ model_config = ConfigDict(extra="forbid")
84+ 
85+ src_format: str = "auto"
86+ dst_format: str = "ascendv1"
87+ dst_ir: IRKind | None = None
88+ 
89+ 
90+class ParallelConfig(BaseModel):
91+ """Worker pool and memory budget for IR-task execution."""
92+ 
93+ model_config = ConfigDict(extra="forbid")
94+ 
95+ max_workers: int = 1
96+ max_inflight_bytes: int | None = None
97+ max_tensor_bytes_per_task: int | None = None
98+ shard_cache_size: int = 1
99+ worker_device: str = "cpu"
100+ # thread: 组内 ThreadPoolExecutor(受 GIL 限制);process: 组间 ProcessPoolExecutor,纯 CPU 计算并行
101+ worker_backend: Literal["thread", "process"] = "process"
102+ # 每个 worker 进程内的线程数(YAML 层固定为 4,不经配置暴露)
103+ worker_threads: int = 4
104+ # 仅 worker_backend=thread 且 worker_device 指向 NPU 时生效,限制组内并发以防显存 OOM
105+ npu_max_workers: int = 1
106+ task_granularity: Literal["ir_task", "dependency_group"] = "dependency_group"
107+ # 单个 dependency group 的最大任务数;超过则按任务切成多个子组分散到不同进程并行,
108+ # 缓解 MoE 大组(一层 512 个 expert 任务)只能单进程承包导致的收尾拖尾、多核空闲。
109+ # None 或 <=0 表示不拆分(保持整组,fused 缓存复用率最高)。
110+ max_group_size: int | None = None
111+ 
112+ 
113+class ConvertConfig(BaseModel):
114+ """
115+ Top-level convert job configuration loaded from CLI/YAML.
116+ """
117+ 
118+ model_config = ConfigDict(extra="forbid")
119+ 
120+ model_path: str
121+ save_path: str
122+ model_family: str | None = None
123+ dst_format: str = "ascendv1"
124+ defaults: ConvertDefaults = Field(default_factory=ConvertDefaults)
125+ preprocess_rules: list[WeightMappingRule] = Field(default_factory=list)
126+ module_rules: list[ModuleRule] = Field(default_factory=list)
127+ convert_rules: list[ConvertRule] = Field(default_factory=list)
128+ parallel: ParallelConfig = Field(default_factory=ParallelConfig)
129+ 
130+ @field_validator("model_path", "save_path", mode="after")
131+ @classmethod
132+ def _non_empty_path(cls, v: str) -> str:
133+ if not v or not str(v).strip():
134+ raise ValueError("path must be non-empty")
135+ return v
136+ 
137+ @model_validator(mode="after")
138+ def _mxfp8_requires_ascendv1(self) -> ConvertConfig:
139+ """任意 convert_rule 目标为 W8A8_MXFP8 时,dst_format 必须为 ascendv1。"""
140+ wants_mxfp8 = any(r.action == "transform" and r.target_ir == _MXFP8_TARGET_IR for r in self.convert_rules)
141+ if not wants_mxfp8:
142+ return self
143+ dst = self.dst_format.lower()
144+ if dst not in _ASCENDV1_DST_FORMATS:
145+ raise ValueError(
146+ f"target_ir W8A8_MXFP8 requires dst_format ascendv1 (Ascend NPU deployment); "
147+ f"got dst_format={self.dst_format!r}. Use huggingface/compressed_tensors only for "
148+ f"FLOAT targets (e.g. fp8_block -> bf16)."
149+ )
150+ return self
@@ -0,0 +1,75 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Convert 阶段的设备解析与 worker 并发上限工具。
6+ 
7+``worker_backend=process`` 时全程固定 CPU;thread 后端可解析到 NPU。
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+import torch
13+ 
14+from msmodelslim.utils.logging import get_logger
15+ 
16+logger = get_logger()
17+ 
18+ 
19+def npu_available() -> bool:
20+ return hasattr(torch, "npu") and torch.npu.is_available()
21+ 
22+ 
23+def resolve_worker_device(worker_device: str | None) -> str:
24+ """
25+ 将 ``parallel.worker_device`` 解析为 safetensors / torch 可用的设备字符串。
26+ 
27+ 支持:
28+ - ``auto``:有 NPU 时用 ``npu:0``,否则 ``cpu``
29+ - ``cpu`` / ``npu``:简写;``npu`` 等价于 ``npu:0``
30+ - ``npu:0`` / ``npu:1`` 等:显式指定单卡
31+ """
32+ spec = (worker_device or "auto").strip()
33+ lowered = spec.lower()
34+ 
35+ if lowered == "auto":
36+ if npu_available():
37+ return "npu:0"
38+ logger.warning("No NPU available; convert weights on CPU instead")
39+ return "cpu"
40+ 
41+ if lowered == "cpu":
42+ return "cpu"
43+ 
44+ if lowered == "npu":
45+ if not npu_available():
46+ logger.warning("worker_device=npu but NPU unavailable; falling back to CPU")
47+ return "cpu"
48+ return "npu:0"
49+ 
50+ if lowered.startswith("npu"):
51+ if not npu_available():
52+ logger.warning("worker_device=%r but NPU unavailable; falling back to CPU", spec)
53+ return "cpu"
54+ return spec
55+ 
56+ raise ValueError(
57+ f"Unsupported worker_device {worker_device!r}; "
58+ "expected cpu, npu, auto, or an explicit npu:<index> device string"
59+ )
60+ 
61+ 
62+def effective_convert_workers(
63+ max_workers: int,
64+ resolved_worker_device: str,
65+ npu_max_workers: int,
66+) -> int:
67+ """
68+ NPU 上多 worker 并发会把多张 2D 权重 + 量化中间张量同时驻留显存,易 OOM。
69+ 在 accelerator 模式下将组内并发上限压到 ``npu_max_workers``(默认 1)。
70+ """
71+ workers = max(1, max_workers)
72+ if resolved_worker_device == "cpu":
73+ return workers
74+ cap = max(1, npu_max_workers)
75+ return min(workers, cap)
@@ -0,0 +1,31 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""IR graph edge definition (shared by router and tasks)."""
5+ 
6+from __future__ import annotations
7+ 
8+from dataclasses import dataclass, field
9+ 
10+from msmodelslim.core.convert.types import IRKind, LossLevel
11+ 
12+ 
13+@dataclass
14+class RouteConstraints:
15+ """Explicit routing requirements from convert_rules."""
16+ 
17+ required_nodes: list[IRKind] = field(default_factory=list)
18+ forbidden_edges: list[tuple[IRKind, IRKind]] = field(default_factory=list)
19+ explicit_route: list[IRKind] | None = None
20+ 
21+ 
22+@dataclass(frozen=True)
23+class TransformEdge:
24+ """One edge in the convert IR graph."""
25+ 
26+ src_ir: IRKind
27+ dst_ir: IRKind
28+ processor_name: str
29+ loss_level: LossLevel = LossLevel.LOSSY
30+ requirements: tuple[str, ...] = ()
31+ cost: int = 1
@@ -0,0 +1,284 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+离线权重转换(msmodelslim convert)领域协议定义。
6+ 
7+本文件只做一件事:定义“阶段接口”和“阶段之间共享的数据契约”,不包含具体实现。
8+你可以把它看成 convert 子系统的“接口说明书 + 数据流规范”。
9+ 
10+设计目标:
11+1. 让 app 层编排器(ConvertApplication)只依赖抽象接口,而不依赖具体实现类。
12+2. 让不同实现(reader / preprocess / executor / save)可替换、可测试。
13+3. 把“输入是什么、输出是什么、阶段间怎么交接”写清楚,减少隐式约定。
14+ 
15+重要约束(由 router 注册阶段强制校验):
16+- convert 专用 Processor 必须是离线数据无关型,
17+ 即 ``requires_forward=False`` 且 ``requires_calibration=False``。
18+"""
19+ 
20+from __future__ import annotations
21+ 
22+from abc import ABC, abstractmethod
23+from pathlib import Path
24+from typing import Any, Iterator, Protocol, runtime_checkable
25+ 
26+from torch import nn
27+ 
28+from msmodelslim.core.convert.catalog import PreprocessResult, TensorCatalog
29+from msmodelslim.core.convert.config import ConvertConfig, WeightMappingRule
30+from msmodelslim.core.convert.tasks import IRResult, IRTask, RoutedTask
31+from msmodelslim.core.convert.types import IRKind
32+ 
33+ 
34+class ConvertContext:
35+ """
36+ 单次 convert 运行的可变上下文(全流程共享的“运行时状态”)。
37+ 
38+ 这个对象贯穿整个流水线:
39+ CLI/Fatory 创建后,Preprocess / TreeBuilder / TaskBuilder / Executor / Save
40+ 都会在它上面读写各自阶段的中间结果。
41+ 
42+ 字段说明:
43+ - config: ``ConvertConfig``
44+ 本次任务的静态配置(路径、规则、并行参数、目标格式等)。
45+ - model_path / save_path: ``Path``
46+ 源 checkpoint 目录与输出目录(从 config 预解析为 Path)。
47+ - reader: ``ICheckpointReader | None``
48+ 读盘器,负责 index/header/tensor 读取。
49+ - virtual_tree: ``nn.Module | None``
50+ 虚拟模块树(不做 forward);executor 会把转换后的模块写回此树。
51+ - preprocess_result: ``PreprocessResult | None``
52+ 预处理结果,含目录改写与 DependencyMap。
53+ - catalog: ``TensorCatalog | None``
54+ 当前有效“权重目录”(一般为 preprocess 后目录)。
55+ """
56+ 
57+ def __init__(
58+ self,
59+ config: ConvertConfig,
60+ reader: ICheckpointReader | None = None,
61+ ) -> None:
62+ self.config = config
63+ self.model_path = Path(config.model_path)
64+ self.save_path = Path(config.save_path)
65+ self.reader = reader
66+ self.virtual_tree: nn.Module | None = None
67+ self.preprocess_result: PreprocessResult | None = None
68+ self.catalog: TensorCatalog | None = None
69+ # resolve_worker_device(config.parallel.worker_device) 的结果;executor / processor 共用。
70+ # worker_backend=process 时固定为 "cpu"。
71+ self.resolved_worker_device: str = "cpu"
72+ 
73+ 
74+class ICheckpointReader(ABC):
75+ """
76+ 离线 checkpoint 读取协议(不依赖模型 forward)。
77+ 
78+ 职责边界:
79+ - 负责“如何从磁盘读取 index/header/tensor”;
80+ - 不负责规则匹配、路由、转换、保存等业务决策。
81+ 
82+ 典型实现位于 ``infra/io``,本接口是 convert 领域层对读盘能力的契约。
83+ """
84+ 
85+ @abstractmethod
86+ def read_weight_map(self) -> dict[str, str]:
87+ """
88+ 读取原始索引映射。
89+ 
90+ 返回:
91+ dict[tensor_key, shard_path]
92+ """
93+ pass
94+ 
95+ @abstractmethod
96+ def read_catalog(self) -> TensorCatalog:
97+ """
98+ 构建权重目录(TensorCatalog)。
99+ 
100+ 实现可选择“仅 index 快速构建”或“携带部分 header 信息”。
101+ """
102+ pass
103+ 
104+ @abstractmethod
105+ def read_header(self, key: str) -> tuple[str, tuple[int, ...]]:
106+ """
107+ 读取单个 tensor 的头信息(不加载真实 tensor 数据)。
108+ 
109+ 返回:
110+ (dtype_name, shape)
111+ """
112+ pass
113+ 
114+ @abstractmethod
115+ def load_tensors(
116+ self,
117+ inverse_weight_map: dict[str, list[str] | None],
118+ device: str = "cpu",
119+ ) -> dict[str, Any]:
120+ """
121+ 按任务加载 tensor 数据。
122+ 
123+ 参数:
124+ inverse_weight_map:
125+ dict[shard_path, list[tensor_key] | None]
126+ - list 为具体要加载的 key 列表
127+ - None 表示该 shard 全量加载(由实现决定是否支持)
128+ device:
129+ 目标设备(通常为 "cpu"
130+ 
131+ 返回:
132+ dict[tensor_key, tensor]
133+ """
134+ pass
135+ 
136+ @abstractmethod
137+ def read_model_config(self) -> dict[str, Any]:
138+ """
139+ 读取模型配置(如 config.json)。
140+ 
141+ 用于提供 preprocess/processor 所需的上下文参数(例如 num_experts、block_size)。
142+ """
143+ pass
144+ 
145+ 
146+class IPreprocessExecutor(ABC):
147+ """
148+ 预处理执行接口:对 catalog 应用 preprocess 规则并产出后续阶段可直接消费的结果。
149+ 
150+ 输出 ``PreprocessResult``,通常包含:
151+ - 预处理后的逻辑目录(catalog)
152+ - DependencyMap(任务分组、逆向加载映射等)
153+ """
154+ 
155+ @abstractmethod
156+ def run(
157+ self,
158+ context: ConvertContext,
159+ raw_catalog: TensorCatalog,
160+ rules: list[WeightMappingRule],
161+ ) -> PreprocessResult:
162+ pass
163+ 
164+ 
165+class IVirtualModelTreeBuilder(ABC):
166+ """
167+ 虚拟模块树构建接口。
168+ 
169+ 输入:
170+ - preprocess 后目录
171+ - module_rules / policy / reader
172+ 
173+ 输出:
174+ - 懒加载的虚拟 ``nn.Module`` 树(ModelFreeLinear / PassthroughModule 等)
175+ """
176+ 
177+ @abstractmethod
178+ def build(self, context: ConvertContext, catalog: TensorCatalog) -> nn.Module:
179+ pass
180+ 
181+ 
182+class IRTaskBuilder(ABC):
183+ """
184+ IR 任务构建接口。
185+ 
186+ 从虚拟树中枚举“需要转换的层”,并结合 convert_rules 生成 ``IRTask`` 列表。
187+ """
188+ 
189+ @abstractmethod
190+ def build(
191+ self,
192+ context: ConvertContext,
193+ tree: nn.Module,
194+ catalog: TensorCatalog,
195+ ) -> list[IRTask]:
196+ pass
197+ 
198+ 
199+@runtime_checkable
200+class IIRTransformProcessor(Protocol):
201+ """
202+ IR 变换处理器协议(路由图中的“一条有向边”)。
203+ 
204+ 语义:
205+ - 一个 processor = 一种 ``src_ir -> dst_ir`` 的转换能力;
206+ - 例如 ``FP8_BLOCK -> FLOAT``、``FLOAT -> W8A8_MXFP8``。
207+ 
208+ 字段约定:
209+ - name: 处理器唯一名称(注册和检索使用)
210+ - src_ir / dst_ir: 源/目标 IR 类型标签
211+ - requires_forward / requires_calibration:
212+ convert 场景必须为 False(否则 router 拒绝注册)
213+ - loss_level: 转换损失等级(lossy / lossless 等)
214+ 
215+ transform 约定:
216+ - 输入为可转换模块(通常已 lazy_init 完成);
217+ - 返回转换后的模块(可原地改,也可返回新对象);
218+ - 不应依赖运行时样本数据(forward/calibration 数据)。
219+ """
220+ 
221+ name: str
222+ src_ir: IRKind
223+ dst_ir: IRKind
224+ requires_forward: bool
225+ requires_calibration: bool
226+ loss_level: str
227+ 
228+ def transform(self, module: nn.Module, context: ConvertContext) -> nn.Module:
229+ """
230+ 执行单步 IR 变换。
231+ 
232+ 参数:
233+ module: 当前层模块(通常为虚拟层或中间转换产物)
234+ context: 本次运行上下文(可用于读取配置/reader)
235+ 
236+ 返回:
237+ 转换后的模块(目标 IR 形态)
238+ """
239+ pass
240+ 
241+ 
242+class IConvertExecutor(ABC):
243+ """
244+ 转换执行器接口:调度并执行已完成路由的任务列表(可并行)。
245+ 
246+ 为何返回 ``Iterator[IRResult]``:
247+ - 支持流式消费结果(边执行边汇报/边写回);
248+ - 降低一次性聚合所有结果的内存压力;
249+ - 出错时可更早暴露并中止流程。
250+ 
251+ 典型调用方:
252+ - ``ConvertApplication.run`` 中:
253+ ``for result in executor.run(context, routed_tasks): ...``
254+ """
255+ 
256+ @abstractmethod
257+ def run(
258+ self,
259+ context: ConvertContext,
260+ routed_tasks: list[RoutedTask],
261+ ) -> Iterator[IRResult]:
262+ pass
263+ 
264+ 
265+class ISaveProcessorAdapter(ABC):
266+ """
267+ 保存适配接口:把虚拟树导出到目标权重格式。
268+ 
269+ 目的:
270+ - 复用现有 SaveProcessor / format writer,而不是重复实现写盘逻辑。
271+ - 根据 ``dst_format`` 选择具体保存后端(如 AscendV1 / HF 等)。
272+ 
273+ 注意:
274+ - convert 侧只关心“给我一棵最终树,把它写出去”;
275+ - 具体文件布局、分片策略、附加元信息由保存后端负责。
276+ """
277+ 
278+ @abstractmethod
279+ def save(
280+ self,
281+ context: ConvertContext,
282+ tree: nn.Module,
283+ ) -> None:
284+ pass
@@ -0,0 +1,163 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+IR graph router: shortest-path (or constrained) routing between IR nodes.
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from typing import TYPE_CHECKING
11+ 
12+from msmodelslim.core.convert.edges import RouteConstraints, TransformEdge
13+from msmodelslim.core.convert.types import IRKind, LossLevel
14+from msmodelslim.utils.exception import UnsupportedError
15+ 
16+if TYPE_CHECKING:
17+ from msmodelslim.core.convert.protocol import IIRTransformProcessor
18+ 
19+ 
20+class IRRouter:
21+ """
22+ Resolve transform paths on a registered IR graph.
23+ 
24+ Usage::
25+ 
26+ router = IRRouter.default()
27+ edges = router.resolve(src_ir=IRKind.FP8_BLOCK, dst_ir=IRKind.W8A8_MXFP8)
28+ """
29+ 
30+ _DEFAULT: IRRouter | None = None
31+ 
32+ def __init__(self) -> None:
33+ self._edges: list[TransformEdge] = []
34+ self._processors: dict[str, IIRTransformProcessor] = {}
35+ 
36+ @classmethod
37+ def default(cls) -> IRRouter:
38+ if cls._DEFAULT is None:
39+ from msmodelslim.processor.convert.registry import register_convert_processors
40+ 
41+ cls._DEFAULT = register_convert_processors(cls())
42+ return cls._DEFAULT
43+ 
44+ def register_edge(self, edge: TransformEdge) -> None:
45+ self._edges.append(edge)
46+ 
47+ def register_processor(self, processor: IIRTransformProcessor) -> None:
48+ if processor.requires_forward or processor.requires_calibration:
49+ raise UnsupportedError(
50+ f"Convert cannot register processor {processor.name}: "
51+ "requires_forward and requires_calibration must be False.",
52+ )
53+ self._processors[processor.name] = processor
54+ self.register_edge(
55+ TransformEdge(
56+ src_ir=processor.src_ir,
57+ dst_ir=processor.dst_ir,
58+ processor_name=processor.name,
59+ loss_level=LossLevel(processor.loss_level)
60+ if processor.loss_level in LossLevel._value2member_map_
61+ else LossLevel.LOSSY,
62+ ),
63+ )
64+ 
65+ def resolve(
66+ self,
67+ src_ir: IRKind,
68+ dst_ir: IRKind,
69+ constraints: RouteConstraints | None = None,
70+ ) -> list[TransformEdge]:
71+ """
72+ Return ordered edges from ``src_ir`` to ``dst_ir``.
73+ 
74+ If ``constraints.explicit_route`` is set, validate and map IR names to edges.
75+ Otherwise run shortest-path on the registered graph.
76+ """
77+ if src_ir == dst_ir:
78+ return []
79+ 
80+ if constraints and constraints.explicit_route:
81+ return self._route_from_explicit(constraints.explicit_route)
82+ 
83+ path = self._shortest_path(src_ir, dst_ir, constraints)
84+ if path is None:
85+ raise UnsupportedError(
86+ f"No route from {src_ir.value} to {dst_ir.value}. "
87+ "Register processors or specify an explicit route in convert_rules.",
88+ )
89+ return path
90+ 
91+ def validate_route(self, route_ir_names: list[IRKind]) -> None:
92+ """Ensure consecutive IR kinds are connected by a registered edge."""
93+ for i in range(len(route_ir_names) - 1):
94+ src, dst = route_ir_names[i], route_ir_names[i + 1]
95+ if not any(e.src_ir == src and e.dst_ir == dst for e in self._edges):
96+ raise UnsupportedError(f"Missing edge {src.value} -> {dst.value}")
97+ 
98+ def get_processor(self, name: str) -> IIRTransformProcessor:
99+ if name not in self._processors:
100+ raise UnsupportedError(f"Processor {name!r} is not registered on IRRouter.")
101+ return self._processors[name]
102+ 
103+ def _route_from_explicit(self, route_ir_names: list[IRKind]) -> list[TransformEdge]:
104+ self.validate_route(route_ir_names)
105+ edges: list[TransformEdge] = []
106+ for i in range(len(route_ir_names) - 1):
107+ src, dst = route_ir_names[i], route_ir_names[i + 1]
108+ matched = [e for e in self._edges if e.src_ir == src and e.dst_ir == dst]
109+ if not matched:
110+ raise UnsupportedError(f"No edge for explicit step {src.value} -> {dst.value}")
111+ edges.append(matched[0])
112+ return edges
113+ 
114+ def _shortest_path(
115+ self,
116+ src: IRKind,
117+ dst: IRKind,
118+ constraints: RouteConstraints | None, # noqa: F821
119+ ) -> list[TransformEdge] | None:
120+ # Dijkstra on small IR graph
OO
OopenLiBingCI6月10日

此条代码评论区间+116+120

【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。

likedislike
OopenLiBingCI6月10日

此条代码评论区间+116+120

【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。

likedislike
OopenLiBingCI6月10日

此条代码评论区间+116+120

【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。

likedislike
OopenLiBingCI6月10日

此条代码评论区间+116+120

【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。

likedislike
121+ import heapq
122+ 
123+ forbidden = set(constraints.forbidden_edges) if constraints else set()
124+ required = set(constraints.required_nodes) if constraints else set()
125+ 
126+ dist: dict[IRKind, int] = {src: 0}
127+ prev: dict[IRKind, tuple[IRKind, TransformEdge] | None] = {src: None}
128+ heap: list[tuple[int, IRKind]] = [(0, src)]
129+ 
130+ while heap:
131+ d, node = heapq.heappop(heap)
132+ if d > dist.get(node, 10**9):
133+ continue
134+ if node == dst:
135+ break
136+ for edge in self._edges:
137+ if edge.src_ir != node:
138+ continue
139+ if (edge.src_ir, edge.dst_ir) in forbidden:
140+ continue
141+ new_dist = d + edge.cost
142+ if new_dist < dist.get(edge.dst_ir, 10**9):
143+ dist[edge.dst_ir] = new_dist
144+ prev[edge.dst_ir] = (node, edge)
145+ heapq.heappush(heap, (new_dist, edge.dst_ir))
146+ 
147+ if dst not in prev:
148+ return None
149+ 
150+ if required and not required.issubset(dist.keys()):
151+ return None
152+ 
153+ # Reconstruct edge list
154+ edges_rev: list[TransformEdge] = []
155+ cur = dst
156+ while cur != src:
157+ if cur not in prev or prev[cur] is None:
158+ return None
159+ _, edge = prev[cur]
160+ edges_rev.append(edge)
161+ cur = edge.src_ir
162+ edges_rev.reverse()
163+ return edges_rev
@@ -0,0 +1,161 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+IR-level work units for parallel conversion.
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from dataclasses import dataclass, field
11+from typing import Any
12+ 
13+import torch
14+from torch import nn
15+ 
16+from msmodelslim.core.convert.edges import RouteConstraints, TransformEdge
17+from msmodelslim.core.convert.types import IRKind, SourceIR, TensorRef
18+ 
19+_MXFP8_DEPLOY_KEYS = frozenset({"weight", "weight_scale", "weight_offset"})
20+ 
21+ 
22+@dataclass(frozen=True)
23+class PortableTensor:
24+ """
25+ 跨进程按值传输的 tensor 表示。
26+ 
27+ 多进程回传时若直接 pickle ``torch.Tensor``,torch 的 multiprocessing reduction 会
28+ 走共享内存 + mmap(``rebuild_storage_fd``);大规模 MoE 下 mmap 区域数会超过
29+ ``vm.max_map_count`` 触发 ``Cannot allocate memory``。
30+ 
31+ 这里改为「原始字节(uint8) + dtype 名 + shape」,仅传输纯 bytes,不占共享内存/mmap,
32+ 主进程用 ``to_tensor`` 精确还原(含 bf16/fp8 等无 numpy 对应的 torch 专有 dtype)。
33+ """
34+ 
35+ raw: bytes
36+ dtype_name: str
37+ shape: tuple[int, ...]
38+ 
39+ @classmethod
40+ def from_tensor(cls, tensor: torch.Tensor) -> "PortableTensor":
41+ contiguous = tensor.detach().cpu().contiguous()
42+ byte_view = contiguous.view(torch.uint8).reshape(-1)
43+ return cls(
44+ raw=bytes(byte_view.numpy().tobytes()),
45+ dtype_name=str(contiguous.dtype).removeprefix("torch."),
46+ shape=tuple(contiguous.shape),
47+ )
48+ 
49+ def to_tensor(self) -> torch.Tensor:
50+ dtype = getattr(torch, self.dtype_name)
51+ flat = torch.frombuffer(bytearray(self.raw), dtype=torch.uint8).clone()
52+ return flat.view(dtype).reshape(self.shape)
53+ 
54+ 
55+def _restore_state_dict(state_dict: dict[str, Any]) -> dict[str, Any]:
56+ """把回传 state_dict 中的 ``PortableTensor`` 还原为 ``torch.Tensor``。"""
57+ return {
58+ key: (value.to_tensor() if isinstance(value, PortableTensor) else value) for key, value in state_dict.items()
59+ }
60+ 
61+ 
62+def _is_mxfp8_deploy_state(state_dict: dict[str, Any]) -> bool:
63+ """True when state_dict 来自 ``W8A8MXDynamicPerBlockFakeQuantLinear.deploy()``。"""
64+ return _MXFP8_DEPLOY_KEYS.issubset(state_dict.keys())
65+ 
66+ 
67+def _float_module_from_state_dict(state_dict: dict[str, Any]) -> nn.Module:
68+ """从 state_dict 重建 FLOAT 模块;直接挂 Parameter,避免 ``nn.Linear.load_state_dict`` 把 bf16 升成 float32。"""
69+ weight = state_dict.get("weight")
70+ if weight is not None and weight.ndim == 2:
71+ bias = state_dict.get("bias")
72+ linear = nn.Linear(weight.shape[1], weight.shape[0], bias=bias is not None)
73+ linear.weight = nn.Parameter(weight.detach(), requires_grad=False)
74+ if bias is not None:
75+ linear.bias = nn.Parameter(bias.detach(), requires_grad=False)
76+ return linear
77+ 
78+ mod = nn.Module()
79+ for name, tensor in state_dict.items():
80+ mod.register_parameter(name, nn.Parameter(tensor.detach(), requires_grad=False))
81+ return mod
82+ 
83+ 
84+@dataclass
85+class IRTask:
86+ """
87+ One convertible unit (typically one linear module on the virtual tree).
88+ 
89+ Scheduling granularity is ``dependency_group`` by default so partner tensors
90+ across shards load together (cf. model_free_ptq inverse_weight_map).
91+ """
92+ 
93+ module_path: str
94+ source_ir: SourceIR
95+ target_ir: IRKind
96+ tensor_bindings: dict[str, TensorRef]
97+ inverse_weight_map: dict[str, list[str] | None]
98+ route_constraints: RouteConstraints | None = None
99+ estimated_bytes: int = 0
100+ device: str = "cpu"
101+ meta: dict[str, Any] = field(default_factory=dict)
102+ 
103+ def create_empty_module(self) -> nn.Module:
104+ """Build placeholder module for this task; filled by lazy_init in worker."""
105+ from msmodelslim.core.quant_service.modelslim_convert.virtual_module import create_model_free_module
106+ 
107+ return create_model_free_module(
108+ module_path=self.module_path,
109+ tensor_bindings=self.tensor_bindings,
110+ source_ir=self.source_ir,
111+ target_ir=self.target_ir,
112+ )
113+ 
114+ 
115+@dataclass
116+class RoutedTask:
117+ """IR task plus resolved processor route."""
118+ 
119+ task: IRTask
120+ route: list[TransformEdge]
121+ route_ir_names: list[IRKind]
122+ 
123+ 
124+@dataclass
125+class IRResult:
126+ """Output of one IR task after transform chain."""
127+ 
128+ module_path: str
129+ final_ir: IRKind
130+ module: nn.Module | None = None
131+ state_dict: dict[str, Any] | None = None
132+ loss_level: str = "lossy"
133+ route_ir_names: list[IRKind] = field(default_factory=list)
134+ 
135+ def resolve_module(self) -> nn.Module:
136+ """返回可写入虚拟树的模块;多进程 state_dict 路径会重建 FakeQuant 类型。"""
137+ if self.module is not None:
138+ return self.module
139+ if self.state_dict is None:
140+ raise ValueError(f"IRResult for {self.module_path} has no module or state_dict")
141+ state_dict = _restore_state_dict(self.state_dict)
142+ if self.final_ir == IRKind.FLOAT:
143+ return _float_module_from_state_dict(state_dict)
144+ if self.final_ir == IRKind.W8A8_MXFP8:
145+ if _is_mxfp8_deploy_state(state_dict):
146+ from msmodelslim.ir.w8a8_mx_dynamic import W8A8MXDynamicPerBlockFakeQuantLinear
147+ 
148+ return W8A8MXDynamicPerBlockFakeQuantLinear.from_deploy_state_dict(state_dict)
149+ return _float_module_from_state_dict(state_dict)
150+ raise ValueError(
151+ f"Cannot rebuild module for {self.module_path} with final_ir={self.final_ir!r} from state_dict"
152+ )
153+ 
154+ def materialize_to_module(self, target: nn.Module) -> None:
155+ """Copy weights/IR fields from result into an existing virtual-tree module."""
156+ if self.module is None and self.state_dict is None:
157+ raise ValueError(f"IRResult for {self.module_path} has no payload")
158+ if self.module is not None:
159+ target.load_state_dict(self.module.state_dict(), strict=False)
160+ elif self.state_dict is not None:
161+ target.load_state_dict(_restore_state_dict(self.state_dict), strict=False)
@@ -0,0 +1,75 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Shared value types for offline weight conversion.
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from dataclasses import dataclass, field
11+from enum import Enum
12+from typing import Any
13+ 
14+ 
15+class IRKind(str, Enum):
16+ """Canonical intermediate-representation node names for routing."""
17+ 
18+ FLOAT = "FLOAT"
19+ FP8_BLOCK = "FP8_BLOCK"
20+ W8A8_MXFP8 = "W8A8_MXFP8"
21+ W4A4_MXFP4 = "W4A4_MXFP4"
22+ W4A8_MXFP8 = "W4A8_MXFP8"
23+ INT4_PACKED = "INT4_PACKED"
24+ NVFP4_MODELOPT = "NVFP4_MODELOPT"
25+ HIFP4 = "HIFP4"
26+ UNKNOWN = "UNKNOWN"
27+ 
28+ 
29+class TensorRole(str, Enum):
30+ """Role of a tensor within a logical weight group."""
31+ 
32+ MAIN_WEIGHT = "main_weight"
33+ BIAS = "bias"
34+ SCALE = "scale"
35+ OFFSET = "offset"
36+ SHAPE_META = "shape_meta"
37+ PACKED = "packed"
38+ GLOBAL_SCALE = "global_scale"
39+ OTHER = "other"
40+ 
41+ 
42+class LossLevel(str, Enum):
43+ """Whether a transform edge is lossless for weights."""
44+ 
45+ LOSSLESS = "lossless"
46+ LOSSY = "lossy"
47+ 
48+ 
49+@dataclass(frozen=True)
50+class TensorRef:
51+ """
52+ Reference to a checkpoint tensor without loading payload.
53+ 
54+ Used by virtual modules' ``tensor_bindings`` and preprocess planners.
55+ """
56+ 
57+ logical_name: str
58+ key: str
59+ shard: str
60+ dtype: str
61+ shape: tuple[int, ...]
62+ role: TensorRole | None = None
63+ meta: dict[str, Any] = field(default_factory=dict)
64+ 
65+ 
66+@dataclass
67+class SourceIR:
68+ """
69+ Resolved source IR for a virtual module, with optional inference evidence.
70+ """
71+ 
72+ kind: IRKind
73+ source_format: str | None = None
74+ confidence: float = 1.0
75+ evidence: list[str] = field(default_factory=list)
@@ -0,0 +1,15 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+modelslim_convert quant_service:离线权重格式转换(data-free,不经 runner 校准)。
6+"""
7+ 
8+__all__ = [
9+ "ModelslimConvertQuantService",
10+ "ModelslimConvertQuantServiceConfig",
11+ "ModelslimConvertQuantConfig",
12+]
13+ 
14+from .quant_config import ModelslimConvertQuantConfig
15+from .quant_service import ModelslimConvertQuantService, ModelslimConvertQuantServiceConfig
@@ -0,0 +1,135 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+ConvertApplication:离线权重转换编排入口(convert_design.md §7)。
6+ 
7+流水线(固定顺序,不经过 quant_service / runner 旁路):
8+ 1. CheckpointReader.read_catalog — 仅从 index 建 catalog
9+ 2. PreprocessExecutor — preprocess_rules 结构变换
10+ 3. VirtualModelTreeBuilder — module_rules 建虚拟 nn.Module 树
11+ 4. DefaultIRTaskBuilder — convert_rules 枚举 IRTask
12+ 5. IRRouter.resolve — 为每个任务解析 IR 边链
13+ 6. ConvertExecutor — lazy_init + processor/convert 链
14+ 7. SaveProcessorAdapter — processor/save → format 落盘
15+"""
16+ 
17+from __future__ import annotations
18+ 
19+import time
20+ 
21+from tqdm import tqdm
22+ 
23+from msmodelslim.core.convert.config import ConvertConfig
24+from msmodelslim.core.convert.device import resolve_worker_device
25+from msmodelslim.core.convert.protocol import ConvertContext
26+from msmodelslim.core.convert.router import IRRouter
27+from msmodelslim.core.convert.tasks import RoutedTask
28+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import set_submodule_by_path
29+from msmodelslim.utils.logging import get_logger, logger_setter
30+ 
31+logger = get_logger()
32+ 
33+ 
34+@logger_setter(prefix="msmodelslim.core.quant_service.modelslim_convert")
35+class ConvertApplication:
36+ """
37+ 依赖注入式编排器:各阶段实现由 factory 组装,本类只负责阶段顺序与上下文传递。
38+ 
39+ Attributes:
40+ _reader_factory: ``(model_path) -> ICheckpointReader``
41+ _preprocess / _tree_builder / _task_builder / _executor / _save_adapter: 各阶段执行器
42+ _router: 路由解析(executor 内 transform 共用同一 IRRouter 实例)
43+ """
44+ 
45+ def __init__(
46+ self,
47+ checkpoint_reader_factory,
48+ preprocess_executor,
49+ tree_builder,
50+ task_builder,
51+ executor,
52+ save_adapter,
53+ router: IRRouter | None = None,
54+ ) -> None:
55+ self._reader_factory = checkpoint_reader_factory
56+ self._preprocess = preprocess_executor
57+ self._tree_builder = tree_builder
58+ self._task_builder = task_builder
59+ self._executor = executor
60+ self._save_adapter = save_adapter
61+ self._router = router or IRRouter.default()
62+ 
63+ def run(self, config: ConvertConfig) -> None:
64+ """执行一次完整 convert 任务。"""
65+ pipeline_t0 = time.perf_counter()
66+ context = ConvertContext(config=config)
67+ # process 后端固定 CPU(多进程突破 GIL);thread 后端可解析到 NPU。
68+ if config.parallel.worker_backend == "process":
69+ context.resolved_worker_device = "cpu"
70+ else:
71+ context.resolved_worker_device = resolve_worker_device(config.parallel.worker_device)
72+ reader = self._reader_factory(config.model_path)
73+ context.reader = reader
74+ logger.info(
75+ "Convert backend=%s, device=%s",
76+ config.parallel.worker_backend,
77+ context.resolved_worker_device,
78+ )
79+ 
80+ phase_t0 = time.perf_counter()
81+ with tqdm(total=1, desc="read checkpoint index") as pbar:
82+ raw_catalog = reader.read_catalog()
83+ pbar.update(1)
84+ logger.info("Convert phase timing: read_index=%.2fs", time.perf_counter() - phase_t0)
85+ 
86+ phase_t0 = time.perf_counter()
87+ catalog_result = self._preprocess.run(context, raw_catalog, config.preprocess_rules)
88+ context.preprocess_result = catalog_result
89+ context.catalog = catalog_result.catalog
90+ logger.info("Convert phase timing: preprocess=%.2fs", time.perf_counter() - phase_t0)
91+ 
92+ phase_t0 = time.perf_counter()
93+ with tqdm(total=1, desc="build virtual module tree") as pbar:
94+ tree = self._tree_builder.build(context, catalog_result.catalog)
95+ pbar.update(1)
96+ context.virtual_tree = tree
97+ logger.info("Convert phase timing: build_tree=%.2fs", time.perf_counter() - phase_t0)
98+ 
99+ phase_t0 = time.perf_counter()
100+ with tqdm(total=1, desc="build IR tasks") as pbar:
101+ ir_tasks = self._task_builder.build(context, tree, catalog_result.catalog)
102+ pbar.update(1)
103+ logger.info(
104+ "Convert phase timing: build_tasks=%.2fs (ir_tasks=%d)",
105+ time.perf_counter() - phase_t0,
106+ len(ir_tasks),
107+ )
108+ 
109+ phase_t0 = time.perf_counter()
110+ routed: list[RoutedTask] = []
111+ for task in tqdm(ir_tasks, desc="route IR tasks", leave=False):
112+ edges = self._router.resolve(
113+ task.source_ir.kind,
114+ task.target_ir,
115+ task.route_constraints,
116+ )
117+ route_names = [task.source_ir.kind] + [e.dst_ir for e in edges]
118+ routed.append(RoutedTask(task=task, route=edges, route_ir_names=route_names))
119+ logger.info(
120+ "Convert phase timing: route_tasks=%.2fs (routed=%d)",
121+ time.perf_counter() - phase_t0,
122+ len(routed),
123+ )
124+ 
125+ phase_t0 = time.perf_counter()
126+ for result in self._executor.run(context, routed):
127+ set_submodule_by_path(tree, result.module_path, result.resolve_module())
128+ logger.info("Convert phase timing: convert_ir=%.2fs", time.perf_counter() - phase_t0)
129+ 
130+ phase_t0 = time.perf_counter()
131+ with tqdm(total=1, desc="save checkpoint") as pbar:
132+ self._save_adapter.save(context, tree)
133+ pbar.update(1)
134+ logger.info("Convert phase timing: save_checkpoint=%.2fs", time.perf_counter() - phase_t0)
135+ logger.info("Convert finished in %.2fs", time.perf_counter() - pipeline_t0)
@@ -0,0 +1,20 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""检测 YAML 是否为 modelslim_convert 任务配置。"""
5+ 
6+from __future__ import annotations
7+ 
8+from pathlib import Path
9+ 
10+from msmodelslim.utils.security import yaml_safe_load
11+ 
12+MODELSLIM_CONVERT_APIVERSION = "modelslim_convert"
13+ 
14+ 
15+def is_modelslim_convert_config(config_path: str | Path) -> bool:
16+ """``config_path`` 指向的 YAML 是否 ``apiversion: modelslim_convert``。"""
17+ raw = yaml_safe_load(str(config_path))
18+ if not isinstance(raw, dict):
19+ return False
20+ return raw.get("apiversion") == MODELSLIM_CONVERT_APIVERSION
@@ -0,0 +1,285 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+将 ``apiversion: modelslim_convert`` 的 spec 映射为 ``ConvertConfig``。
6+ 
7+新 YAML spec 字段:
8+ - preprocess: rename / convert(chunk、merge)
9+ - linears: 匹配线性层并指定 target IR 与 route
10+ - save: 落盘格式(ascend_v1 等)
11+ - parallel: 并行参数
12+"""
13+ 
14+from __future__ import annotations
15+ 
16+from typing import Any, Literal
17+ 
18+from pydantic import BaseModel, ConfigDict, Field
19+ 
20+from msmodelslim.core.convert.config import (
21+ ConvertConfig,
22+ ConvertDefaults,
23+ ConvertRule,
24+ ModuleRule,
25+ ParallelConfig,
26+ WeightMappingRule,
27+ WeightOpConfig,
28+)
29+from msmodelslim.core.convert.types import IRKind
30+ 
31+ 
32+class RenamePattern(BaseModel):
33+ model_config = ConfigDict(extra="forbid")
34+ 
35+ from_: str = Field(alias="from")
36+ to: str
37+ 
38+ 
39+class RenamePreprocessConfig(BaseModel):
40+ model_config = ConfigDict(extra="forbid")
41+ 
42+ type: Literal["rename"] = "rename"
43+ patterns: list[RenamePattern] = Field(default_factory=list)
44+ 
45+ 
46+class ConvertOpConfig(BaseModel):
47+ model_config = ConfigDict(extra="allow")
48+ 
49+ type: str
50+ dim: int | None = None
51+ projections: list[str] | None = None
52+ 
53+ 
54+class ConvertPreprocessConfig(BaseModel):
55+ model_config = ConfigDict(extra="forbid")
56+ 
57+ type: Literal["convert"] = "convert"
58+ source: list[str] = Field(default_factory=list)
59+ target: list[str] = Field(default_factory=list)
60+ ops: list[ConvertOpConfig] = Field(default_factory=list)
61+ 
62+ 
63+class LinearConvertConfig(BaseModel):
64+ model_config = ConfigDict(extra="forbid")
65+ 
66+ match: list[str] = Field(default_factory=list)
67+ target: IRKind
68+ route: list[IRKind] | Literal["auto"] = "auto"
69+ 
70+ 
71+class SaveConfig(BaseModel):
72+ model_config = ConfigDict(extra="allow")
73+ 
74+ type: str = "ascend_v1"
75+ part_file_size: int = 4
76+ 
77+ 
78+class ParallelSpecConfig(BaseModel):
79+ model_config = ConfigDict(extra="forbid")
80+ 
81+ # workers=1:单进程组内线程(可配 NPU);workers>1:组间多进程 + 组内线程(CPU,突破 GIL)
82+ workers: int = 1
83+ # 单组最大任务数;超过则拆成多个子组分散到不同进程,缓解 MoE 大组拖尾
84+ max_group_size: int | None = None
85+ # 仅 workers=1 且 worker_device 指向 NPU 时生效
86+ worker_device: str = "cpu"
87+ npu_max_workers: int = 1
88+ 
89+ 
90+class ModelslimConvertServiceConfig(BaseModel):
91+ """modelslim_convert quant_service 的 spec 结构。"""
92+ 
93+ model_config = ConfigDict(extra="allow")
94+ 
95+ preprocess: list[dict[str, Any]] = Field(default_factory=list)
96+ linears: list[LinearConvertConfig] = Field(default_factory=list)
97+ save: list[SaveConfig] = Field(default_factory=list)
98+ parallel: ParallelSpecConfig = Field(default_factory=ParallelSpecConfig)
99+ defaults: ConvertDefaults = Field(default_factory=ConvertDefaults)
100+ 
101+ 
102+_SAVE_TYPE_MAP = {
103+ "ascend_v1": "ascendv1",
104+ "ascendv1": "ascendv1",
105+ "ascendv1_saver": "ascendv1",
106+ "huggingface": "huggingface",
107+ "hf": "huggingface",
108+ "compressed_tensors": "compressed_tensors",
109+}
110+ 
111+ 
112+# 固定策略:同 shard / fused 依赖的任务分组,组内共享 shard 句柄与 fused 缓存。
113+_DEFAULT_TASK_GRANULARITY = "dependency_group"
114+_DEFAULT_SHARD_CACHE_SIZE = 1
115+_DEFAULT_WORKER_THREADS = 4
116+ 
117+ 
118+def _preprocess_to_rules(spec: ModelslimConvertServiceConfig) -> list[WeightMappingRule]:
119+ rules: list[WeightMappingRule] = []
120+ for idx, raw in enumerate(spec.preprocess):
121+ ptype = raw.get("type")
122+ if ptype == "rename":
123+ cfg = RenamePreprocessConfig.model_validate(raw)
124+ for pat_idx, pat in enumerate(cfg.patterns):
125+ rules.append(
126+ WeightMappingRule(
127+ id=f"rename_{idx}_{pat_idx}",
128+ source_patterns=[pat.from_],
129+ target_patterns=[pat.to],
130+ ops=[WeightOpConfig(type="rename")],
131+ ),
132+ )
133+ elif ptype == "convert":
134+ cfg = ConvertPreprocessConfig.model_validate(raw)
135+ ops = _map_convert_ops(cfg.ops)
136+ rules.append(
137+ WeightMappingRule(
138+ id=f"convert_{idx}",
139+ source_patterns=list(cfg.source),
140+ target_patterns=list(cfg.target),
141+ ops=ops,
142+ module_kind="linear",
143+ reversible=True,
144+ ),
145+ )
146+ else:
147+ raise ValueError(f"Unsupported preprocess type: {ptype!r}")
148+ return rules
149+ 
150+ 
151+def _map_convert_ops(ops: list[ConvertOpConfig]) -> list[WeightOpConfig]:
152+ mapped: list[WeightOpConfig] = []
153+ for op in ops:
154+ if op.type == "chunk":
155+ mapped.append(
156+ WeightOpConfig(
157+ type="split_fused_gate_up",
158+ params={
159+ "split_dim": op.dim if op.dim is not None else 1,
160+ "projections": op.projections or ["gate_proj", "up_proj"],
161+ },
162+ ),
163+ )
164+ elif op.type == "merge":
165+ mapped.append(
166+ WeightOpConfig(
167+ type="merge_gate_up",
168+ params={"split_dim": op.dim if op.dim is not None else 0},
169+ ),
170+ )
171+ else:
172+ mapped.append(WeightOpConfig(type=op.type, params=op.model_dump(exclude={"type"})))
173+ return mapped
174+ 
175+ 
176+# 源 IR -> (source_format, 额外 tensor 绑定)。决定虚拟树如何绑定权重并供 router 选路。
177+_SOURCE_IR_BINDINGS: dict[IRKind, tuple[str, dict[str, str]]] = {
178+ IRKind.FP8_BLOCK: (
179+ "fp8_block",
180+ {"weight": "{module}.weight", "weight_scale_inv": "{module}.weight_scale_inv"},
181+ ),
182+ IRKind.FLOAT: ("bf16", {"weight": "{module}.weight"}),
183+}
184+ 
185+ 
186+def _infer_source_ir(route: list[IRKind] | str) -> IRKind | None:
187+ """显式 route 的首元素即源 IR;route=auto 时由虚拟树按 catalog dtype 推断。"""
188+ if route == "auto":
189+ return None
190+ if route:
191+ return route[0]
192+ return IRKind.FLOAT
193+ 
194+ 
195+def _module_rule_fields_for_route(route: list[IRKind] | str) -> tuple[str | None, IRKind | None, dict[str, str]]:
196+ """Return (source_format, source_ir, tensor_map) for a linear route spec."""
197+ source_ir = _infer_source_ir(route)
198+ if source_ir is None:
199+ return (
200+ None,
201+ None,
202+ {
203+ "weight": "{module}.weight",
204+ "weight_scale_inv": "{module}.weight_scale_inv",
205+ },
206+ )
207+ source_format, tensor_map = _SOURCE_IR_BINDINGS.get(source_ir, _SOURCE_IR_BINDINGS[IRKind.FLOAT])
208+ return source_format, source_ir, dict(tensor_map)
209+ 
210+ 
211+def _linears_to_module_and_convert_rules(
212+ linears: list[LinearConvertConfig],
213+) -> tuple[list[ModuleRule], list[ConvertRule]]:
214+ module_rules: list[ModuleRule] = []
215+ convert_rules: list[ConvertRule] = []
216+ for linear in linears:
217+ source_format, source_ir, tensor_map = _module_rule_fields_for_route(linear.route)
218+ for pattern in linear.match:
219+ module_rules.append(
220+ ModuleRule(
221+ match=pattern,
222+ module_kind="linear",
223+ source_format=source_format,
224+ source_ir=source_ir,
225+ tensor_map=dict(tensor_map),
226+ ),
227+ )
228+ convert_rules.append(
229+ ConvertRule(
230+ match=pattern,
231+ target_ir=linear.target,
232+ route=linear.route,
233+ ),
234+ )
235+ return module_rules, convert_rules
236+ 
237+ 
238+def _resolve_dst_format(save: list[SaveConfig], defaults: ConvertDefaults) -> str:
239+ if save:
240+ return _SAVE_TYPE_MAP.get(save[0].type.lower(), save[0].type.lower())
241+ return defaults.dst_format
242+ 
243+ 
244+def spec_to_convert_config(
245+ spec: ModelslimConvertServiceConfig | dict[str, Any],
246+ model_path: str,
247+ save_path: str,
248+ model_family: str | None = None,
249+) -> ConvertConfig:
250+ """将 quant spec 转为可执行的 ``ConvertConfig``。"""
251+ if not isinstance(spec, ModelslimConvertServiceConfig):
252+ spec = ModelslimConvertServiceConfig.model_validate(spec)
253+ 
254+ module_rules, convert_rules = _linears_to_module_and_convert_rules(spec.linears)
255+ parallel = ParallelConfig(
256+ max_workers=spec.parallel.workers,
257+ task_granularity=_DEFAULT_TASK_GRANULARITY,
258+ worker_backend="process" if spec.parallel.workers > 1 else "thread",
259+ worker_threads=_DEFAULT_WORKER_THREADS,
260+ max_group_size=spec.parallel.max_group_size,
261+ shard_cache_size=_DEFAULT_SHARD_CACHE_SIZE,
262+ worker_device=spec.parallel.worker_device,
263+ npu_max_workers=spec.parallel.npu_max_workers,
264+ )
265+ 
266+ return ConvertConfig(
267+ model_path=model_path,
268+ save_path=save_path,
269+ model_family=model_family,
270+ dst_format=_resolve_dst_format(spec.save, spec.defaults),
271+ defaults=spec.defaults,
272+ preprocess_rules=_preprocess_to_rules(spec),
273+ module_rules=module_rules,
274+ convert_rules=convert_rules,
275+ parallel=parallel,
276+ )
277+ 
278+ 
279+def load_specific_config(yaml_spec: object) -> ModelslimConvertServiceConfig:
280+ """从 YAML spec 加载 modelslim_convert 配置。"""
281+ if isinstance(yaml_spec, ModelslimConvertServiceConfig):
282+ return yaml_spec
283+ if not isinstance(yaml_spec, dict):
284+ raise ValueError("task spec must be dict")
285+ return ModelslimConvertServiceConfig.model_validate(yaml_spec)
@@ -0,0 +1,43 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Convert 默认组件装配(convert_design.md §7.2)。
6+ 
7+唯一产品入口应通过本 factory 构造 ``ConvertApplication``,保证:
8+ - CheckpointReader(infra/io)
9+ - register_convert_processors() 注册的 IR 边
10+ - impl/* 各阶段实现
11+"""
12+ 
13+from __future__ import annotations
14+ 
15+from msmodelslim.core.quant_service.modelslim_convert.application import ConvertApplication
16+from msmodelslim.core.quant_service.modelslim_convert.impl import (
17+ ConvertExecutor,
18+ DefaultIRTaskBuilder,
19+ PreprocessExecutor,
20+ SaveProcessorAdapter,
21+ VirtualModelTreeBuilder,
22+)
23+from msmodelslim.infra.io.checkpoint_reader import CheckpointReader
24+from msmodelslim.processor.convert.registry import register_convert_processors
25+ 
26+ 
27+def create_convert_application() -> ConvertApplication:
28+ """
29+ 创建可运行的 ConvertApplication。
30+ 
31+ ``router`` 同时注入 Application(路由规划)与 ConvertExecutor(边执行),
32+ 避免重复 register 导致边表不一致。
33+ """
34+ router = register_convert_processors()
35+ return ConvertApplication(
36+ checkpoint_reader_factory=CheckpointReader,
37+ preprocess_executor=PreprocessExecutor(),
38+ tree_builder=VirtualModelTreeBuilder(),
39+ task_builder=DefaultIRTaskBuilder(),
40+ executor=ConvertExecutor(router=router),
41+ save_adapter=SaveProcessorAdapter(),
42+ router=router,
43+ )
@@ -0,0 +1,27 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Convert 各阶段默认实现(由 ``app.convert.factory`` 装配)。
6+ 
7+模块对应设计文档阶段:
8+ PreprocessExecutor §6
9+ VirtualModelTreeBuilder §8
10+ DefaultIRTaskBuilder §9
11+ ConvertExecutor §10
12+ SaveProcessorAdapter §11
13+"""
14+ 
15+from msmodelslim.core.quant_service.modelslim_convert.impl.executor import ConvertExecutor
16+from msmodelslim.core.quant_service.modelslim_convert.impl.preprocess import PreprocessExecutor
17+from msmodelslim.core.quant_service.modelslim_convert.impl.save_adapter import SaveProcessorAdapter
18+from msmodelslim.core.quant_service.modelslim_convert.impl.task_builder import DefaultIRTaskBuilder
19+from msmodelslim.core.quant_service.modelslim_convert.impl.virtual_tree import VirtualModelTreeBuilder
20+ 
21+__all__ = [
22+ "ConvertExecutor",
23+ "PreprocessExecutor",
24+ "SaveProcessorAdapter",
25+ "DefaultIRTaskBuilder",
26+ "VirtualModelTreeBuilder",
27+]
@@ -0,0 +1,480 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+ConvertExecutor(convert_design.md §10)。
6+ 
7+对每个已路由的 IRTask:
8+ 1. ``ModelFreeModule.lazy_init`` 加载源权重;
9+ 2. 按 ``RoutedTask.route`` 顺序调用 ``IIRTransformProcessor.transform``;
10+ 3. 返回 ``IRResult`` 供 Application 写回虚拟树。
11+ 
12+并行策略:
13+ - ``task_granularity=dependency_group``:共享 ``fused_from`` 或同一 safetensors shard 的任务同组;
14+ - ``ShardHandleCache``:组内复用 ``safe_open`` 句柄,避免重复 mmap 同一 shard;
15+ - ``worker_backend=process``:组间 ProcessPoolExecutor(纯 CPU),突破 GIL,适合计算瓶颈;
16+ - ``worker_backend=thread``:组内线程池(兼容旧行为,CPU 受 GIL 限制);
17+ - ``max_inflight_bytes``:组内/组间并发任务的粗粒度内存上限。
18+"""
19+ 
20+from __future__ import annotations
21+ 
22+import multiprocessing as mp
23+import queue
24+import time
25+from concurrent.futures import ProcessPoolExecutor, wait, FIRST_COMPLETED
26+from dataclasses import dataclass, field
27+from typing import Iterator
28+ 
29+from tqdm import tqdm
30+ 
31+from msmodelslim.core.convert.catalog import DependencyMap, TensorCatalog
32+from msmodelslim.core.convert.device import effective_convert_workers
33+from msmodelslim.core.convert.protocol import ConvertContext, IConvertExecutor
34+from msmodelslim.core.convert.router import IRRouter
35+from msmodelslim.core.convert.tasks import IRResult, IRTask, RoutedTask
36+from msmodelslim.core.quant_service.modelslim_convert.impl.group_runner import (
37+ DependencyGroupRunner,
38+ estimate_task_bytes,
39+)
40+from msmodelslim.core.quant_service.modelslim_convert.impl.worker import (
41+ GroupWorkPayload,
42+ GroupWorkSummary,
43+ _init_worker,
44+ convert_dependency_group,
45+)
46+from msmodelslim.utils.logging import get_logger
47+ 
48+logger = get_logger()
49+ 
50+_GROUP_TIMING_LOG_THRESHOLD = 50
51+# Manager.Queue:0 表示不限制深度,避免 worker put 阻塞导致死锁
52+_RESULT_QUEUE_MAXSIZE = 0
53+_QUEUE_GET_TIMEOUT_S = 0.2
54+ 
55+ 
56+@dataclass
57+class _GroupTimingRecord:
58+ group_key: str
59+ task_count: int
60+ wall_s: float
61+ lazy_init_s: float
62+ transform_s: float
63+ lookup_s: float
64+ pool_wait_s: float
65+ fused_loads: int
66+ fused_hits: int
67+ shard_opens: int = 0
68+ shard_hits: int = 0
69+ 
70+ 
71+@dataclass
72+class _ConvertRunTiming:
73+ schedule_s: float = 0.0
74+ total_s: float = 0.0
75+ lazy_init_s: float = 0.0
76+ transform_s: float = 0.0
77+ lookup_s: float = 0.0
78+ group_wall_s: float = 0.0
79+ pool_wait_s: float = 0.0
80+ task_count: int = 0
81+ group_count: int = 0
82+ fused_loads: int = 0
83+ fused_hits: int = 0
84+ shard_opens: int = 0
85+ shard_hits: int = 0
86+ groups: list[_GroupTimingRecord] = field(default_factory=list)
87+ 
88+ def add_group(self, record: _GroupTimingRecord) -> None:
89+ self.groups.append(record)
90+ self.lazy_init_s += record.lazy_init_s
91+ self.transform_s += record.transform_s
92+ self.lookup_s += record.lookup_s
93+ self.group_wall_s += record.wall_s
94+ self.pool_wait_s += record.pool_wait_s
95+ self.task_count += record.task_count
96+ self.group_count += 1
97+ self.fused_loads += record.fused_loads
98+ self.fused_hits += record.fused_hits
99+ self.shard_opens += record.shard_opens
100+ self.shard_hits += record.shard_hits
101+ 
102+ 
103+def _group_key(task: IRTask, dep_map: DependencyMap) -> str:
104+ """
105+ 依赖组键:
106+ 
107+ - 有 ``fused_from`` / catalog 依赖:同 fused 源一组(Qwen MoE fused 缓存);
108+ - 否则按 ``inverse_weight_map`` 的 shard 分组:同 safetensors 文件的任务
109+ 进一组,组内并行 + shard handle 缓存,避免 DeepSeek 等「一任务一组」的调度开销。
110+ """
111+ deps: set[str] = set()
112+ for ref in task.tensor_bindings.values():
113+ if ref.meta.get("fused_from"):
114+ deps.add(ref.meta["fused_from"])
115+ deps.update(dep_map.dependencies_of(ref.key))
116+ if deps:
117+ return "dep:" + "|".join(sorted(deps))
118+ shards = sorted(task.inverse_weight_map.keys())
119+ if shards:
120+ return "shard:" + "|".join(shards)
121+ return task.module_path
122+ 
123+ 
124+def _schedule_groups(routed_tasks: list[RoutedTask], dep_map: DependencyMap) -> list[list[RoutedTask]]:
125+ buckets: dict[str, list[RoutedTask]] = {}
126+ for rt in routed_tasks:
127+ buckets.setdefault(_group_key(rt.task, dep_map), []).append(rt)
128+ return list(buckets.values())
129+ 
130+ 
131+def _split_oversized_groups(
132+ groups: list[list[RoutedTask]],
133+ max_group_size: int | None,
134+) -> list[list[RoutedTask]]:
135+ """
136+ 把任务数超过 ``max_group_size`` 的大组按任务切成多个子组。
137+ 
138+ 动机:MoE 一层的 experts(约 512 个 IR 任务)落在同一 dependency group,
139+ 整组只能由单个进程承包,收尾阶段这些大组串行拖尾、多核大量空闲。
140+ 切分后同层 experts 可分散到多个进程并行;各子组各自维护 fused 缓存,
141+ fused 源数量本就很少(按层计),重复加载代价可忽略。
142+ """
143+ if not max_group_size or max_group_size <= 0:
144+ return groups
145+ out: list[list[RoutedTask]] = []
146+ for group in groups:
147+ if len(group) <= max_group_size:
148+ out.append(group)
149+ continue
150+ for i in range(0, len(group), max_group_size):
151+ out.append(group[i : i + max_group_size])
152+ return out
153+ 
154+ 
155+def _short_group_label(group: list[RoutedTask], dep_map: DependencyMap) -> str:
156+ if not group:
157+ return ""
158+ key = _group_key(group[0].task, dep_map)
159+ if len(key) > 80:
160+ return key[:77] + "..."
161+ return key
162+ 
163+ 
164+def _estimate_group_bytes(group: list[RoutedTask], catalog: TensorCatalog | None) -> int:
165+ return sum(estimate_task_bytes(rt.task, catalog) for rt in group)
166+ 
167+ 
168+def _log_run_timing(stats: _ConvertRunTiming, backend: str) -> None:
169+ total = stats.total_s or 1.0
170+ logger.info(
171+ "ConvertExecutor timing summary (%s): total=%.2fs | schedule=%.3fs | "
172+ "lazy_init=%.2fs (%.1f%%) | transform=%.2fs (%.1f%%) | lookup=%.2fs (%.1f%%) | "
173+ "group_wall=%.2fs | pool_wait=%.2fs (%.1f%%) | "
174+ "tasks=%d groups=%d | fused_loads=%d fused_hits=%d | shard_opens=%d shard_hits=%d",
175+ backend,
176+ stats.total_s,
177+ stats.schedule_s,
178+ stats.lazy_init_s,
179+ 100.0 * stats.lazy_init_s / total,
180+ stats.transform_s,
181+ 100.0 * stats.transform_s / total,
182+ stats.lookup_s,
183+ 100.0 * stats.lookup_s / total,
184+ stats.group_wall_s,
185+ stats.pool_wait_s,
186+ 100.0 * stats.pool_wait_s / total,
187+ stats.task_count,
188+ stats.group_count,
189+ stats.fused_loads,
190+ stats.fused_hits,
191+ stats.shard_opens,
192+ stats.shard_hits,
193+ )
194+ if stats.groups:
195+ slowest = sorted(stats.groups, key=lambda g: g.wall_s, reverse=True)[:5]
196+ for rank, rec in enumerate(slowest, 1):
197+ logger.info(
198+ "ConvertExecutor slow group #%d: key=%r tasks=%d wall=%.2fs "
199+ "lazy_init=%.2fs transform=%.2fs fused=%d/%d shard=%d/%d",
200+ rank,
201+ rec.group_key,
202+ rec.task_count,
203+ rec.wall_s,
204+ rec.lazy_init_s,
205+ rec.transform_s,
206+ rec.fused_loads,
207+ rec.fused_hits,
208+ rec.shard_opens,
209+ rec.shard_hits,
210+ )
211+ 
212+ 
213+class ConvertExecutor(IConvertExecutor):
214+ def __init__(self, router: IRRouter | None = None) -> None:
215+ self._router = router or IRRouter.default()
216+ self._group_runner = DependencyGroupRunner(self._router)
217+ 
218+ def run(
219+ self,
220+ context: ConvertContext,
221+ routed_tasks: list[RoutedTask],
222+ ) -> Iterator[IRResult]:
223+ run_t0 = time.perf_counter()
224+ parallel = context.config.parallel
225+ dep_map = context.preprocess_result.dependency_map if context.preprocess_result is not None else DependencyMap()
226+ catalog = context.catalog
227+ 
228+ schedule_t0 = time.perf_counter()
229+ groups = (
230+ [[rt] for rt in routed_tasks]
231+ if parallel.task_granularity == "ir_task"
232+ else _schedule_groups(routed_tasks, dep_map)
233+ )
234+ # 拆分 MoE 超大组,避免收尾阶段大组单进程串行拖尾、多核空闲。
235+ groups = _split_oversized_groups(groups, parallel.max_group_size)
236+ schedule_s = time.perf_counter() - schedule_t0
237+ run_stats = _ConvertRunTiming(schedule_s=schedule_s)
238+ 
239+ backend = parallel.worker_backend
240+ process_workers = max(1, parallel.max_workers)
241+ if backend == "process":
242+ worker_threads = max(1, parallel.worker_threads or parallel.max_workers)
243+ else:
244+ worker_threads = effective_convert_workers(
245+ parallel.worker_threads or parallel.max_workers,
246+ context.resolved_worker_device,
247+ parallel.npu_max_workers,
248+ )
249+ logger.info(
250+ "Convert schedule: %d IR tasks, %d groups, backend=%s, process_workers=%d, worker_threads=%d",
251+ len(routed_tasks),
252+ len(groups),
253+ backend,
254+ process_workers,
255+ worker_threads,
256+ )
257+ 
258+ budget = parallel.max_inflight_bytes
259+ pbar = tqdm(total=len(routed_tasks), desc="convert ir tasks")
260+ 
261+ if backend == "process" and process_workers > 1:
262+ yield from self._run_multiprocess(
263+ context,
264+ groups,
265+ process_workers,
266+ worker_threads,
267+ budget,
268+ catalog,
269+ dep_map,
270+ pbar,
271+ run_stats,
272+ )
273+ else:
274+ thread_workers = effective_convert_workers(
275+ process_workers,
276+ context.resolved_worker_device,
277+ parallel.npu_max_workers,
278+ )
279+ for group in groups:
280+ yield from self._run_group_inprocess(
281+ context,
282+ group,
283+ thread_workers,
284+ budget,
285+ catalog,
286+ dep_map,
287+ pbar,
288+ run_stats,
289+ return_mode="tensor_ref",
290+ )
291+ 
292+ pbar.close()
293+ run_stats.total_s = time.perf_counter() - run_t0
294+ _log_run_timing(run_stats, backend)
295+ 
296+ @staticmethod
297+ def _drain_result_queue(result_queue, pbar: tqdm, block: bool) -> Iterator[IRResult]:
298+ """从结果队列取出已完成的 IRResult;block 时用短 timeout 避免忙等。"""
299+ while True:
300+ try:
301+ if block:
302+ result = result_queue.get(timeout=_QUEUE_GET_TIMEOUT_S)
303+ else:
304+ result = result_queue.get_nowait()
305+ except queue.Empty:
306+ break
307+ yield result
308+ pbar.update(1)
309+ 
310+ def _run_multiprocess(
311+ self,
312+ context: ConvertContext,
313+ groups: list[list[RoutedTask]],
314+ process_workers: int,
315+ worker_threads: int,
316+ budget: int | None,
317+ catalog: TensorCatalog | None,
318+ dep_map: DependencyMap,
319+ pbar: tqdm,
320+ run_stats: _ConvertRunTiming,
321+ ) -> Iterator[IRResult]:
322+ mp_ctx = mp.get_context("spawn")
323+ # 多进程回传必须走 state_dict,避免 pickle 自定义 nn.Module 子类。
324+ return_mode = "state_dict"
325+ pending: dict[object, tuple[list[RoutedTask], str, float]] = {}
326+ submitted = completed = 0
327+ total_tasks = sum(len(g) for g in groups)
328+ 
329+ with mp_ctx.Manager() as manager:
330+ result_queue = manager.Queue(maxsize=_RESULT_QUEUE_MAXSIZE)
331+ with ProcessPoolExecutor(
332+ max_workers=process_workers,
333+ mp_context=mp_ctx,
334+ initializer=_init_worker,
335+ initargs=(result_queue,),
336+ ) as pool:
337+ while completed < len(groups) or pending:
338+ inflight_bytes = sum(_estimate_group_bytes(group, catalog) for group, _, _ in pending.values())
339+ while submitted < len(groups) and len(pending) < process_workers:
340+ group = groups[submitted]
341+ est = _estimate_group_bytes(group, catalog)
342+ if budget is not None and pending and inflight_bytes + est > budget:
343+ break
344+ group_label = _short_group_label(group, dep_map)
345+ payload = GroupWorkPayload(
346+ model_path=str(context.model_path),
347+ routed_tasks=group,
348+ config=context.config,
349+ worker_threads=max(1, worker_threads),
350+ budget=budget,
351+ return_mode=return_mode,
352+ )
353+ fut = pool.submit(convert_dependency_group, payload)
354+ pending[fut] = (group, group_label, time.perf_counter())
355+ submitted += 1
356+ inflight_bytes += est
357+ 
358+ yield from self._drain_result_queue(result_queue, pbar, block=True)
359+ 
360+ if not pending:
361+ continue
362+ 
363+ done, _ = wait(
364+ set(pending.keys()),
365+ timeout=_QUEUE_GET_TIMEOUT_S,
366+ return_when=FIRST_COMPLETED,
367+ )
368+ for fut in done:
369+ group, group_label, group_t0 = pending.pop(fut)
370+ wait_t0 = time.perf_counter()
371+ summary: GroupWorkSummary = fut.result()
372+ pool_wait_s = time.perf_counter() - wait_t0
373+ completed += 1
374+ main_wall_s = time.perf_counter() - group_t0
375+ run_stats.add_group(
376+ _GroupTimingRecord(
377+ group_key=group_label,
378+ task_count=summary.task_count,
379+ wall_s=main_wall_s,
380+ lazy_init_s=summary.lazy_init_s,
381+ transform_s=summary.transform_s,
382+ lookup_s=summary.lookup_s,
383+ pool_wait_s=pool_wait_s + summary.pool_wait_s,
384+ fused_loads=summary.fused_loads,
385+ fused_hits=summary.fused_hits,
386+ shard_opens=summary.shard_opens,
387+ shard_hits=summary.shard_hits,
388+ )
389+ )
390+ if summary.task_count >= _GROUP_TIMING_LOG_THRESHOLD:
391+ logger.info(
392+ "ConvertExecutor process group done: key=%r tasks=%d "
393+ "main_wall=%.2fs worker_wall=%.2fs "
394+ "lazy_init=%.2fs transform=%.2fs fused=%d/%d shard=%d/%d",
395+ group_label,
396+ summary.task_count,
397+ main_wall_s,
398+ summary.worker_wall_s,
399+ summary.lazy_init_s,
400+ summary.transform_s,
401+ summary.fused_loads,
402+ summary.fused_hits,
403+ summary.shard_opens,
404+ summary.shard_hits,
405+ )
406+ yield from self._drain_result_queue(result_queue, pbar, block=True)
407+ 
408+ # 收尾:不依赖 Queue.empty()(Manager 下不可靠),按 task 数 drain
409+ while pbar.n < total_tasks:
410+ drained = 0
411+ for result in self._drain_result_queue(result_queue, pbar, block=True):
412+ yield result
413+ drained += 1
414+ if drained == 0:
415+ break
416+ 
417+ if pbar.n < total_tasks:
418+ logger.warning(
419+ "ConvertExecutor process: expected %d task results, got %d from queue",
420+ total_tasks,
421+ pbar.n,
422+ )
423+ 
424+ def _run_group_inprocess(
425+ self,
426+ context: ConvertContext,
427+ group: list[RoutedTask],
428+ max_workers: int,
429+ budget: int | None,
430+ catalog: TensorCatalog | None,
431+ dep_map: DependencyMap,
432+ pbar: tqdm,
433+ run_stats: _ConvertRunTiming,
434+ return_mode: str,
435+ ) -> Iterator[IRResult]:
436+ group_label = _short_group_label(group, dep_map)
437+ group_t0 = time.perf_counter()
438+ results = self._group_runner.run_group(
439+ context=context,
440+ group=group,
441+ max_workers=max_workers,
442+ budget=budget,
443+ catalog=catalog,
444+ return_mode=return_mode,
445+ )
446+ group_wall_s = time.perf_counter() - group_t0
447+ stats = self._group_runner.last_stats
448+ run_stats.add_group(
449+ _GroupTimingRecord(
450+ group_key=group_label,
451+ task_count=stats.task_count,
452+ wall_s=group_wall_s,
453+ lazy_init_s=stats.lazy_init_s,
454+ transform_s=stats.transform_s,
455+ lookup_s=stats.lookup_s,
456+ pool_wait_s=stats.pool_wait_s,
457+ fused_loads=stats.fused_loads,
458+ fused_hits=stats.fused_hits,
459+ shard_opens=stats.shard_opens,
460+ shard_hits=stats.shard_hits,
461+ )
462+ )
463+ for result in results:
464+ yield result
465+ pbar.update(1)
466+ if stats.task_count >= _GROUP_TIMING_LOG_THRESHOLD:
467+ logger.info(
468+ "ConvertExecutor group timing: key=%r tasks=%d wall=%.2fs "
469+ "lazy_init=%.2fs transform=%.2fs pool_wait=%.2fs fused=%d/%d shard=%d/%d",
470+ group_label,
471+ stats.task_count,
472+ group_wall_s,
473+ stats.lazy_init_s,
474+ stats.transform_s,
475+ stats.pool_wait_s,
476+ stats.fused_loads,
477+ stats.fused_hits,
478+ stats.shard_opens,
479+ stats.shard_hits,
480+ )
@@ -0,0 +1,239 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+单个 dependency group 内的 IR 任务执行(线程池 + fused cache)。
6+ 
7+供主进程 ``ConvertExecutor`` 与子进程 ``convert_dependency_group`` 共用。
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+import threading
13+import time
14+from collections.abc import Callable
15+from concurrent.futures import ThreadPoolExecutor
16+from dataclasses import dataclass, field
17+ 
18+from torch import nn
19+ 
20+from msmodelslim.core.convert.catalog import TensorCatalog
21+from msmodelslim.core.convert.protocol import ConvertContext
22+from msmodelslim.core.convert.router import IRRouter
23+from msmodelslim.core.convert.tasks import IRResult, IRTask, PortableTensor, RoutedTask
24+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeModule
25+from msmodelslim.core.quant_service.modelslim_convert.weight_mapping.fused_cache import FusedTensorCache
26+from msmodelslim.infra.io.shard_handle_cache import ShardHandleCache
27+ 
28+ 
29+def estimate_task_bytes(task: IRTask, catalog: TensorCatalog | None) -> int:
30+ """按 float16 粗算单任务权重大小,用于 ``max_inflight_bytes`` 限流。"""
31+ total = 0
32+ for ref in task.tensor_bindings.values():
33+ shape = ref.shape
34+ if not shape and catalog is not None:
35+ entry = catalog.get(ref.key)
36+ shape = entry.shape if entry else ()
37+ if shape:
38+ n = 1
39+ for d in shape:
40+ n *= int(d)
41+ total += n * 2
42+ return max(total, 1)
43+ 
44+ 
45+@dataclass
46+class TaskTiming:
47+ lazy_init_s: float = 0.0
48+ transform_s: float = 0.0
49+ lookup_s: float = 0.0
50+ 
51+ 
52+@dataclass
53+class GroupRunStats:
54+ lazy_init_s: float = 0.0
55+ transform_s: float = 0.0
56+ lookup_s: float = 0.0
57+ pool_wait_s: float = 0.0
58+ task_count: int = 0
59+ fused_loads: int = 0
60+ fused_hits: int = 0
61+ shard_opens: int = 0
62+ shard_hits: int = 0
63+ 
64+ 
65+@dataclass
66+class _TimingCollector:
67+ lock: threading.Lock = field(default_factory=threading.Lock)
68+ lazy_init_s: float = 0.0
69+ transform_s: float = 0.0
70+ lookup_s: float = 0.0
71+ task_count: int = 0
72+ 
73+ def record(self, timing: TaskTiming) -> None:
74+ with self.lock:
75+ self.lazy_init_s += timing.lazy_init_s
76+ self.transform_s += timing.transform_s
77+ self.lookup_s += timing.lookup_s
78+ self.task_count += 1
79+ 
80+ 
81+def prepare_result(result: IRResult, return_mode: str) -> IRResult:
82+ """
83+ 多进程回传时将 module 转为 CPU state_dict 并把每个 tensor 包成 ``PortableTensor``,
84+ 使其按值(纯 bytes)pickle,避免 torch 共享内存/mmap 导致的 ``Cannot allocate memory``。
85+ """
86+ if return_mode != "state_dict" or result.module is None:
87+ return result
88+ state_dict = {key: PortableTensor.from_tensor(value) for key, value in result.module.state_dict().items()}
89+ return IRResult(
90+ module_path=result.module_path,
91+ final_ir=result.final_ir,
92+ module=None,
93+ state_dict=state_dict,
94+ loss_level=result.loss_level,
95+ route_ir_names=result.route_ir_names,
96+ )
97+ 
98+ 
99+class DependencyGroupRunner:
100+ """在单进程内执行一个 dependency group 的全部 IR 任务。"""
101+ 
102+ def __init__(self, router: IRRouter) -> None:
103+ self._router = router
104+ self._last_stats = GroupRunStats()
105+ self._last_wall_s = 0.0
106+ 
107+ def run_group(
108+ self,
109+ context: ConvertContext,
110+ group: list[RoutedTask],
111+ max_workers: int,
112+ budget: int | None,
113+ catalog: TensorCatalog | None,
114+ return_mode: str,
115+ result_sink: Callable[[IRResult], None] | None = None,
116+ ) -> list[IRResult]:
117+ group_t0 = time.perf_counter()
118+ pool_wait_s = 0.0
119+ reader = context.reader
120+ fused_cache = FusedTensorCache()
121+ shard_cache = ShardHandleCache(max_shards=context.config.parallel.shard_cache_size)
122+ collector = _TimingCollector()
123+ results: list[IRResult] = []
124+ 
125+ def _emit(result: IRResult) -> None:
126+ prepared = prepare_result(result, return_mode)
127+ if result_sink is not None:
128+ result_sink(prepared)
129+ else:
130+ results.append(prepared)
131+ 
132+ had_fused_attr = reader is not None and hasattr(reader, "fused_tensor_cache")
133+ had_shard_attr = reader is not None and hasattr(reader, "shard_handle_cache")
134+ if reader is not None:
135+ reader.fused_tensor_cache = fused_cache
136+ reader.shard_handle_cache = shard_cache
137+ try:
138+ if max_workers <= 1:
139+ for rt in group:
140+ result, timing = self._run_one(context, rt)
141+ collector.record(timing)
142+ _emit(result)
143+ else:
144+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
145+ pending: list[tuple[int, object]] = []
146+ submitted = completed = 0
147+ while completed < len(group):
148+ while submitted < len(group) and len(pending) < max_workers:
149+ if budget is not None and pending:
150+ used = sum(est for est, _ in pending)
151+ est = estimate_task_bytes(group[submitted].task, catalog)
152+ if used + est > budget:
153+ break
154+ rt = group[submitted]
155+ est = estimate_task_bytes(rt.task, catalog)
156+ pending.append((est, pool.submit(self._run_one, context, rt)))
157+ submitted += 1
158+ if not pending:
159+ break
160+ _, fut = pending.pop(0)
161+ wait_t0 = time.perf_counter()
162+ result, timing = fut.result()
163+ pool_wait_s += time.perf_counter() - wait_t0
164+ collector.record(timing)
165+ _emit(result)
166+ completed += 1
167+ finally:
168+ if reader is not None:
169+ if had_fused_attr:
170+ reader.fused_tensor_cache = None
171+ else:
172+ try:
173+ delattr(reader, "fused_tensor_cache")
174+ except AttributeError:
175+ pass
176+ if had_shard_attr:
177+ reader.shard_handle_cache = None
178+ else:
179+ try:
180+ delattr(reader, "shard_handle_cache")
181+ except AttributeError:
182+ pass
183+ fused_cache.clear()
184+ shard_cache.clear()
185+ 
186+ self._last_stats = GroupRunStats(
187+ lazy_init_s=collector.lazy_init_s,
188+ transform_s=collector.transform_s,
189+ lookup_s=collector.lookup_s,
190+ pool_wait_s=pool_wait_s,
191+ task_count=collector.task_count,
192+ fused_loads=fused_cache.misses,
193+ fused_hits=fused_cache.hits,
194+ shard_opens=shard_cache.opens,
195+ shard_hits=shard_cache.hits,
196+ )
197+ self._last_wall_s = time.perf_counter() - group_t0
198+ return results
199+ 
200+ @property
201+ def last_stats(self) -> GroupRunStats:
202+ return getattr(self, "_last_stats", GroupRunStats())
203+ 
204+ @property
205+ def last_wall_s(self) -> float:
206+ return getattr(self, "_last_wall_s", 0.0)
207+ 
208+ def _run_one(self, context: ConvertContext, routed: RoutedTask) -> tuple[IRResult, TaskTiming]:
209+ timing = TaskTiming()
210+ lookup_t0 = time.perf_counter()
211+ tree = context.virtual_tree
212+ if tree is not None:
213+ mod = tree.get_submodule(routed.task.module_path)
214+ else:
215+ mod = routed.task.create_empty_module()
216+ timing.lookup_s = time.perf_counter() - lookup_t0
217+ 
218+ if isinstance(mod, ModelFreeModule) and not mod.lazy_initialized:
219+ if context.reader is None:
220+ raise RuntimeError(f"checkpoint reader is required to lazy_init {routed.task.module_path}")
221+ lazy_t0 = time.perf_counter()
222+ mod.lazy_init(context.reader, device=context.resolved_worker_device)
223+ timing.lazy_init_s = time.perf_counter() - lazy_t0
224+ 
225+ current: nn.Module = mod
226+ transform_t0 = time.perf_counter()
227+ for edge in routed.route:
228+ current = self._router.get_processor(edge.processor_name).transform(current, context)
229+ timing.transform_s = time.perf_counter() - transform_t0
230+ 
231+ loss = "lossy" if any(e.loss_level.value == "lossy" for e in routed.route) else "lossless"
232+ result = IRResult(
233+ module_path=routed.task.module_path,
234+ final_ir=routed.task.target_ir,
235+ module=current,
236+ route_ir_names=routed.route_ir_names,
237+ loss_level=loss,
238+ )
239+ return result, timing
@@ -0,0 +1,70 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+PreprocessExecutor(convert_design.md §6)。
6+ 
7+职责:对原始 ``TensorCatalog`` 应用 ``preprocess_rules``,仅做结构变换(rename / split 等),
8+不做数值量化。输出供虚拟树使用的逻辑 catalog 与 ``DependencyMap``。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+from tqdm import tqdm
14+ 
15+from msmodelslim.core.quant_service.modelslim_convert.weight_mapping.ops import apply_preprocess_ops
16+from msmodelslim.core.convert.catalog import PreprocessResult, TensorCatalog, TensorEntry, build_dependency_map
17+from msmodelslim.core.convert.config import WeightMappingRule
18+from msmodelslim.core.convert.protocol import ConvertContext, IPreprocessExecutor
19+from msmodelslim.utils.logging import get_logger
20+ 
21+logger = get_logger()
22+ 
23+ 
24+class PreprocessExecutor(IPreprocessExecutor):
25+ """顺序应用每条 ``WeightMappingRule``,并汇总依赖图。"""
26+ 
27+ def run(
28+ self,
29+ context: ConvertContext,
30+ raw_catalog: TensorCatalog,
31+ rules: list[WeightMappingRule],
32+ ) -> PreprocessResult:
33+ # 深拷贝 catalog,避免修改 reader 侧缓存
34+ catalog = TensorCatalog()
35+ for key, entry in tqdm(raw_catalog.items(), desc="preprocess copy catalog", leave=False):
36+ catalog.add(
37+ TensorEntry(
38+ key=key,
39+ shard=entry.shard,
40+ dtype=entry.dtype,
41+ shape=entry.shape,
42+ meta=dict(entry.meta),
43+ ),
44+ )
45+ 
46+ num_experts = _resolve_num_experts(context)
47+ 
48+ for rule in tqdm(rules, desc="preprocess rules"):
49+ apply_preprocess_ops(catalog, rule, num_experts=num_experts)
50+ 
51+ dep_map = build_dependency_map(catalog.to_weight_map(), catalog=catalog)
52+ logger.info("Preprocess done: %d catalog keys", len(catalog))
53+ return PreprocessResult(
54+ catalog=catalog,
55+ dependency_map=dep_map,
56+ applied_rules=[r.id for r in rules],
57+ )
58+ 
59+ 
60+def _resolve_num_experts(context: ConvertContext) -> int:
61+ """``split_fused_gate_up`` 所需专家数:优先 model ``config.json``,否则默认 256。"""
62+ cfg = context.reader.read_model_config() if context.reader is not None else {}
63+ for candidate in (
64+ cfg.get("num_experts"),
65+ (cfg.get("text_config") or {}).get("num_experts"),
66+ (cfg.get("language_config") or {}).get("num_experts"),
67+ ):
68+ if candidate is not None:
69+ return int(candidate)
70+ return 256
@@ -0,0 +1,99 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+SaveProcessorAdapter(convert_design.md §11)。
6+ 
7+将转换后的虚拟树交给既有保存栈,不重复实现写盘逻辑:
8+ 
9+ - **``dst_format=ascendv1``**(MXFP8 产品路径):``AscendV1Saver`` — W8A8_MXFP8 权重仅在昇腾 NPU 运行,须用此格式。
10+ - **``dst_format=huggingface|compressed_tensors``**:``QuantSaveProcessor`` + compressed_tensors —
11+ 用于 **FLOAT / bf16** 等 HF 侧导出(如 fp8_block → bf16),**不**作为 MXFP8 生产落盘格式。
12+ 
13+"""
14+ 
15+from __future__ import annotations
16+ 
17+from pathlib import Path
18+ 
19+from torch import nn
20+ 
21+from msmodelslim.core.convert.protocol import ConvertContext, ISaveProcessorAdapter
22+from msmodelslim.format.registry import parse_format_config
23+from msmodelslim.model.interface import IModel
24+from msmodelslim.model.base import BaseModelAdapter
25+from msmodelslim.processor.save.processor import QuantSaveProcessor, QuantSaveProcessorConfig
26+from msmodelslim.utils.logging import get_logger
27+ 
28+logger = get_logger()
29+ 
30+ 
31+def _lazy_init_unsaved_modules(context: ConvertContext, tree: nn.Module) -> None:
32+ """保存前加载未参与 IR 转换的模块(PassthroughModule、未 quant 的 ModelFreeLinear)。"""
33+ from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeModule
34+ 
35+ reader = context.reader
36+ if reader is None:
37+ return
38+ n = 0
39+ for mod in tree.modules():
40+ if isinstance(mod, ModelFreeModule) and not mod.lazy_initialized:
41+ mod.lazy_init(reader, device="cpu")
42+ n += 1
43+ if n:
44+ logger.info("Lazy-loaded %d module(s) before save (passthrough / FLOAT linear)", n)
45+ 
46+ 
47+class SaveProcessorAdapter(ISaveProcessorAdapter):
48+ def save(self, context: ConvertContext, tree: nn.Module) -> None:
49+ dst = context.config.dst_format.lower()
50+ save_dir = str(context.save_path)
51+ model_type = context.config.model_family or "convert"
52+ adapter = BaseModelAdapter(
53+ model_type=model_type,
54+ model_path=Path(context.model_path),
55+ )
56+ 
57+ if dst in ("huggingface", "hf", "compressed_tensors"):
58+ self._save_compressed_tensors(context, tree, save_dir, adapter)
59+ elif dst in ("ascendv1", "ascendv1_saver"):
60+ self._save_ascendv1(context, tree, save_dir, adapter)
61+ else:
62+ raise ValueError(f"Unsupported dst_format for convert save: {dst}")
63+ 
64+ @staticmethod
65+ def _save_compressed_tensors(
66+ context: ConvertContext,
67+ tree: nn.Module,
68+ save_dir: str,
69+ adapter: IModel,
70+ ) -> None:
71+ format_cfg = parse_format_config({"type": "compressed_tensors", "part_file_size": 4})
72+ cfg = QuantSaveProcessorConfig(type="saver", format=format_cfg)
73+ cfg.set_save_directory(save_dir)
74+ _lazy_init_unsaved_modules(context, tree)
75+ saver = QuantSaveProcessor(tree, cfg, adapter)
76+ saver.pre_run()
77+ from msmodelslim.core.base.protocol import BatchProcessRequest
78+ 
79+ for name, module in tree.named_modules():
80+ if name:
81+ saver.postprocess(BatchProcessRequest(name=name, module=module, datas=None, outputs=None))
82+ saver.post_run()
83+ logger.info("Saved HF/compressed_tensors checkpoint to %s", save_dir)
84+ 
85+ @staticmethod
86+ def _save_ascendv1(
87+ context: ConvertContext,
88+ tree: nn.Module,
89+ save_dir: str,
90+ adapter: IModel,
91+ ) -> None:
92+ from msmodelslim.core.quant_service.modelslim_v1.save.ascendv1 import AscendV1Config, AscendV1Saver
93+ 
94+ _lazy_init_unsaved_modules(context, tree)
95+ cfg = AscendV1Config(save_directory=save_dir, part_file_size=4)
96+ saver = AscendV1Saver(model=tree, config=cfg, adapter=adapter)
97+ saver.pre_run()
98+ saver.post_run()
99+ logger.info("Saved AscendV1 checkpoint to %s", save_dir)
@@ -0,0 +1,57 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+虚拟模块源 IR 推断与 tensor 绑定(convert_design.md §5.3.2)。
6+ 
7+``infer_source_ir``:module_rule 显式声明优先,否则按 tensor_bindings 启发式判断 FP8_BLOCK / INT4 / FLOAT。
8+``bind_tensors_for_module``:将 ``tensor_map`` 模板 ``{module}`` 展开为 catalog 中的实际 key。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import fnmatch
14+ 
15+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeModule
16+from msmodelslim.core.convert.config import ConvertConfig, ModuleRule
17+from msmodelslim.ir.kernels import WEIGHT_SCALE_INV_SUFFIX
18+from msmodelslim.core.convert.types import IRKind, SourceIR, TensorRef
19+ 
20+ 
21+def infer_source_ir(module: ModelFreeModule, config: ConvertConfig) -> SourceIR:
22+ """解析虚拟模块的源 IR,供 IRRouter 选路。"""
23+ for rule in config.module_rules:
24+ if fnmatch.fnmatch(module.full_name, rule.match):
25+ if rule.source_ir is not None:
26+ return SourceIR(kind=rule.source_ir, source_format=rule.source_format, evidence=["module_rule"])
27+ if rule.source_format == "fp8_block":
28+ return SourceIR(kind=IRKind.FP8_BLOCK, source_format=rule.source_format, evidence=["module_rule"])
29+ 
30+ bindings = module.tensor_bindings
31+ keys = set(bindings.keys())
32+ if "weight_scale_inv" in keys or any(k.endswith("scale_inv") for k in keys):
33+ return SourceIR(kind=IRKind.FP8_BLOCK, evidence=["weight_scale_inv"])
34+ if "weight_packed" in keys:
35+ return SourceIR(kind=IRKind.INT4_PACKED, evidence=["weight_packed"])
36+ if "weight" in keys and bindings["weight"].dtype and "float8" in bindings["weight"].dtype.lower():
37+ return SourceIR(kind=IRKind.FP8_BLOCK, evidence=[bindings["weight"].dtype])
38+ if "weight" in keys:
39+ return SourceIR(kind=IRKind.FLOAT, evidence=[bindings["weight"].dtype])
40+ return SourceIR(kind=IRKind.UNKNOWN, confidence=0.0)
41+ 
42+ 
43+def bind_tensors_for_module(module_path: str, rule: ModuleRule, catalog_keys: set[str]) -> dict[str, TensorRef]:
44+ """
45+ 按 module_rule.tensor_map 生成逻辑名 -> TensorRef(shard/dtype 由 virtual_tree enrich 后填充)。
46+ """
47+ bindings: dict[str, TensorRef] = {}
48+ for logical, pattern in rule.tensor_map.items():
49+ key = pattern.replace("{module}", module_path)
50+ if key not in catalog_keys:
51+ if logical == "weight_scale":
52+ inv = module_path + WEIGHT_SCALE_INV_SUFFIX
53+ if inv in catalog_keys:
54+ key, logical = inv, "weight_scale_inv"
55+ if key in catalog_keys:
56+ bindings[logical] = TensorRef(logical_name=logical, key=key, shard="", dtype="", shape=())
57+ return bindings
@@ -0,0 +1,81 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+DefaultIRTaskBuilder(convert_design.md §9.2)。
6+ 
7+遍历虚拟树中 ``ModelFreeLinear``,按 ``convert_rules`` 生成 ``IRTask``。
8+``inverse_weight_map`` 使用预处理阶段的 ``DependencyMap``;fused 逻辑 key 映射到 ``fused_from`` 物理 key。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import fnmatch
14+ 
15+from torch import nn
16+ 
17+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeLinear
18+from msmodelslim.core.convert.auto_routes import resolve_auto_route
19+from msmodelslim.core.convert.catalog import DependencyMap, TensorCatalog
20+from msmodelslim.core.convert.config import ConvertRule
21+from msmodelslim.core.convert.edges import RouteConstraints
22+from msmodelslim.core.convert.protocol import ConvertContext, IRTaskBuilder
23+from msmodelslim.core.convert.tasks import IRTask
24+ 
25+ 
26+class DefaultIRTaskBuilder(IRTaskBuilder):
27+ def build(
28+ self,
29+ context: ConvertContext,
30+ tree: nn.Module,
31+ catalog: TensorCatalog,
32+ ) -> list[IRTask]:
33+ tasks: list[IRTask] = []
34+ dep_map = context.preprocess_result.dependency_map if context.preprocess_result is not None else DependencyMap()
35+ if context.preprocess_result is None:
36+ for key, entry in catalog.items():
37+ dep_map.add_owner(key, entry.shard)
38+ 
39+ for name, mod in tree.named_modules():
40+ if name == "" or not isinstance(mod, ModelFreeLinear):
41+ continue
42+ rule = _match_convert_rule(name, context.config.convert_rules)
43+ if rule is None or rule.action != "transform":
44+ continue
45+ if mod.target_ir is None:
46+ mod.target_ir = rule.target_ir
47+ 
48+ if rule.route == "auto":
49+ route = resolve_auto_route(mod.source_ir.kind, rule.target_ir)
50+ else:
51+ # explicit_route 须以 source_ir 起、target_ir 止。
52+ # 若 YAML 的 route 已显式包含起点(如 [FP8_BLOCK, FLOAT, W8A8_MXFP8]),
53+ # 直接采用;否则补上 source_ir 作为起点,避免出现 FP8_BLOCK->FP8_BLOCK 自环边。
54+ route = list(rule.route)
55+ if not route or route[0] != mod.source_ir.kind:
56+ route = [mod.source_ir.kind, *route]
57+ constraints = RouteConstraints(explicit_route=route)
58+ 
59+ # 加载计划使用物理 key(fused 源),lazy_init 再按 meta 切片
60+ load_keys = [(ref.meta or {}).get("fused_from") or ref.key for ref in mod.tensor_bindings.values()]
61+ inv_map = dep_map.inverse_load_map(load_keys)
62+ 
63+ tasks.append(
64+ IRTask(
65+ module_path=name,
66+ source_ir=mod.source_ir,
67+ target_ir=rule.target_ir,
68+ tensor_bindings=mod.tensor_bindings,
69+ inverse_weight_map=inv_map,
70+ route_constraints=constraints,
71+ ),
72+ )
73+ return tasks
74+ 
75+ 
76+def _match_convert_rule(module_path: str, rules: list[ConvertRule]) -> ConvertRule | None:
77+ """首个 fnmatch 命中的 convert_rule 生效。"""
78+ for rule in rules:
79+ if fnmatch.fnmatch(module_path, rule.match):
80+ return rule
81+ return None
@@ -0,0 +1,307 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+VirtualModelTreeBuilder(convert_design.md §8)。
6+ 
7+根据 ``module_rules`` 与预处理后的 ``TensorCatalog`` 构建懒加载虚拟 ``nn.Module`` 树:
8+ 1. 收集 module_rules 命中的 binding key;
9+ 2. ``CheckpointReader.enrich_catalog`` 仅读取相关 shard 的 dtype/shape;
10+ 3. 为每个 module_path 创建 ``ModelFreeLinear`` / ``PassthroughModule`` 并绑定 ``TensorRef``。
11+"""
12+ 
13+from __future__ import annotations
14+ 
15+import fnmatch
16+from collections import defaultdict
17+ 
18+from torch import nn
19+from tqdm import tqdm
20+ 
21+from msmodelslim.core.quant_service.modelslim_convert.impl.source_ir import bind_tensors_for_module, infer_source_ir
22+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import (
23+ ModelFreeModule,
24+ PassthroughModule,
25+ create_model_free_module,
26+ set_submodule_by_path,
27+)
28+from msmodelslim.core.convert.catalog import TensorCatalog
29+from msmodelslim.core.convert.config import ConvertConfig, ModuleRule
30+from msmodelslim.ir.kernels import WEIGHT_SCALE_INV_SUFFIX
31+from msmodelslim.core.convert.protocol import ConvertContext, IVirtualModelTreeBuilder
32+from msmodelslim.core.convert.types import IRKind, SourceIR, TensorRef
33+from msmodelslim.infra.io.checkpoint_reader import CheckpointReader
34+from msmodelslim.utils.logging import get_logger
35+ 
36+logger = get_logger()
37+ 
38+ 
39+def collect_bound_catalog_keys(tree: nn.Module) -> set[str]:
40+ """All checkpoint keys referenced by ``tensor_bindings`` on the virtual tree."""
41+ handled: set[str] = set()
42+ for mod in tree.modules():
43+ if isinstance(mod, ModelFreeModule):
44+ for ref in mod.tensor_bindings.values():
45+ handled.add(ref.key)
46+ fused = (ref.meta or {}).get("fused_from")
47+ if fused:
48+ handled.add(fused)
49+ return handled
50+ 
51+ 
52+def _candidate_module_paths(catalog_keys: set[str], rule_match: str) -> list[str]:
53+ """
54+ 从 catalog key 反推 module_path,再用 fnmatch 过滤。
55+ 
56+ 仅处理 ``*.weight`` 结尾的 key(预处理后 MoE 均为 per-expert 2D weight)。
57+ """
58+ seen: set[str] = set()
59+ out: list[str] = []
60+ for key in catalog_keys:
61+ if key.endswith(WEIGHT_SCALE_INV_SUFFIX):
62+ continue
63+ if not key.endswith(".weight"):
64+ continue
65+ path = key[: -len(".weight")]
66+ if path in seen:
67+ continue
68+ if fnmatch.fnmatch(path, rule_match):
69+ seen.add(path)
70+ out.append(path)
71+ return out
72+ 
73+ 
74+def _collect_binding_keys(config: ConvertConfig, catalog_keys: set[str]) -> set[str]:
75+ """汇总 module_rules 需要 enrich 的 checkpoint tensor key。"""
76+ keys: set[str] = set()
77+ for rule in config.module_rules:
78+ for path in _candidate_module_paths(catalog_keys, rule.match):
79+ weight_key = f"{path}.weight"
80+ if weight_key in catalog_keys:
81+ keys.add(weight_key)
82+ scale_key = path + WEIGHT_SCALE_INV_SUFFIX
83+ if scale_key in catalog_keys:
84+ keys.add(scale_key)
85+ for _, pat in rule.tensor_map.items():
86+ resolved = pat.replace("{module}", path)
87+ if resolved in catalog_keys:
88+ keys.add(resolved)
89+ return keys
90+ 
91+ 
92+def _resolve_bindings(
93+ module_path: str,
94+ rule: ModuleRule,
95+ catalog: TensorCatalog,
96+ catalog_keys: set[str],
97+) -> dict[str, TensorRef]:
98+ bindings = bind_tensors_for_module(module_path, rule, catalog_keys)
99+ weight_key = f"{module_path}.weight"
100+ if not bindings and weight_key in catalog_keys:
101+ e = catalog.get(weight_key)
102+ bindings["weight"] = TensorRef(
103+ "weight",
104+ weight_key,
105+ e.shard,
106+ e.dtype,
107+ e.shape,
108+ meta=dict(e.meta),
109+ )
110+ inv_key = module_path + WEIGHT_SCALE_INV_SUFFIX
111+ if inv_key in catalog_keys:
112+ ie = catalog.get(inv_key)
113+ bindings["weight_scale_inv"] = TensorRef(
114+ "weight_scale_inv",
115+ inv_key,
116+ ie.shard,
117+ ie.dtype,
118+ ie.shape,
119+ )
120+ else:
121+ bindings = {k: _fill_ref(v, catalog) for k, v in bindings.items()}
122+ return bindings
123+ 
124+ 
125+class VirtualModelTreeBuilder(IVirtualModelTreeBuilder):
126+ def build(self, context: ConvertContext, catalog: TensorCatalog) -> nn.Module:
127+ root = nn.Module()
128+ catalog_keys = set(catalog.keys())
129+ 
130+ reader = context.reader
131+ if isinstance(reader, CheckpointReader):
132+ needed = _collect_binding_keys(context.config, catalog_keys)
133+ for key in catalog_keys:
134+ entry = catalog.get(key)
135+ if entry and entry.meta.get("fused_from"):
136+ needed.add(entry.meta["fused_from"])
137+ with tqdm(total=1, desc="enrich catalog metadata", leave=False) as pbar:
138+ reader.enrich_catalog(catalog, keys=needed)
139+ pbar.update(1)
140+ logger.info("Enriched metadata for %d keys", len(needed))
141+ 
142+ matched_paths: set[str] = set()
143+ for rule in sorted(context.config.module_rules, key=lambda r: r.convert):
144+ _install_rule_modules(root, catalog, catalog_keys, rule, matched_paths, context)
145+ 
146+ handled = collect_bound_catalog_keys(root)
147+ _attach_preserve_all_catalog(root, catalog, catalog_keys, handled, context, matched_paths)
148+ return root
149+ 
150+ 
151+def _install_rule_modules(
152+ root: nn.Module,
153+ catalog: TensorCatalog,
154+ catalog_keys: set[str],
155+ rule: ModuleRule,
156+ matched_paths: set[str],
157+ context: ConvertContext,
158+) -> None:
159+ candidates = _candidate_module_paths(catalog_keys, rule.match)
160+ logger.info("module_rule %r (convert=%s) -> %d modules", rule.match, rule.convert, len(candidates))
161+ 
162+ for module_path in candidates:
163+ if module_path in matched_paths:
164+ continue
165+ matched_paths.add(module_path)
166+ 
167+ bindings = _resolve_bindings(module_path, rule, catalog, catalog_keys)
168+ mod = create_model_free_module(
169+ module_path=module_path,
170+ tensor_bindings=bindings,
171+ source_format=rule.source_format,
172+ source_ir=SourceIR(
173+ kind=rule.source_ir or IRKind.UNKNOWN,
174+ source_format=rule.source_format,
175+ ),
176+ target_ir=None,
177+ module_kind=rule.module_kind,
178+ )
179+ mod.source_ir = infer_source_ir(mod, context.config)
180+ set_submodule_by_path(root, module_path, mod)
181+ 
182+ 
183+def _fill_ref(ref: TensorRef, catalog: TensorCatalog) -> TensorRef:
184+ """将 catalog 中 shard/dtype/shape/meta 填入 TensorRef。"""
185+ entry = catalog.get(ref.key)
186+ if entry is None:
187+ return ref
188+ return TensorRef(
189+ logical_name=ref.logical_name,
190+ key=ref.key,
191+ shard=entry.shard,
192+ dtype=entry.dtype,
193+ shape=entry.shape,
194+ meta=dict(entry.meta),
195+ )
196+ 
197+ 
198+def _split_catalog_key(key: str) -> tuple[str, str]:
199+ if "." in key:
200+ parent, leaf = key.rsplit(".", 1)
201+ return parent, leaf
202+ return key, "tensor"
203+ 
204+ 
205+def _get_submodule(root: nn.Module, path: str) -> nn.Module | None:
206+ parent = root
207+ for part in path.split("."):
208+ if not hasattr(parent, part):
209+ return None
210+ parent = getattr(parent, part)
211+ return parent
212+ 
213+ 
214+def _submodule_has_leaves(root: nn.Module, path: str) -> bool:
215+ """``path`` 上是否已挂载子模块(避免用 Passthrough 覆盖 ``experts.*`` 容器)。"""
216+ sub = _get_submodule(root, path)
217+ if sub is None:
218+ return False
219+ return len(list(sub.named_children())) > 0
220+ 
221+ 
222+def _install_passthrough_group(
223+ root: nn.Module,
224+ install_path: str,
225+ bindings: dict[str, TensorRef],
226+ matched_paths: set[str],
227+) -> int:
228+ if install_path in matched_paths:
229+ existing = _get_submodule(root, install_path)
230+ if isinstance(existing, ModelFreeModule):
231+ for leaf, ref in bindings.items():
232+ if leaf not in existing.tensor_bindings:
233+ existing.tensor_bindings[leaf] = ref
234+ existing.lazy_initialized = False
235+ return len(bindings)
236+ 
237+ mod = PassthroughModule(
238+ full_name=install_path,
239+ tensor_bindings=bindings,
240+ source_ir=SourceIR(kind=IRKind.FLOAT, source_format="bf16"),
241+ )
242+ set_submodule_by_path(root, install_path, mod)
243+ matched_paths.add(install_path)
244+ return len(bindings)
245+ 
246+ 
247+def _attach_preserve_all_catalog(
248+ root: nn.Module,
249+ catalog: TensorCatalog,
250+ catalog_keys: set[str],
251+ handled: set[str],
252+ context: ConvertContext,
253+ matched_paths: set[str],
254+) -> None:
255+ """
256+ 将 catalog 中尚未绑定的张量挂为 ``PassthroughModule``,保存时原样 FLOAT 落盘。
257+ 
258+ 覆盖 ``embed_tokens``、``lm_head``、``norm`` 等非 linears 匹配的 key。
259+ """
260+ remaining: list[str] = []
261+ for key in catalog_keys:
262+ if key in handled:
263+ continue
264+ if key.endswith(WEIGHT_SCALE_INV_SUFFIX):
265+ continue
266+ remaining.append(key)
267+ 
268+ if not remaining:
269+ return
270+ 
271+ reader = context.reader
272+ if isinstance(reader, CheckpointReader):
273+ reader.enrich_catalog(catalog, keys=set(remaining))
274+ 
275+ groups: dict[str, dict[str, TensorRef]] = defaultdict(dict)
276+ for key in remaining:
277+ entry = catalog.get(key)
278+ if entry is None:
279+ continue
280+ parent, leaf = _split_catalog_key(key)
281+ groups[parent][leaf] = TensorRef(
282+ logical_name=leaf,
283+ key=key,
284+ shard=entry.shard,
285+ dtype=entry.dtype,
286+ shape=entry.shape,
287+ meta=dict(entry.meta),
288+ )
289+ 
290+ attached = 0
291+ for module_path, bindings in groups.items():
292+ if _submodule_has_leaves(root, module_path):
293+ for leaf, ref in bindings.items():
294+ attached += _install_passthrough_group(
295+ root,
296+ ref.key,
297+ {leaf: ref},
298+ matched_paths,
299+ )
300+ continue
301+ attached += _install_passthrough_group(root, module_path, bindings, matched_paths)
302+ 
303+ logger.info(
304+ "Attached %d catalog tensor(s) across %d passthrough module(s) (non-linear preserve)",
305+ attached,
306+ len(groups),
307+ )
@@ -0,0 +1,116 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Convert 子进程 worker:在独立进程中执行一个 dependency group 内的 IR 任务。
6+ 
7+ProcessPool 调度粒度为 dependency group,组内仍用 ThreadPoolExecutor + FusedTensorCache;
8+多进程路径固定纯 CPU,不涉及 NPU。结果经进程级 ``result_queue`` 逐条流式回传,
9+避免一次性 pickle 大量 nn.Module。
10+"""
11+ 
12+from __future__ import annotations
13+ 
14+from dataclasses import dataclass
15+from typing import Any
16+ 
17+from msmodelslim.core.convert.config import ConvertConfig
18+from msmodelslim.core.convert.tasks import IRResult, RoutedTask
19+from msmodelslim.core.quant_service.modelslim_convert.impl.group_runner import (
20+ DependencyGroupRunner,
21+ GroupRunStats,
22+)
23+ 
24+_PROCESS_DEVICE = "cpu"
25+# 由 ProcessPoolExecutor.initializer 注入;spawn 下不可通过 payload pickle 传递 Queue
26+_RESULT_QUEUE: Any | None = None
27+ 
28+ 
29+def _init_worker(result_queue: Any) -> None:
30+ """子进程启动时绑定主进程创建的 Manager.Queue 代理。
31+ 
32+ 同时把单个 torch 算子限制为单线程:多进程 + 组内线程已提供并行度,
33+ 若再让每个 torch 算子开满 OpenMP 线程会造成线程超订(oversubscription),
34+ 总线程数远超物理核数而拖慢计算。并行度统一交由 workers × worker_threads 控制。
35+ """
36+ global _RESULT_QUEUE
37+ _RESULT_QUEUE = result_queue
38+ 
39+ import torch
40+ 
41+ torch.set_num_threads(1)
42+ 
43+ 
44+@dataclass(frozen=True)
45+class GroupWorkPayload:
46+ """跨进程传递的最小工作单元(须可 pickle,不含 Queue)。"""
47+ 
48+ model_path: str
49+ routed_tasks: list[RoutedTask]
50+ config: ConvertConfig
51+ worker_threads: int
52+ budget: int | None
53+ return_mode: str
54+ 
55+ 
56+@dataclass(frozen=True)
57+class GroupWorkSummary:
58+ """子进程完成一组任务后的摘要(不含 tensor payload)。"""
59+ 
60+ task_count: int
61+ fused_loads: int
62+ fused_hits: int
63+ shard_opens: int = 0
64+ shard_hits: int = 0
65+ lazy_init_s: float = 0.0
66+ transform_s: float = 0.0
67+ lookup_s: float = 0.0
68+ pool_wait_s: float = 0.0
69+ worker_wall_s: float = 0.0
70+ 
71+ 
72+def convert_dependency_group(payload: GroupWorkPayload) -> GroupWorkSummary:
73+ """
74+ ProcessPool worker 入口:在子进程内创建 reader/router 并跑完一个 dependency group。
75+ 
76+ 必须在模块顶层定义以便 ``spawn`` 上下文 pickle。
77+ """
78+ from msmodelslim.core.convert.protocol import ConvertContext
79+ from msmodelslim.infra.io.checkpoint_reader import CheckpointReader
80+ from msmodelslim.processor.convert.registry import register_convert_processors
81+ 
82+ if _RESULT_QUEUE is None:
83+ raise RuntimeError("convert worker result queue is not initialized")
84+ 
85+ router = register_convert_processors()
86+ reader = CheckpointReader(payload.model_path)
87+ context = ConvertContext(config=payload.config, reader=reader)
88+ context.resolved_worker_device = _PROCESS_DEVICE
89+ 
90+ runner = DependencyGroupRunner(router)
91+ 
92+ def _sink(result: IRResult) -> None:
93+ _RESULT_QUEUE.put(result)
94+ 
95+ runner.run_group(
96+ context=context,
97+ group=payload.routed_tasks,
98+ max_workers=payload.worker_threads,
99+ budget=payload.budget,
100+ catalog=None,
101+ return_mode=payload.return_mode,
102+ result_sink=_sink,
103+ )
104+ stats: GroupRunStats = runner.last_stats
105+ return GroupWorkSummary(
106+ task_count=stats.task_count,
107+ fused_loads=stats.fused_loads,
108+ fused_hits=stats.fused_hits,
109+ shard_opens=stats.shard_opens,
110+ shard_hits=stats.shard_hits,
111+ lazy_init_s=stats.lazy_init_s,
112+ transform_s=stats.transform_s,
113+ lookup_s=stats.lookup_s,
114+ pool_wait_s=stats.pool_wait_s,
115+ worker_wall_s=runner.last_wall_s,
116+ )
@@ -0,0 +1,24 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+modelslim_convert 量化任务配置(apiversion + spec)。
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from typing_extensions import Self
11+ 
12+from msmodelslim.core.quant_service.interface import BaseQuantConfig
13+from .config_mapper import ModelslimConvertServiceConfig, load_specific_config
14+ 
15+ 
16+class ModelslimConvertQuantConfig(BaseQuantConfig):
17+ spec: ModelslimConvertServiceConfig
18+ 
19+ @classmethod
20+ def from_base(cls, quant_config: BaseQuantConfig) -> Self:
21+ return cls(
22+ apiversion=quant_config.apiversion,
23+ spec=load_specific_config(quant_config.spec),
24+ )
@@ -0,0 +1,76 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+ModelslimConvertQuantService:将 convert 流水线注册为 quant_service 插件。
6+ 
7+通过 ``msmodelslim quant --config_path <yaml>`` 使用,YAML 中 ``apiversion: modelslim_convert``。
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+from pathlib import Path
13+from typing import List, Literal, Optional
14+ 
15+from msmodelslim.core.const import DeviceType
16+from msmodelslim.core.quant_service.interface import BaseQuantConfig, IQuantService, QuantServiceConfig
17+from msmodelslim.model import IModel
18+from msmodelslim.utils.logging import get_logger, logger_setter
19+from .config_mapper import spec_to_convert_config
20+from .factory import create_convert_application
21+from .quant_config import ModelslimConvertQuantConfig
22+ 
23+logger = get_logger()
24+ 
25+ 
26+class ModelslimConvertQuantServiceConfig(QuantServiceConfig):
27+ """modelslim_convert 量化服务配置,用于插件选择与 QuantService 初始化。"""
28+ 
29+ apiversion: Literal["modelslim_convert"] = "modelslim_convert"
30+ 
31+ 
32+@logger_setter(prefix="msmodelslim.core.quant_service.modelslim_convert")
33+class ModelslimConvertQuantService(IQuantService):
34+ """离线权重转换服务:data-free,不经 runner 校准。"""
35+ 
36+ backend_name: str = "modelslim_convert"
37+ 
38+ def __init__(
39+ self,
40+ quant_service_config: ModelslimConvertQuantServiceConfig,
41+ **kwargs,
42+ ) -> None:
43+ self.quant_service_config = quant_service_config
44+ 
45+ def quantize(
46+ self,
47+ quant_config: BaseQuantConfig,
48+ model_adapter: IModel,
49+ save_path: Optional[Path] = None,
50+ device: DeviceType = DeviceType.NPU,
51+ device_indices: Optional[List[int]] = None,
52+ ) -> None:
53+ if save_path is None:
54+ raise ValueError("modelslim_convert requires save_path")
55+ 
56+ convert_quant_config = ModelslimConvertQuantConfig.from_base(quant_config)
57+ convert_config = spec_to_convert_config(
58+ spec=convert_quant_config.spec,
59+ model_path=str(model_adapter.model_path),
60+ save_path=str(save_path),
61+ model_family=getattr(model_adapter, "model_type", None),
62+ )
63+ 
64+ logger.info(
65+ "==========CONVERT: model_path=%s save_path=%s==========",
66+ convert_config.model_path,
67+ convert_config.save_path,
68+ )
69+ app = create_convert_application()
70+ app.run(convert_config)
71+ logger.info("==========CONVERT: END==========")
72+ 
73+ 
74+def get_plugin():
75+ """获取 modelslim_convert 量化服务插件。"""
76+ return ModelslimConvertQuantServiceConfig, ModelslimConvertQuantService
@@ -0,0 +1,159 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+虚拟 nn.Module 节点(convert_design.md §8)。
6+ 
7+用途:
8+ - 为 SaveProcessor 提供 ``named_modules()`` 遍历结构;
9+ - 为 IR 任务提供 ``lazy_init`` 绑定的权重属性名(weight / weight_scale_inv 等)。
10+ 
11+不做 forward;转换后 module 类型可能变为 qir.FakeQuantLinear 等。
12+"""
13+ 
14+from __future__ import annotations
15+ 
16+import torch
17+from torch import nn
18+ 
19+from msmodelslim.core.quant_service.modelslim_convert.weight_mapping.fused_load import load_logical_tensor
20+from msmodelslim.core.convert.protocol import ICheckpointReader
21+from msmodelslim.core.convert.types import IRKind, SourceIR, TensorRef
22+ 
23+ 
24+class ModelFreeModule(nn.Module): # pylint: disable=abstract-method
25+ """
26+ Convert 专用模块基类:元数据 + ``tensor_bindings``,首次访问权重时 ``lazy_init``。
27+ 
28+ ``tensor_bindings`` 的 key 为逻辑名(如 weight),value 为 ``TensorRef``(含 checkpoint key、
29+ shard、以及 preprocess 写入的 ``meta``,例如 ``fused_from``)。
30+ """
31+ 
32+ full_name: str
33+ source_format: str | None
34+ source_ir: SourceIR
35+ target_ir: IRKind | None
36+ tensor_bindings: dict[str, TensorRef]
37+ lazy_initialized: bool
38+ 
39+ def __init__(
40+ self,
41+ full_name: str,
42+ tensor_bindings: dict[str, TensorRef] | None = None,
43+ source_format: str | None = None,
44+ source_ir: SourceIR | None = None,
45+ target_ir: IRKind | None = None,
46+ ) -> None:
47+ super().__init__()
48+ self.full_name = full_name
49+ self.source_format = source_format
50+ self.source_ir = source_ir or SourceIR(kind=IRKind.UNKNOWN)
51+ self.target_ir = target_ir
52+ self.tensor_bindings = tensor_bindings or {}
53+ self.lazy_initialized = False
54+ 
55+ def lazy_init(self, reader: ICheckpointReader, device: torch.device | str = "cpu") -> None:
56+ """
57+ 从 checkpoint 加载绑定张量到 Parameter/Buffer。
58+ 
59+ - 普通 key:按 shard 批量 ``reader.load_tensors``。
60+ - ``meta.fused_from``:走 ``load_logical_tensor``,只读 fused 张量再切片(§6.3)。
61+ """
62+ if self.lazy_initialized:
63+ return
64+ dev = str(device)
65+ direct: dict[str, list[str]] = {}
66+ for logical, ref in self.tensor_bindings.items():
67+ meta = ref.meta or {}
68+ if meta.get("fused_from"):
69+ tensor = load_logical_tensor(reader, ref.key, meta, device=dev)
70+ self._register_logical(logical, tensor)
71+ continue
72+ direct.setdefault(ref.shard, []).append(ref.key)
73+ 
74+ if direct:
75+ merged = {s: sorted(set(k)) for s, k in direct.items()}
76+ tensors = reader.load_tensors(merged, device=dev)
77+ for logical, ref in self.tensor_bindings.items():
78+ if ref.meta.get("fused_from"):
79+ continue
80+ tensor = tensors.get(ref.key)
81+ if tensor is None:
82+ continue
83+ self._register_logical(logical, tensor)
84+ self.lazy_initialized = True
85+ 
86+ def _register_logical(self, logical: str, tensor: torch.Tensor) -> None:
87+ if logical in ("weight", "bias"):
88+ self.register_parameter(logical, nn.Parameter(tensor, requires_grad=False))
89+ else:
90+ self.register_buffer(logical, tensor, persistent=True)
91+ 
92+ 
93+class ModelFreeLinear(ModelFreeModule): # pylint: disable=abstract-method
94+ """Linear 语义虚拟模块;convert_rules 仅对此类生成 IRTask。"""
95+ 
96+ pass
97+ 
98+ 
99+class PassthroughModule(ModelFreeModule): # pylint: disable=abstract-method
100+ """Norm / embedding / catalog 余量:原样 FLOAT 落盘,走 AscendV1 ``on_float_module``。"""
101+ 
102+ def _register_logical(self, logical: str, tensor: torch.Tensor) -> None:
103+ # AscendV1 仅遍历 named_parameters;非 weight/bias 的 A_log 等也注册为 Parameter
104+ safe = logical.replace(".", "_")
105+ self.register_parameter(safe, nn.Parameter(tensor, requires_grad=False))
106+ 
107+ def _single_bound_parameter(self, prefix: str) -> tuple[str, nn.Parameter] | None:
108+ if prefix != self.full_name or len(self.tensor_bindings) != 1:
109+ return None
110+ logical, ref = next(iter(self.tensor_bindings.items()))
111+ if ref.key not in (self.full_name, prefix):
112+ return None
113+ safe = logical.replace(".", "_")
114+ param = self._parameters.get(safe)
115+ if param is None:
116+ return None
117+ return prefix, param
118+ 
119+ def named_parameters(self, prefix: str = "", recurse: bool = True, remove_duplicate: bool = True):
120+ """
121+ 单子量且 ``full_name`` 与 checkpoint key 一致时(如 ``...experts.down_proj``),
122+ 写出名须为 prefix 本身,不能是 ``prefix.down_proj``。
123+ """
124+ if not recurse:
125+ single = self._single_bound_parameter(prefix)
126+ if single is not None:
127+ yield single
128+ return
129+ yield from super().named_parameters(prefix=prefix, recurse=recurse, remove_duplicate=remove_duplicate)
130+ 
131+ 
132+def create_model_free_module(
133+ module_path: str,
134+ tensor_bindings: dict[str, TensorRef],
135+ source_ir: SourceIR,
136+ target_ir: IRKind,
137+ source_format: str | None = None,
138+ module_kind: str = "linear",
139+) -> ModelFreeModule:
140+ """按 ``module_kind`` 选择具体虚拟模块类。"""
141+ cls = PassthroughModule if module_kind in ("norm", "embedding", "lm_head", "passthrough") else ModelFreeLinear
142+ return cls(
143+ full_name=module_path,
144+ tensor_bindings=tensor_bindings,
145+ source_format=source_format,
146+ source_ir=source_ir,
147+ target_ir=target_ir,
148+ )
149+ 
150+ 
151+def set_submodule_by_path(root: nn.Module, path: str, module: nn.Module) -> None:
152+ """按点分路径挂载子模块,缺失的中间节点自动创建为 ``nn.Module()``。"""
153+ parts = path.split(".")
154+ parent = root
155+ for part in parts[:-1]:
156+ if not hasattr(parent, part) or getattr(parent, part) is None:
157+ setattr(parent, part, nn.Module())
158+ parent = getattr(parent, part)
159+ setattr(parent, parts[-1], module)
@@ -0,0 +1,6 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+from msmodelslim.core.quant_service.modelslim_convert.weight_mapping.ops import apply_preprocess_ops
5+ 
6+__all__ = ["apply_preprocess_ops"]
@@ -0,0 +1,55 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Dependency-group 内 fused MoE 张量缓存(convert P0 优化)。
6+ 
7+同一 ``fused_from`` 源上的多个 expert 任务共享一块 3D fused 权重;
8+组内首次加载后缓存,后续任务仅切片,避免重复 ``safe_open`` / 读盘。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import threading
14+from collections.abc import Callable
15+ 
16+import torch
17+ 
18+ 
19+class FusedTensorCache:
20+ """线程安全的 (fused_key, device) → Tensor 缓存,生命周期为一个 dependency group。"""
21+ 
22+ def __init__(self) -> None:
23+ self._data: dict[tuple[str, str], torch.Tensor] = {}
24+ self._guard = threading.Lock()
25+ self.hits = 0
26+ self.misses = 0
27+ 
28+ def get_or_load(
29+ self,
30+ fused_key: str,
31+ device: str,
32+ loader: Callable[[], torch.Tensor],
33+ ) -> torch.Tensor:
34+ cache_key = (fused_key, str(device))
35+ with self._guard:
36+ cached = self._data.get(cache_key)
37+ if cached is not None:
38+ self.hits += 1
39+ return cached
40+ 
41+ # 读盘在锁外执行,避免组内多 worker 串行等待 I/O
42+ tensor = loader()
43+ 
44+ with self._guard:
45+ existing = self._data.get(cache_key)
46+ if existing is not None:
47+ self.hits += 1
48+ return existing
49+ self._data[cache_key] = tensor
50+ self.misses += 1
51+ return tensor
52+ 
53+ def clear(self) -> None:
54+ with self._guard:
55+ self._data.clear()
@@ -0,0 +1,63 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Fused MoE 逻辑张量加载(convert_design.md §6.3)。
6+ 
7+预处理只注册逻辑 key;本模块在 lazy_init 时读取 ``meta.fused_from`` 指向的 3D fused 张量,
8+按 ``expert_id`` 与 ``projection`` 切片为 2D,避免在 catalog 中物化全部 expert 副本。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import torch
14+ 
15+from msmodelslim.core.convert.protocol import ICheckpointReader
16+from msmodelslim.core.quant_service.modelslim_convert.weight_mapping.fused_cache import FusedTensorCache
17+ 
18+ 
19+def _load_fused_tensor(reader: ICheckpointReader, fused_key: str, device: str) -> torch.Tensor:
20+ """加载 fused 源张量;reader 携带 ``fused_tensor_cache`` 时组内复用,避免重复读盘。"""
21+ 
22+ def _loader() -> torch.Tensor:
23+ weight_map = reader.read_weight_map()
24+ shard = weight_map[fused_key]
25+ return reader.load_tensors({shard: [fused_key]}, device=device)[fused_key]
26+ 
27+ cache = getattr(reader, "fused_tensor_cache", None)
28+ if not isinstance(cache, FusedTensorCache):
29+ return _loader()
30+ return cache.get_or_load(fused_key, device, _loader)
31+ 
32+ 
33+def load_logical_tensor(
34+ reader: ICheckpointReader,
35+ key: str,
36+ meta: dict,
37+ device: str = "cpu",
38+) -> torch.Tensor:
39+ """
40+ 加载单个逻辑 tensor。
41+ 
42+ Args:
43+ reader: checkpoint 读取器
44+ key: 逻辑 key(catalog 中的名字,可能不在 index 独立列出)
45+ meta: 须含 ``fused_from``、``expert_id``、``projection`` 等(由 split_fused_gate_up 写入)
46+ device: 加载设备
47+ """
48+ fused_key = meta.get("fused_from")
49+ if not fused_key:
50+ weight_map = reader.read_weight_map()
51+ shard = weight_map[key]
52+ return reader.load_tensors({shard: [key]}, device=device)[key]
53+ 
54+ fused = _load_fused_tensor(reader, fused_key, device)
55+ 
56+ expert_id = int(meta["expert_id"])
57+ projection = meta["projection"]
58+ split_dim = int(meta.get("split_dim", 1))
59+ parts = meta.get("chunk_parts", ["gate_proj", "up_proj"])
60+ 
61+ expert_slice = fused[expert_id]
62+ chunks = torch.chunk(expert_slice, len(parts), dim=split_dim)
63+ return chunks[parts.index(projection)].contiguous()
@@ -0,0 +1,153 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+预处理结构算子(convert_design.md §6.2)。
6+ 
7+借鉴 HuggingFace WeightConverter / ConversionOps:本模块只改写 ``TensorCatalog`` 的 key 与 meta,
8+不在预处理阶段物化大张量;物化推迟到 ``virtual_module.lazy_init`` + ``fused_load``。
9+ 
10+已支持算子:
11+ - ``rename``:正则捕获组重命名 key
12+ - ``split_fused_gate_up``:Qwen3.5 BF16 MoE 3D gate_up_proj → per-expert gate/up 逻辑 key
13+"""
14+ 
15+from __future__ import annotations
16+ 
17+import fnmatch
18+import re
19+from dataclasses import dataclass
20+ 
21+from msmodelslim.core.convert.catalog import RestoreRule, TensorCatalog, TensorEntry
22+from msmodelslim.core.convert.config import WeightMappingRule, WeightOpConfig
23+ 
24+ 
25+@dataclass
26+class PreprocessApplyResult:
27+ """单条 preprocess_rule 的应用结果。"""
28+ 
29+ catalog: TensorCatalog
30+ restore_rules: list[RestoreRule]
31+ added_keys: list[str]
32+ 
33+ 
34+def apply_preprocess_ops(
35+ catalog: TensorCatalog,
36+ rule: WeightMappingRule,
37+ num_experts: int | None = None,
38+) -> PreprocessApplyResult:
39+ """顺序执行 rule.ops 中的结构操作。"""
40+ added: list[str] = []
41+ restore: list[RestoreRule] = []
42+ 
43+ for op in rule.ops:
44+ if op.type == "rename":
45+ added = _op_rename(catalog, rule, added)
46+ elif op.type == "split_fused_gate_up":
47+ n_exp = int(op.params.get("num_experts", num_experts or 0))
48+ if n_exp <= 0:
49+ raise ValueError("split_fused_gate_up requires num_experts in op.params or model config.json")
50+ added, restore = _op_split_fused_gate_up(
51+ catalog,
52+ rule,
53+ n_exp,
54+ op.params,
55+ added,
56+ restore,
57+ )
58+ else:
59+ raise NotImplementedError(f"preprocess op {op.type!r} not implemented")
60+ 
61+ return PreprocessApplyResult(catalog, restore, added)
62+ 
63+ 
64+def _op_rename(
65+ catalog: TensorCatalog,
66+ rule: WeightMappingRule,
67+ added: list[str],
68+) -> list[str]:
69+ """``re.fullmatch`` + ``expand`` 生成新 key,保留原 shard。"""
70+ src_pat = rule.source_patterns[0]
71+ tgt_tmpl = rule.target_patterns[0]
72+ for key in list(catalog.keys()):
73+ m = re.fullmatch(src_pat, key)
74+ if not m:
75+ continue
76+ entry = catalog.get(key)
77+ if entry is None:
78+ continue
79+ new_key = m.expand(tgt_tmpl)
80+ catalog.add(
81+ TensorEntry(
82+ key=new_key,
83+ shard=entry.shard,
84+ dtype=entry.dtype,
85+ shape=entry.shape,
86+ meta={**entry.meta, "renamed_from": key},
87+ ),
88+ )
89+ added.append(new_key)
90+ return added
91+ 
92+ 
93+def _op_split_fused_gate_up(
94+ catalog: TensorCatalog,
95+ rule: WeightMappingRule,
96+ num_experts: int,
97+ params: dict,
98+ added: list[str],
99+ restore: list[RestoreRule],
100+) -> tuple[list[str], list[RestoreRule]]:
101+ """
102+ 将 ``*.mlp.experts.gate_up_proj``(shape [E, 2*I, H])展开为 E×2 个 2D 逻辑 key。
103+ 
104+ 每个逻辑 entry 的 ``meta`` 含 ``fused_from`` / ``expert_id`` / ``projection``,
105+ 供 ``fused_load.load_logical_tensor`` 在 lazy_init 时切片。
106+ """
107+ pattern = rule.source_patterns[0] if rule.source_patterns else "*.mlp.experts.gate_up_proj"
108+ split_dim = int(params.get("split_dim", 1))
109+ chunk_parts = params.get("projections", ["gate_proj", "up_proj"])
110+ 
111+ for key in list(catalog.keys()):
112+ if not fnmatch.fnmatch(key, pattern):
113+ continue
114+ entry = catalog.get(key)
115+ if entry is None:
116+ continue
117+ prefix = key[: -len(".gate_up_proj")] if key.endswith(".gate_up_proj") else key
118+ 
119+ for expert_id in range(num_experts):
120+ for proj in chunk_parts:
121+ logical_key = f"{prefix}.{expert_id}.{proj}.weight"
122+ catalog.add(
123+ TensorEntry(
124+ key=logical_key,
125+ shard=entry.shard,
126+ dtype=entry.dtype,
127+ shape=(),
128+ meta={
129+ "fused_from": key,
130+ "layout": "gate_up_interleaved",
131+ "expert_id": expert_id,
132+ "projection": proj,
133+ "split_dim": split_dim,
134+ "num_experts": num_experts,
135+ "chunk_parts": list(chunk_parts),
136+ },
137+ ),
138+ )
139+ added.append(logical_key)
140+ 
141+ catalog._entries.pop(key, None)
142+ if rule.reversible:
143+ restore.append(
144+ RestoreRule(
145+ id=f"{rule.id}_restore",
146+ source_patterns=[f"{prefix}.*.gate_proj.weight", f"{prefix}.*.up_proj.weight"],
147+ target_pattern=key,
148+ ops=[WeightOpConfig(type="merge_gate_up", params=params)],
149+ when="before_save" if params.get("restore_on_hf", True) else "never",
150+ ),
151+ )
152+ 
153+ return added, restore
@@ -44,7 +44,7 @@ from msmodelslim.format.compressed_tensors_format.compressed_tensors_safetensors
44)44)
45from msmodelslim.format.compressed_tensors_format.quantization.quant_config import QuantizationConfig45from msmodelslim.format.compressed_tensors_format.quantization.quant_config import QuantizationConfig
46from msmodelslim.format.interface import ExportContext46from msmodelslim.format.interface import ExportContext
47-from msmodelslim.utils.exception import ConfigError, InvalidModelError, SchemaValidateError47+from msmodelslim.utils.exception import ConfigError, SchemaValidateError
48from msmodelslim.utils.security import (48from msmodelslim.utils.security import (
49 SafeWriteUmask,49 SafeWriteUmask,
50 get_valid_read_path,50 get_valid_read_path,
@@ -209,14 +209,26 @@ class CompressedTensorsQuantFormat(QuantFormatBase):
209 action="Ensure config.json is a valid JSON object.",209 action="Ensure config.json is a valid JSON object.",
210 )210 )
211 211 
212- config_data[QUANTIZATION_CONFIG_NAME] = self._build_quantization_config(model)212+ qconfig = self._build_quantization_config(model)
213+ if qconfig is None:
214+ if QUANTIZATION_CONFIG_NAME in config_data:
215+ config_data.pop(QUANTIZATION_CONFIG_NAME)
216+ logger.info(
217+ "No quantized QIR modules in model; removed %s for float-only export",
218+ QUANTIZATION_CONFIG_NAME,
219+ )
220+ else:
221+ config_data[QUANTIZATION_CONFIG_NAME] = qconfig
213 write_path = get_valid_write_path(config_path, extensions=[".json"])222 write_path = get_valid_write_path(config_path, extensions=[".json"])
214 writer = self._json_writer_factory_infra.create_json_writer(223 writer = self._json_writer_factory_infra.create_json_writer(
215 os.path.dirname(write_path),224 os.path.dirname(write_path),
216 os.path.basename(write_path),225 os.path.basename(write_path),
217 )226 )
218 writer.dump(config_data, indent=2)227 writer.dump(config_data, indent=2)
219- logger.info("Updated compressed-tensors quantization_config in %s", write_path)228+ if qconfig is None:
229+ logger.info("Updated config.json for float-only export: %s", write_path)
230+ else:
231+ logger.info("Updated compressed-tensors quantization_config in %s", write_path)
220 232 
221 def _ensure_config_json_exists(self, config_path: str) -> bool:233 def _ensure_config_json_exists(self, config_path: str) -> bool:
222 if os.path.exists(config_path):234 if os.path.exists(config_path):
@@ -234,13 +246,10 @@ class CompressedTensorsQuantFormat(QuantFormatBase):
234 writer.dump(src_reader.load(), indent=2)246 writer.dump(src_reader.load(), indent=2)
235 return os.path.exists(config_path)247 return os.path.exists(config_path)
236 248 
237- def _build_quantization_config(self, model: nn.Module) -> Dict[str, Any]:249+ def _build_quantization_config(self, model: nn.Module) -> Dict[str, Any] | None:
238 qconfig = QuantizationConfig.from_model(model)250 qconfig = QuantizationConfig.from_model(model)
239 if qconfig is None:251 if qconfig is None:
240- raise InvalidModelError(252+ return None
241- "No quantized QIR module found in model",
242- action="Ensure the model contains at least one supported QIR fake-quant module before export.",
243- )
244 return qconfig.to_quantization_config_dict()253 return qconfig.to_quantization_config_dict()
245 254 
246 255 
@@ -0,0 +1,162 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+CheckpointReader(convert_design.md §7.3 / infra 层)。
6+ 
7+无模型代码读 safetensors:
8+ - ``read_catalog``:仅解析 index.json,O(keys) 不打开 shard(避免 6 万 key 重复 safe_open)
9+ - ``enrich_catalog``:按 binding key 集合按需打开 shard,每 shard 最多一次
10+ - ``load_tensors``:按 inverse_weight_map 批量加载张量数据
11+"""
12+ 
13+from __future__ import annotations
14+ 
15+import json
16+from pathlib import Path
17+from typing import Any
18+ 
19+from msmodelslim.core.convert.catalog import TensorCatalog, TensorEntry
20+from msmodelslim.core.convert.protocol import ICheckpointReader
21+from msmodelslim.infra.io.shard_handle_cache import ShardHandleCache
22+from msmodelslim.utils.logging import get_logger
23+ 
24+logger = get_logger()
25+ 
26+ 
27+class CheckpointReader(ICheckpointReader):
28+ """``model_path`` 下含 ``model.safetensors.index.json`` 或单文件 ``model.safetensors``。"""
29+ 
30+ def __init__(self, model_path: str | Path) -> None:
31+ self.model_path = Path(model_path)
32+ self._weight_map: dict[str, str] | None = None
33+ self._config: dict[str, Any] | None = None
34+ self._shard_meta: dict[str, dict[str, tuple[str, tuple[int, ...]]]] | None = None
35+ 
36+ def read_weight_map(self) -> dict[str, str]:
37+ """原始 index:tensor key -> shard 相对路径。"""
38+ if self._weight_map is not None:
39+ return self._weight_map
40+ index_path = self.model_path / "model.safetensors.index.json"
41+ if not index_path.is_file():
42+ single = self.model_path / "model.safetensors"
43+ if single.is_file():
44+ self._weight_map = {"__single__": str(single)}
45+ return self._weight_map
46+ raise FileNotFoundError(f"No safetensors index or single file under {self.model_path}")
47+ data = json.loads(index_path.read_text(encoding="utf-8"))
48+ self._weight_map = dict(data.get("weight_map", {}))
49+ return self._weight_map
50+ 
51+ def read_catalog(self) -> TensorCatalog:
52+ """快速建 catalog:dtype/shape 为 UNKNOWN/(),由 ``enrich_catalog`` 按需填充。"""
53+ weight_map = self.read_weight_map()
54+ catalog = TensorCatalog()
55+ for key, shard in weight_map.items():
56+ if key == "__single__":
57+ continue
58+ catalog.add(TensorEntry(key=key, shard=shard, dtype="UNKNOWN", shape=()))
59+ logger.info("Built catalog from index (%d keys), headers deferred", len(catalog))
60+ return catalog
61+ 
62+ def _load_all_shard_metadata(self) -> dict[str, dict[str, tuple[str, tuple[int, ...]]]]:
63+ """全量 shard header 缓存(``enrich_catalog(keys=None)`` 时使用)。"""
64+ if self._shard_meta is not None:
65+ return self._shard_meta
66+ from safetensors import safe_open
67+ 
68+ weight_map = self.read_weight_map()
69+ shards = sorted({s for _, s in weight_map.items() if _ != "__single__"})
70+ meta: dict[str, dict[str, tuple[str, tuple[int, ...]]]] = {}
71+ for i, shard_name in enumerate(shards, 1):
72+ shard_path = self.model_path / shard_name
73+ shard_meta: dict[str, tuple[str, tuple[int, ...]]] = {}
74+ with safe_open(str(shard_path), framework="pt", device="cpu") as f:
75+ for key in f.keys():
76+ sl = f.get_slice(key)
77+ dtype = str(sl.get_dtype()) if hasattr(sl, "get_dtype") else "UNKNOWN"
78+ shape = tuple(sl.get_shape()) if hasattr(sl, "get_shape") else ()
79+ shard_meta[key] = (dtype, shape)
80+ meta[shard_name] = shard_meta
81+ logger.info("Shard metadata %d/%d: %s (%d tensors)", i, len(shards), shard_name, len(shard_meta))
82+ self._shard_meta = meta
83+ return meta
84+ 
85+ def enrich_catalog(self, catalog: TensorCatalog, keys: set[str] | None = None) -> None:
86+ """
87+ 写入 dtype/shape。
88+ 
89+ ``keys`` 非空时只打开包含这些 key 的 shard(virtual_tree 绑定 + fused_from 源 key)。
90+ """
91+ weight_map = self.read_weight_map()
92+ if keys is None:
93+ meta = self._load_all_shard_metadata()
94+ for key, entry in catalog.items():
95+ if key in meta.get(entry.shard, {}):
96+ d, s = meta[entry.shard][key]
97+ catalog.add(TensorEntry(key=key, shard=entry.shard, dtype=d, shape=s))
98+ return
99+ 
100+ shards_needed: dict[str, set[str]] = {}
101+ for key in keys:
102+ shard = weight_map.get(key)
103+ if shard:
104+ shards_needed.setdefault(shard, set()).add(key)
105+ 
106+ from safetensors import safe_open
107+ 
108+ for shard_name, wanted in shards_needed.items():
109+ if self._shard_meta and shard_name in self._shard_meta:
110+ for key in wanted:
111+ if key in self._shard_meta[shard_name]:
112+ d, s = self._shard_meta[shard_name][key]
113+ entry = catalog.get(key)
114+ if entry:
115+ catalog.add(TensorEntry(key=key, shard=entry.shard, dtype=d, shape=s))
116+ continue
117+ shard_path = self.model_path / shard_name
118+ with safe_open(str(shard_path), framework="pt", device="cpu") as f:
119+ for key in wanted:
120+ if key not in f.keys():
121+ continue
122+ sl = f.get_slice(key)
123+ d = str(sl.get_dtype()) if hasattr(sl, "get_dtype") else "UNKNOWN"
124+ sh = tuple(sl.get_shape()) if hasattr(sl, "get_shape") else ()
125+ entry = catalog.get(key)
126+ if entry:
127+ catalog.add(TensorEntry(key=key, shard=entry.shard, dtype=d, shape=sh))
128+ 
129+ def read_header(self, key: str) -> tuple[str, tuple[int, ...]]:
130+ """单 key header(会触发全 shard meta 加载,大批量场景请用 enrich_catalog)。"""
131+ weight_map = self.read_weight_map()
132+ shard = weight_map.get(key)
133+ if shard is None:
134+ raise KeyError(key)
135+ meta = self._load_all_shard_metadata()
136+ return meta[shard][key]
137+ 
138+ def load_tensors(
139+ self,
140+ inverse_weight_map: dict[str, list[str] | None],
141+ device: str = "cpu",
142+ ) -> dict[str, Any]:
143+ """按 shard 批量读 tensor;返回 dict[key, Tensor]。"""
144+ cache = getattr(self, "shard_handle_cache", None)
145+ if isinstance(cache, ShardHandleCache):
146+ return cache.load_tensors(self.model_path, inverse_weight_map, device=device)
147+ return ShardHandleCache(max_shards=1).load_tensors(
148+ self.model_path,
149+ inverse_weight_map,
150+ device=device,
151+ )
152+ 
153+ def read_model_config(self) -> dict[str, Any]:
154+ """读取 ``config.json``(不存在则返回空 dict)。"""
155+ if self._config is not None:
156+ return self._config
157+ config_path = self.model_path / "config.json"
158+ if config_path.is_file():
159+ self._config = json.loads(config_path.read_text(encoding="utf-8"))
160+ else:
161+ self._config = {}
162+ return self._config
@@ -0,0 +1,94 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Safetensors shard 级 handle 缓存(convert P0 IO 优化)。
6+ 
7+同一 dependency / shard 组内多个 IR 任务共享已打开的 ``safe_open`` 句柄,
8+避免每个任务重复 ``open/mmap`` 同一 ``.safetensors`` 文件。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import threading
14+from collections import OrderedDict
15+from pathlib import Path
16+from typing import Any, Callable
17+ 
18+_CacheEntry = tuple[Any, threading.Lock]
19+ 
20+ 
21+class ShardHandleCache:
22+ """线程安全的 (shard_path, device) → open handle LRU 缓存,生命周期为一个 dependency group。"""
23+ 
24+ def __init__(self, max_shards: int = 2) -> None:
25+ self._max = max(1, max_shards)
26+ self._entries: OrderedDict[tuple[str, str], _CacheEntry] = OrderedDict()
27+ self._guard = threading.Lock()
28+ self.opens = 0
29+ self.hits = 0
30+ 
31+ def load_tensors(
32+ self,
33+ model_path: Path,
34+ inverse_weight_map: dict[str, list[str] | None],
35+ device: str = "cpu",
36+ ) -> dict[str, Any]:
37+ """与 ``CheckpointReader.load_tensors`` 相同语义,复用 shard handle。"""
38+ from safetensors import safe_open
39+ 
40+ out: dict[str, Any] = {}
41+ for shard, names in inverse_weight_map.items():
42+ shard_path = Path(shard)
43+ if not shard_path.is_absolute():
44+ shard_path = model_path / shard
45+ abs_path = str(shard_path.resolve())
46+ cache_key = (abs_path, str(device))
47+ 
48+ handle, handle_lock = self._get_or_open(
49+ cache_key,
50+ lambda path=abs_path: safe_open(path, framework="pt", device=device),
51+ )
52+ load_keys = names if names is not None else list(handle.keys())
53+ with handle_lock:
54+ for key in load_keys:
55+ out[key] = handle.get_tensor(key)
56+ return out
57+ 
58+ def _get_or_open(self, cache_key: tuple[str, str], opener: Callable[[], Any]) -> _CacheEntry:
59+ with self._guard:
60+ cached = self._entries.get(cache_key)
61+ if cached is not None:
62+ self._entries.move_to_end(cache_key)
63+ self.hits += 1
64+ return cached
65+ 
66+ handle = opener()
67+ entry: _CacheEntry = (handle, threading.Lock())
68+ 
69+ with self._guard:
70+ existing = self._entries.get(cache_key)
71+ if existing is not None:
72+ self._close_handle(handle)
73+ self._entries.move_to_end(cache_key)
74+ self.hits += 1
75+ return existing
76+ while len(self._entries) >= self._max:
77+ _, evicted = self._entries.popitem(last=False)
78+ self._close_handle(evicted[0])
79+ self._entries[cache_key] = entry
80+ self.opens += 1
81+ return entry
82+ 
83+ @staticmethod
84+ def _close_handle(handle: Any) -> None:
85+ try:
86+ handle.__exit__(None, None, None)
87+ except Exception: # nosec B110
88+ pass
89+ 
90+ def clear(self) -> None:
91+ with self._guard:
92+ for handle, _ in self._entries.values():
93+ self._close_handle(handle)
94+ self._entries.clear()
@@ -0,0 +1,19 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Offline weight-transform kernels (convert, model scripts, data-free processors).
6+ 
7+These are **stateless tensor ops** on checkpoint layouts. They are intentionally
8+**not** placed under ``core/convert`` (orchestration only) nor ``ir.api`` (QFuncRegistry
9+quantize/dequantize dispatch for ``QStorage`` / ``QDType``).
10+ 
11+Add new families as separate modules, e.g. ``fp8_block.py``, ``int4_packed.py``.
12+"""
13+ 
14+from msmodelslim.ir.kernels.fp8_block import WEIGHT_SCALE_INV_SUFFIX, weight_dequant
15+ 
16+__all__ = [
17+ "WEIGHT_SCALE_INV_SUFFIX",
18+ "weight_dequant",
19+]
@@ -0,0 +1,35 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+FP8 per-block checkpoint kernels (E4M3 weight + block ``weight_scale_inv``).
6+ 
7+Used by ``processor/convert`` (FP8_BLOCK → FLOAT) and legacy ``model/*/convert_fp8_to_bf16`` scripts.
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+import torch
13+ 
14+# Canonical suffix in HuggingFace / DeepSeek-style FP8 checkpoints.
15+WEIGHT_SCALE_INV_SUFFIX = ".weight_scale_inv"
16+ 
17+ 
18+def weight_dequant(
19+ weight: torch.Tensor,
20+ scale: torch.Tensor,
21+ block_size: int = 128,
22+) -> torch.Tensor:
23+ """
24+ Dequantize FP8 block weights to bfloat16.
25+ 
26+ Args:
27+ weight: Quantized weight ``(M, N)``.
28+ scale: Block scale ``(M // block_size, N // block_size)`` (``weight_scale_inv``).
29+ block_size: Block size from ``quantization_config.weight_block_size`` (default 128).
30+ """
31+ m, n = weight.shape
32+ weight = weight.to(torch.float32)
33+ scale_expanded = scale.repeat_interleave(block_size, dim=0).repeat_interleave(block_size, dim=1)
34+ scale_expanded = scale_expanded[:m, :n]
35+ return (weight * scale_expanded).to(torch.bfloat16)
@@ -20,7 +20,7 @@ See the Mulan PSL v2 for more details.
20"""20"""
21 21 
22import torch22import torch
23-from torch import nn as nn23+from torch import nn
24from torch.nn import functional as F24from torch.nn import functional as F
25 25 
26from msmodelslim.ir.qal import QABCRegistry, QScope, QScheme, QParam, QStorage, QDType26from msmodelslim.ir.qal import QABCRegistry, QScope, QScheme, QParam, QStorage, QDType
@@ -32,22 +32,10 @@ from msmodelslim.ir.utils import reshape_to_blocks, undo_reshape_to_blocks
32from msmodelslim.utils.logging import logger_setter32from msmodelslim.utils.logging import logger_setter
33 33 
34 34 
35-@QABCRegistry.multi_register(35+@QABCRegistry.multi_register(dispatch_key=[(mxfp8_per_block_sym, mxfp8_per_block_sym)], abc_type=AutoFakeQuantLinear)
36- dispatch_key=[
37- (mxfp8_per_block_sym, mxfp8_per_block_sym)
38- ],
39- abc_type=AutoFakeQuantLinear
40-)
41@logger_setter()36@logger_setter()
42class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear):37class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear):
43- 38+ def __init__(self, x_q_param: QParam, w_q_param: QParam, w_q: QStorage, bias: torch.Tensor):
44- def __init__(
45- self,
46- x_q_param: QParam,
47- w_q_param: QParam,
48- w_q: QStorage,
49- bias: torch.Tensor
50- ):
51 super().__init__()39 super().__init__()
52 self.w_scheme = w_q_param.scheme40 self.w_scheme = w_q_param.scheme
53 self.w_mx_finfo = w_q_param.scheme.dtype.mx_finfo41 self.w_mx_finfo = w_q_param.scheme.dtype.mx_finfo
@@ -68,6 +56,24 @@ class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear):
68 def __repr__(self) -> str:56 def __repr__(self) -> str:
69 return f"W8A8MXDynamicPerBlockFakeQuantLinear(symmetric={self.w_scheme.symmetric})"57 return f"W8A8MXDynamicPerBlockFakeQuantLinear(symmetric={self.w_scheme.symmetric})"
70 58 
59+ @classmethod
60+ def from_deploy_state_dict(cls, state_dict: dict[str, torch.Tensor]) -> "W8A8MXDynamicPerBlockFakeQuantLinear":
61+ """从 deploy 后的 state_dict 重建模块(convert 多进程回传,仅供 AscendV1 落盘)。"""
62+ mx_scheme = QScheme(scope=QScope.PER_BLOCK, dtype=QDType.MXFP8, symmetric=True)
63+ axes = -1
64+ x_q_param = QParam(scheme=mx_scheme, ext={"axes": axes})
65+ w_q_param = QParam(
66+ scheme=mx_scheme,
67+ ext={
68+ "axes": axes,
69+ "scale": state_dict["weight_scale"],
70+ "offset": state_dict["weight_offset"],
71+ },
72+ )
73+ w_q = QStorage(QDType.MXFP8, state_dict["weight"])
74+ bias = state_dict.get("bias")
75+ return cls(x_q_param, w_q_param, w_q, bias)
76+ 
71 def forward(self, x: torch.Tensor) -> torch.Tensor:77 def forward(self, x: torch.Tensor) -> torch.Tensor:
72 axes = self.x_axes78 axes = self.x_axes
73 axes = [axes] if isinstance(axes, int) else axes79 axes = [axes] if isinstance(axes, int) else axes
@@ -77,10 +83,11 @@ class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear):
77 self.x_minmax_block_observer.update(x, shared_exp_axes=shared_exp_axes)83 self.x_minmax_block_observer.update(x, shared_exp_axes=shared_exp_axes)
78 x_min_val, x_max_val = self.x_minmax_block_observer.get_min_max()84 x_min_val, x_max_val = self.x_minmax_block_observer.get_min_max()
79 x_q_param = calculate_qparam(85 x_q_param = calculate_qparam(
80- x_min_val, x_max_val,86+ x_min_val,
87+ x_max_val,
81 q_dtype=self.x_scheme.dtype,88 q_dtype=self.x_scheme.dtype,
82 q_scope=self.x_scheme.scope,89 q_scope=self.x_scheme.scope,
83- symmetric=self.x_scheme.symmetric90+ symmetric=self.x_scheme.symmetric,
84 )91 )
85 x_q_dq = fake_quantize(QStorage(QDType.FLOAT, x), x_q_param)92 x_q_dq = fake_quantize(QStorage(QDType.FLOAT, x), x_q_param)
86 x_q_dq.value = undo_reshape_to_blocks(x_q_dq.value, padded_shape, orig_shape, axes)93 x_q_dq.value = undo_reshape_to_blocks(x_q_dq.value, padded_shape, orig_shape, axes)
@@ -92,8 +99,10 @@ class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear):
92 w_q_storage.value, _, w_orig_shape, w_padded_shape = reshape_to_blocks(99 w_q_storage.value, _, w_orig_shape, w_padded_shape = reshape_to_blocks(
93 w_q_storage.value, axes, self.w_mx_finfo.block_size100 w_q_storage.value, axes, self.w_mx_finfo.block_size
94 )101 )
95- w_q_param = QParam(scheme=QScheme(scope=QScope.PER_BLOCK, dtype=QDType.MXFP8, symmetric=True),102+ w_q_param = QParam(
96- ext={"scale": self.weight_scale.data})103+ scheme=QScheme(scope=QScope.PER_BLOCK, dtype=QDType.MXFP8, symmetric=True),
104+ ext={"scale": self.weight_scale.data},
105+ )
97 weight_q_dq = dequantize(w_q_storage, w_q_param)106 weight_q_dq = dequantize(w_q_storage, w_q_param)
98 weight_q_dq.value = undo_reshape_to_blocks(weight_q_dq.value, w_padded_shape, w_orig_shape, axes)107 weight_q_dq.value = undo_reshape_to_blocks(weight_q_dq.value, w_padded_shape, w_orig_shape, axes)
99 108 
@@ -0,0 +1,11 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Data-free IR transform processors for offline convert.
6+"""
7+ 
8+from msmodelslim.processor.convert.base import BaseConvertProcessor
9+from msmodelslim.processor.convert.registry import register_convert_processors
10+ 
11+__all__ = ["BaseConvertProcessor", "register_convert_processors"]
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+Base class for convert-only processors (data-free, no forward).
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from abc import ABC, abstractmethod
11+ 
12+from torch import nn
13+ 
14+from msmodelslim.core.convert.protocol import ConvertContext
15+from msmodelslim.core.convert.types import IRKind, LossLevel
16+ 
17+ 
18+class BaseConvertProcessor(ABC):
19+ """
20+ Convenience base implementing ``IIRTransformProcessor`` flags.
21+ 
22+ Subclasses implement ``transform`` only; register with ``IRRouter.register_processor``.
23+ """
24+ 
25+ name: str
26+ src_ir: IRKind
27+ dst_ir: IRKind
28+ requires_forward: bool = False
29+ requires_calibration: bool = False
30+ loss_level: str = LossLevel.LOSSY.value
31+ 
32+ @abstractmethod
33+ def transform(self, module: nn.Module, context: ConvertContext) -> nn.Module: ...
@@ -0,0 +1,68 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+IR edge: FP8_BLOCK -> FLOAT (bf16 ``nn.Linear``).
6+ 
7+Reuses block dequant kernel from ``ir.kernels.fp8_block``.
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+import json
13+from pathlib import Path
14+ 
15+import torch
16+from torch import nn
17+ 
18+from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeLinear
19+from msmodelslim.ir.kernels import WEIGHT_SCALE_INV_SUFFIX, weight_dequant
20+from msmodelslim.core.convert.protocol import ConvertContext
21+from msmodelslim.core.convert.types import IRKind, LossLevel
22+from msmodelslim.processor.convert.base import BaseConvertProcessor
23+ 
24+ 
25+class DequantToFloatProcessor(BaseConvertProcessor):
26+ name = "DequantToFloatProcessor"
27+ src_ir = IRKind.FP8_BLOCK
28+ dst_ir = IRKind.FLOAT
29+ loss_level = LossLevel.LOSSLESS.value
30+ 
31+ def transform(self, module: nn.Module, context: ConvertContext) -> nn.Module:
32+ if not isinstance(module, ModelFreeLinear):
33+ return module
34+ weight = getattr(module, "weight", None)
35+ if weight is None:
36+ return module
37+ 
38+ scale = module._buffers.get("weight_scale_inv")
39+ if scale is None:
40+ for logical, ref in module.tensor_bindings.items():
41+ if logical in ("weight_scale_inv", "weight_scale") and ref.key.endswith(WEIGHT_SCALE_INV_SUFFIX):
42+ if not module.lazy_initialized:
43+ module.lazy_init(context.reader, device="cpu")
44+ scale = module._buffers.get(logical)
45+ break
46+ 
47+ block_size = _fp8_block_size(context)
48+ if scale is not None:
49+ weight_bf16 = weight_dequant(weight, scale, block_size=block_size)
50+ else:
51+ weight_bf16 = weight.to(torch.bfloat16)
52+ 
53+ bias = getattr(module, "bias", None)
54+ out = nn.Linear(weight_bf16.shape[1], weight_bf16.shape[0], bias=bias is not None)
55+ out.weight = nn.Parameter(weight_bf16, requires_grad=False)
56+ if bias is not None:
57+ out.bias = nn.Parameter(bias.to(torch.bfloat16), requires_grad=False)
58+ return out
59+ 
60+ 
61+def _fp8_block_size(context: ConvertContext) -> int:
62+ cfg_path = Path(context.config.model_path) / "config.json"
63+ if cfg_path.is_file():
64+ qc = json.loads(cfg_path.read_text(encoding="utf-8")).get("quantization_config") or {}
65+ bs = qc.get("weight_block_size")
66+ if bs:
67+ return int(bs[0])
68+ return 128
@@ -0,0 +1,84 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""
5+IR edge: FLOAT -> W8A8_MXFP8.
6+ 
7+Reuses ``LinearQuantizer`` + ``AutoFakeQuantLinear.create`` (same stack as ``linear_quant``).
8+"""
9+ 
10+from __future__ import annotations
11+ 
12+import torch
13+from torch import nn
14+ 
15+from msmodelslim.core.convert.protocol import ConvertContext
16+from msmodelslim.core.convert.types import IRKind, LossLevel
17+from msmodelslim.core.quantizer.linear import LinearQuantizer, LinearQConfig
18+from msmodelslim.core.quantizer.base import QConfig
19+from msmodelslim.ir.qal import QDType, QScope
20+from msmodelslim.processor.convert.base import BaseConvertProcessor
21+from msmodelslim.utils.logging import get_logger
22+ 
23+logger = get_logger()
24+ 
25+ 
26+def _materialize_linear(module: nn.Module, context: ConvertContext | None = None) -> nn.Linear | None:
27+ """将 ``ModelFreeLinear`` 或 ``nn.Linear`` 物化为可量化的 ``nn.Linear``。"""
28+ from msmodelslim.core.quant_service.modelslim_convert.virtual_module import ModelFreeLinear
29+ 
30+ if isinstance(module, ModelFreeLinear):
31+ if not module.lazy_initialized and context is not None and context.reader is not None:
32+ # convert 计算全程 CPU;NPU 设备解析交由 group_runner 的 lazy_init 处理。
33+ module.lazy_init(context.reader, device="cpu")
34+ weight = getattr(module, "weight", None)
35+ if weight is None:
36+ return None
37+ if weight.ndim != 2:
38+ logger.warning(
39+ "Skip MXFP8 for %s: weight shape %s is not 2D (norm/conv layers are left as FLOAT)",
40+ module.full_name,
41+ tuple(weight.shape),
42+ )
43+ return None
44+ bias = getattr(module, "bias", None)
45+ linear = nn.Linear(weight.shape[1], weight.shape[0], bias=bias is not None)
46+ linear.weight = nn.Parameter(weight.detach().to(torch.bfloat16), requires_grad=False)
47+ if bias is not None:
48+ linear.bias = nn.Parameter(bias.detach().to(torch.bfloat16), requires_grad=False)
49+ return linear
50+ if isinstance(module, nn.Linear):
51+ return module
52+ return None
53+ 
54+ 
55+class MxFp8QuantProcessor(BaseConvertProcessor):
56+ name = "MxFp8QuantProcessor"
57+ src_ir = IRKind.FLOAT
58+ dst_ir = IRKind.W8A8_MXFP8
59+ loss_level = LossLevel.LOSSY.value
60+ 
61+ def transform(self, module: nn.Module, context: ConvertContext) -> nn.Module:
62+ linear = _materialize_linear(module, context)
63+ if linear is None:
64+ return module
65+ qconfig = LinearQConfig(
66+ act=QConfig(
67+ dtype=QDType.MXFP8,
68+ scope=QScope.PER_BLOCK,
69+ symmetric=True,
70+ method="minmax",
71+ ext={"axes": -1},
72+ ),
73+ weight=QConfig(
74+ dtype=QDType.MXFP8,
75+ scope=QScope.PER_BLOCK,
76+ symmetric=True,
77+ method="minmax",
78+ ext={"axes": -1},
79+ ),
80+ )
81+ quantizer = LinearQuantizer(qconfig)
82+ quantizer.setup(linear)
83+ # data-free:权重已在 setup → init_weight 中量化,无需 forward
84+ return quantizer.deploy()
@@ -0,0 +1,23 @@
1+#!/usr/bin/env python
2+# -*- coding: UTF-8 -*-
3+ 
4+"""Register data-free convert processors on the global IR router."""
5+ 
6+from __future__ import annotations
7+ 
8+from msmodelslim.core.convert.router import IRRouter
9+from msmodelslim.processor.convert.dequant_to_float import DequantToFloatProcessor
10+from msmodelslim.processor.convert.mxfp8_quant import MxFp8QuantProcessor
11+ 
12+_REGISTERED = False
13+ 
14+ 
15+def register_convert_processors(router: IRRouter | None = None) -> IRRouter:
16+ global _REGISTERED
17+ r = router or IRRouter.default()
18+ if _REGISTERED and router is None:
19+ return r
20+ for proc in (DequantToFloatProcessor(), MxFp8QuantProcessor()):
21+ r.register_processor(proc)
22+ _REGISTERED = True
23+ return r
@@ -91,12 +91,13 @@
91 "broad-except",91 "broad-except",
92 "bare-except",92 "bare-except",
93 93 
94- # 导入相关94+ # 导入相关
95- "wrong-import-order",95+ "wrong-import-order",
96- "wrong-import-position",96+ "wrong-import-position",
97- "import-error",97+ "import-error",
98+ "cyclic-import",
98 99 
99- # 重复代码检测100+ # 重复代码检测
100 "duplicate-code",101 "duplicate-code",
101 ]102 ]
102 103 
@@ -43,7 +43,6 @@ from msmodelslim.format.compressed_tensors_format.config.base import (
43from msmodelslim.format.interface import ExportContext43from msmodelslim.format.interface import ExportContext
44from msmodelslim.utils.exception import (44from msmodelslim.utils.exception import (
45 ConfigError,45 ConfigError,
46- InvalidModelError,
47 SchemaValidateError,46 SchemaValidateError,
48)47)
49 48 
@@ -303,11 +302,10 @@ class TestCompressedTensorsQuantFormatBuildQuantizationConfig:
303 assert isinstance(result, dict)302 assert isinstance(result, dict)
304 assert "config_groups" in result303 assert "config_groups" in result
305 304 
306- def test_build_quantization_config_raise_invalid_model_error_when_no_qir_module(self, quant_format):305+ def test_build_quantization_config_return_none_when_no_qir_module(self, quant_format):
307 empty_model = nn.Sequential(nn.Linear(4, 2))306 empty_model = nn.Sequential(nn.Linear(4, 2))
308 307 
309- with pytest.raises(InvalidModelError, match="No quantized QIR module found"):308+ assert quant_format._build_quantization_config(empty_model) is None
310- quant_format._build_quantization_config(empty_model)
311 309 
312 310 
313class TestCompressedTensorsQuantFormatFinalizeExport:311class TestCompressedTensorsQuantFormatFinalizeExport: