已合并
[feature] 权重转换 #537
caishengcheng创建于 6月10日
[feature] 权重转换 #537
已合并
共 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] |
| 113 | modelslim_v0 = msmodelslim.core.quant_service.modelslim_v0.quant_service:get_plugin | 113 | modelslim_v0 = msmodelslim.core.quant_service.modelslim_v0.quant_service:get_plugin |
| 114 | modelslim_v1 = msmodelslim.core.quant_service.modelslim_v1.quant_service:get_plugin | 114 | modelslim_v1 = msmodelslim.core.quant_service.modelslim_v1.quant_service:get_plugin |
| 115 | +modelslim_convert = msmodelslim.core.quant_service.modelslim_convert.quant_service:get_plugin | ||
| 115 | multimodal_sd_modelslim_v1 = msmodelslim.core.quant_service.multimodal_sd_v1.quant_service:get_plugin | 116 | multimodal_sd_modelslim_v1 = msmodelslim.core.quant_service.multimodal_sd_v1.quant_service:get_plugin |
| 116 | multimodal_vlm_modelslim_v1 = msmodelslim.core.quant_service.multimodal_vlm_v1.quant_service:get_plugin | 117 | multimodal_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 | |||
| 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. | |||
| 18 | See the Mulan PSL v2 for more details. | 18 | See the Mulan PSL v2 for more details. |
| 19 | ------------------------------------------------------------------------- | 19 | ------------------------------------------------------------------------- |
| 20 | """ | 20 | """ |
| 21 | + | ||
| 21 | import re | 22 | import re |
| 22 | from enum import Enum | 23 | from enum import Enum |
| 23 | from pathlib import Path | 24 | from pathlib import Path |
| @@ -37,7 +38,7 @@ from msmodelslim.utils.security import yaml_safe_load | |||
| 37 | from msmodelslim.utils.validation.conversion import ( | 38 | from 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_dir | 41 | + convert_to_readable_dir, |
| 41 | ) | 42 | ) |
| 42 | from msmodelslim.utils.validation.value import validate_str_length | 43 | from msmodelslim.utils.validation.value import validate_str_length |
| 43 | from .model_info_interface import ModelInfoInterface | 44 | from .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 | ||
| 98 | def validate_device_index(device_index: Optional[List[int]], device_type: DeviceType): | 110 | def 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 duplicates | 129 | # 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-device | 134 | # 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 | 159 | ||
| 151 | class NaiveQuantizationApplication: | 160 | class 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_manager | 168 | self.practice_manager = practice_manager |
| 161 | self.quant_service = quant_service | 169 | self.quant_service = quant_service |
| @@ -164,11 +172,11 @@ class NaiveQuantizationApplication: | |||
| 164 | 172 | ||
| 165 | 173 | ||
| 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=False | 179 | + is_default=False, |
| 172 | ) -> ScenarioTagMatch: | 180 | ) -> ScenarioTagMatch: |
| 173 | label = config.metadata.label | 181 | label = config.metadata.label |
| 174 | # Parse quant_type parameters | 182 | # 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_MATCH | 195 | 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]] = None | 219 | + 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 config | 235 | 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 model | 246 | # 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 config | 263 | 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]] = None | 270 | + tag: Optional[List[str]] = None, |
| 259 | ): | 271 | ): |
| 260 | - has_quant_type = True if quant_type is not None else False | 272 | + has_quant_type = quant_type is not None |
| 261 | use_quant_type = quant_type if quant_type is not None else DEFAULT_QUANT_TYPE | 273 | 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 False | 275 | + 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 | continue | 343 | 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 | 348 | ||
| 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 model | 364 | + 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 model | 365 | model_path: str, the path of the model |
| 351 | save_path: str, the path to save the quantized model | 366 | 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_tags | 375 | 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 = None | 418 | tag = None |
| 400 | 419 | ||
| 401 | # Log parameters | 420 | # 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, tag | 448 | + 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_code | 472 | + 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_index | 502 | + 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 command | 123 | # 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 131 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 86 | + def read_weight_map(self) -> dict[str, str]: | ||
| 87 | + """ | ||
| 88 | + 读取原始索引映射。 | ||
| 89 | + | ||
| 90 | + 返回: | ||
| 91 | + dict[tensor_key, shard_path] | ||
| 92 | + """ | ||
| 93 | + pass | ||
| 94 | + | ||
| 95 | + | ||
| 96 | + def read_catalog(self) -> TensorCatalog: | ||
| 97 | + """ | ||
| 98 | + 构建权重目录(TensorCatalog)。 | ||
| 99 | + | ||
| 100 | + 实现可选择“仅 index 快速构建”或“携带部分 header 信息”。 | ||
| 101 | + """ | ||
| 102 | + pass | ||
| 103 | + | ||
| 104 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 190 | + def build( | ||
| 191 | + self, | ||
| 192 | + context: ConvertContext, | ||
| 193 | + tree: nn.Module, | ||
| 194 | + catalog: TensorCatalog, | ||
| 195 | + ) -> list[IRTask]: | ||
| 196 | + pass | ||
| 197 | + | ||
| 198 | + | ||
| 199 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。 ![]() ![]() 【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。 ![]() ![]() 【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。 ![]() ![]() 【openlibing.ci】识别到代码检查告警抑制注释,匹配工具:flake8,请Committer检视其合理性。 ![]() ![]() | |||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 201 | + def last_stats(self) -> GroupRunStats: | ||
| 202 | + return getattr(self, "_last_stats", GroupRunStats()) | ||
| 203 | + | ||
| 204 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | ) |
| 45 | from msmodelslim.format.compressed_tensors_format.quantization.quant_config import QuantizationConfig | 45 | from msmodelslim.format.compressed_tensors_format.quantization.quant_config import QuantizationConfig |
| 46 | from msmodelslim.format.interface import ExportContext | 46 | from msmodelslim.format.interface import ExportContext |
| 47 | -from msmodelslim.utils.exception import ConfigError, InvalidModelError, SchemaValidateError | 47 | +from msmodelslim.utils.exception import ConfigError, SchemaValidateError |
| 48 | from msmodelslim.utils.security import ( | 48 | from 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 | + | ||
| 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 | ||
| 22 | import torch | 22 | import torch |
| 23 | -from torch import nn as nn | 23 | +from torch import nn |
| 24 | from torch.nn import functional as F | 24 | from torch.nn import functional as F |
| 25 | 25 | ||
| 26 | from msmodelslim.ir.qal import QABCRegistry, QScope, QScheme, QParam, QStorage, QDType | 26 | from 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 | |||
| 32 | from msmodelslim.utils.logging import logger_setter | 32 | from 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 | 36 | ||
| 42 | class W8A8MXDynamicPerBlockFakeQuantLinear(AutoFakeQuantLinear): | 37 | class 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.scheme | 40 | self.w_scheme = w_q_param.scheme |
| 53 | self.w_mx_finfo = w_q_param.scheme.dtype.mx_finfo | 41 | 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 | + | ||
| 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_axes | 78 | axes = self.x_axes |
| 73 | axes = [axes] if isinstance(axes, int) else axes | 79 | 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.symmetric | 90 | + 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_size | 100 | 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 | + | ||
| 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 ( | |||
| 43 | from msmodelslim.format.interface import ExportContext | 43 | from msmodelslim.format.interface import ExportContext |
| 44 | from msmodelslim.utils.exception import ( | 44 | from 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 result | 303 | 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 | ||
| 313 | class TestCompressedTensorsQuantFormatFinalizeExport: | 311 | class TestCompressedTensorsQuantFormatFinalizeExport: |


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