From f0463057ef817fce09ee71b44a94643feb512658 Mon Sep 17 00:00:00 2001 From: fanglanyue Date: Mon, 28 Jul 2025 11:28:33 +0800 Subject: [PATCH] mstx module analysis --- profiler/msprof_analyze/cli/cluster_cli.py | 2 +- .../msprof_analyze/cluster_analyse/README.md | 72 ++-- .../cluster_analyse/cluster_analysis.py | 3 +- .../common_func/excel_utils.py | 175 +++++++++ .../recipes/module_statistic/__init__.py | 0 .../module_statistic/module_statistic.py | 347 ++++++++++++++++++ .../features/img/vllm_module_statistic.png | Bin 0 -> 94353 bytes .../docs/features/module_statistic.md | 171 +++++++++ .../msprof_analyze/prof_common/constant.py | 5 + .../prof_exports/base_stats_export.py | 22 +- .../prof_exports/module_statistic_export.py | 113 ++++++ profiler/msprof_analyze/version.txt | 2 +- 12 files changed, 871 insertions(+), 41 deletions(-) create mode 100644 profiler/msprof_analyze/cluster_analyse/common_func/excel_utils.py create mode 100644 profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/__init__.py create mode 100644 profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/module_statistic.py create mode 100644 profiler/msprof_analyze/docs/features/img/vllm_module_statistic.png create mode 100644 profiler/msprof_analyze/docs/features/module_statistic.md create mode 100644 profiler/msprof_analyze/prof_exports/module_statistic_export.py diff --git a/profiler/msprof_analyze/cli/cluster_cli.py b/profiler/msprof_analyze/cli/cluster_cli.py index 0cdb2bd2b..b0c9fb6ec 100644 --- a/profiler/msprof_analyze/cli/cluster_cli.py +++ b/profiler/msprof_analyze/cli/cluster_cli.py @@ -32,7 +32,7 @@ context_settings['ignore_unknown_options'] = True @click.option('--data_simplification', is_flag=True, help='data simplification switch for db data') @click.option('--force', is_flag=True, help="Indicates whether to skip file size verification and owner verification") @click.option("--parallel_mode", type=str, help="context mode", default="concurrent") -@click.option("--export_type", help="recipe export type", type=click.Choice(["db", "notebook"]), default="db") +@click.option("--export_type", help="recipe export type", type=click.Choice(["db", "notebook", "excel"]), default="db") @click.option("--rank_list", type=str, help="Rank id list", default='all') @click.argument('args', nargs=-1) def cluster_cli(**kwargs) -> None: diff --git a/profiler/msprof_analyze/cluster_analyse/README.md b/profiler/msprof_analyze/cluster_analyse/README.md index e488ab85f..40515ca88 100644 --- a/profiler/msprof_analyze/cluster_analyse/README.md +++ b/profiler/msprof_analyze/cluster_analyse/README.md @@ -54,45 +54,47 @@ experimental_config = torch_npu.profiler._ExperimentalConfig( 参数说明: - | 参数名 | 说明 | 是否必选 | - | --------------------- | ------------------------------------------------------------ | -------- | - | --profiling_path或-d | 性能数据汇集目录。未配置-o参数时,运行分析脚本之后会在该目录下自动创建cluster_analysis_output文件夹,保存分析数据。 | 是 | - | --output_path或-o | 自定义输出路径,运行分析脚本之后会在该目录下自动创建cluster_analysis_output文件夹,保存分析数据。 | 否 | - | --mode或-m | 数据解析模式,取值详见“**--mode参数说明**”表。 | 否 | - | --data_simplification | 数据精简模式。对于数据量过大的性能数据db文件,可以通过配置该参数将数据精简,并提高工具分析效率。配置该参数表示开启数据精简,默认未配置表示关闭。 | 否 | - | --force | 强制执行cluster。配置后可强制跳过如下情况:
指定的目录、文件的用户属主不属于当前用户,忽略属主判断直接执行。
csv文件大于5G、json文件大于10G、db文件大于8G,忽略文件过大判断直接执行。
配置该参数表示开启强制执行,默认未配置表示关闭。 | 否 | - | --parallel_mode | 设置收集多卡、多节点db数据时的并发方式。取值为concurrent(使用concurrent.feature进程池实现并发)。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | - | --export_type | 设置导出的数据形式。取值为db(.db格式文件)和notebook(Jupyter Notebook文件),默认值为db。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | - | --rank_list | 对特定Rank上的数据进行统计,默认值为all(表示对所有Rank进行统计),须根据实际卡的Rank ID配置。应配置为大于等于0的整数,若所配置的值大于实际训练所运行的卡的Rank ID,则仅解析合法的RankID的数据,比如当前环境Rank ID为0到7,实际训练运行0到3卡,此时若配置Rank ID为0, 3, 4或不存在的10等其他值,则仅解析0和3。配置示例:--rank_list 0, 1, 2。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | - | --top_num | 设置TopN耗时的通信算子的数量,默认值为15,配置示例:--top_num 20。
**只有-m配置hccl_sum时可配置此参数。** | 否 | - | --exclude_op_name | 控制compute_op_name结果是否包含op_name,示例:--exclude_op_name,后面不需要跟参数。
**只有-m配置compute_op_sum时可配置此参数。** | 否 | - | --bp | 要对比的标杆集群数据,示例:--bp {bp_cluster_profiling_path},表示profiling_path和bp_cluster_profiling_path的数据进行对比。
**只有-m配置cluster_time_compare_summary时可配置此参数。** | 否 | - + | 参数名 | 说明 | 是否必选 | + | -------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -------- | + | --profiling_path或-d | 性能数据汇集目录。未配置-o参数时,运行分析脚本之后会在该目录下自动创建cluster_analysis_output文件夹,保存分析数据。 | 是 | + | --output_path或-o | 自定义输出路径,运行分析脚本之后会在该目录下自动创建cluster_analysis_output文件夹,保存分析数据。 | 否 | + | --mode或-m | 数据解析模式,取值详见“**--mode参数说明**”表。 | 否 | + | --data_simplification | 数据精简模式。对于数据量过大的性能数据db文件,可以通过配置该参数将数据精简,并提高工具分析效率。配置该参数表示开启数据精简,默认未配置表示关闭。 | 否 | + | --force | 强制执行cluster。配置后可强制跳过如下情况:
指定的目录、文件的用户属主不属于当前用户,忽略属主判断直接执行。
csv文件大于5G、json文件大于10G、db文件大于8G,忽略文件过大判断直接执行。
配置该参数表示开启强制执行,默认未配置表示关闭。 | 否 | + | --parallel_mode | 设置收集多卡、多节点db数据时的并发方式。取值为concurrent(使用concurrent.feature进程池实现并发)。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | + | --export_type | 设置导出的数据形式。取值为db(.db格式文件)、notebook(Jupyter Notebook文件)和excel(excel格式文件),默认值为db。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | + | --rank_list | 对特定Rank上的数据进行统计,默认值为all(表示对所有Rank进行统计),须根据实际卡的Rank ID配置。应配置为大于等于0的整数,若所配置的值大于实际训练所运行的卡的Rank ID,则仅解析合法的RankID的数据,比如当前环境Rank ID为0到7,实际训练运行0到3卡,此时若配置Rank ID为0, 3, 4或不存在的10等其他值,则仅解析0和3。配置示例:--rank_list 0, 1, 2。
**只有-m配置cann_api_sum、compute_op_sum、hccl_sum、mstx_sum时可配置此参数。** | 否 | + | --top_num | 设置TopN耗时的通信算子的数量,默认值为15,配置示例:--top_num 20。
**只有-m配置hccl_sum时可配置此参数。** | 否 | + | --exclude_op_name | 控制compute_op_name结果是否包含op_name,示例:--exclude_op_name,后面不需要跟参数。
**只有-m配置compute_op_sum时可配置此参数。** | 否 | + | --bp | 要对比的标杆集群数据,示例:--bp {bp_cluster_profiling_path},表示profiling_path和bp_cluster_profiling_path的数据进行对比。
**只有-m配置cluster_time_compare_summary时可配置此参数。** | 否 | + + --mode参数说明: --mode参数设置不同的数据解析模式,可调用不同的分析能力,交付件详细内容请参见[recipe结果和cluster_analysis.db交付件表结构说明](#recipe结果和cluster_analysisdb交付件表结构说明)。 - | 参数名 | 说明 | 是否必选 | - |------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----| - | communication_matrix | 解析通信矩阵数据。 | 否 | - | communication_time | 解析通信耗时数据。 | 否 | - | all | 同时解析通信矩阵communication_matrix和通信耗时数据communication_time,--mode参数默认值为all。 | 否 | - | cann_api_sum | 集群API性能数据汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/CannApiSum目录下输出交付件stats.ipynb。 | 否 | - | compute_op_sum | 集群场景性能数据的device运行算子信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/ComputeOpSum目录下输出交付件stats.ipynb;可根据实际情况决定是否是否打开--exclude_op_name。 | 否 | - | hccl_sum | 集合通信算子耗时分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/HcclSum目录下输出交付件stats.ipynb。 | 否 | - | mstx_sum | 集群场景mstx打点信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/MstxSum目录下输出交付件stats.ipynb。 | 否 | - | slow_link | 集群慢链路异常分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/SlowLink目录下输出交付件stats.ipynb。 | 否 | - | cluster_time_summary | 集群场景性能数据分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db和analysis.db文件。--export_type为db时,输出交付件cluster_analysis.db,db里面有ClusterTimeSummary,不支持导出notebook。 | 否 | - | cluster_time_compare_summary | 集群场景性能数据对比分析,使用前集群数据必须先分析cluster_time_summary,需要配合--bp参数使用。输入性能数据需要基于cluster_analysis_output下的cluster_analysis.db文件。--export_type为db时,输出交付件cluster_analysis.db,db文件中有对比结果的表ClusterTimeCompareSummary,不支持导出notebook。 | 否 | - | slow_rank_pp_stage | 集群场景性能数据pp stage通信对比分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输入性能数据中MetaData表如果没有包含训练任务的并行策略,则需要通过--tp --pp --dp手动传入,数据类型为正整数。--export_type为db时,输出交付件cluster_analysis.db,db文件中有分析结果PPAnalysisResult和P2PAnalysisResult,不支持导出notebook。 | 否 | - | freq_analysis | 集群场景aicore frequency信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。打印输出是否aicore存在空闲(频率为800MHz)、异常(频率不为1800MHz或800MHz)的现象。如果有,则在输出交付件cluster_analysis.db增加对应的卡和频率信息。 | 否 | - | ep_load_balance | 集群场景moe负载信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出交付件cluster_analysis.db增加EPTokensSummary, TopEPTokensInfo分析表格。 | 否 | - | mstx2comm | 基于ascend_pytorch_profiler_{rank_id}.db文件,将通信内置打点数据转换成通信算子。 | 否 | - | slow_rank | 集群场景通信算子快慢卡汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出交付件cluster_analysis.db中展示各个rank按照当前的快慢卡统计算法得出的快慢卡影响次数。 | | - | p2p_pairing | 集群场景P2P算子生成全局关联索引,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出的关联索引会作为一个新的字段`opConnectionId`附在原性能数据ascend_pytorch_profiler_{rank_id}.db文件的`COMMUNICATION_OP`的表中。 | 否 | - | filter_db | 基于ascend_pytorch_profiler_{rank_id}.db文件,提取通信类大算子数据,计算类关键函数和框架关键函数,节约90%+ 内存,促进快速全局分析。 | 否 | - | pp_chart | 基于打点后的ascend_pytorch_profiler_{rank_id}.db文件,分析打点数据,还原pp流水图 | 否 | - | 自定义分析参数 | 与cann_api_sum、compute_op_sum、hccl_sum等参数功能类似,用户可自定义一套性能数据的分析规则,需要详细了解性能分析的开发人员,具体开发指导请参见“[自定义分析规则开发指导](#自定义分析规则开发指导)”。 | 否 | + | 参数名 | 说明 | 是否必选 | export_type支持类型 | + |------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----|-----------------| + | communication_matrix | 解析通信矩阵数据。 | 否 | / | + | communication_time | 解析通信耗时数据。 | 否 | / | + | all | 同时解析通信矩阵communication_matrix和通信耗时数据communication_time,--mode参数默认值为all。 | 否 | / | + | cann_api_sum | 集群API性能数据汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/CannApiSum目录下输出交付件stats.ipynb。 | 否 | db, nootebook | + | compute_op_sum | 集群场景性能数据的device运行算子信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/ComputeOpSum目录下输出交付件stats.ipynb;可根据实际情况决定是否是否打开--exclude_op_name。 | 否 | db, nootebook | + | hccl_sum | 集合通信算子耗时分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/HcclSum目录下输出交付件stats.ipynb。 | 否 | db, nootebook | + | mstx_sum | 集群场景mstx打点信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/MstxSum目录下输出交付件stats.ipynb。 | 否 | db, nootebook | + | slow_link | 集群慢链路异常分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。--export_type为db时,输出交付件cluster_analysis.db;--export_type为notebook时,在cluster_analysis_output/SlowLink目录下输出交付件stats.ipynb。 | 否 | db, nootebook | + | cluster_time_summary | 集群场景性能数据分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db和analysis.db文件。--export_type为db时,输出交付件cluster_analysis.db,db里面有ClusterTimeSummary。 | 否 | db | + | cluster_time_compare_summary | 集群场景性能数据对比分析,使用前集群数据必须先分析cluster_time_summary,需要配合--bp参数使用。输入性能数据需要基于cluster_analysis_output下的cluster_analysis.db文件。--export_type为db时,输出交付件cluster_analysis.db,db文件中有对比结果的表ClusterTimeCompareSummary。 | 否 | db | + | slow_rank_pp_stage | 集群场景性能数据pp stage通信对比分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输入性能数据中MetaData表如果没有包含训练任务的并行策略,则需要通过--tp --pp --dp手动传入,数据类型为正整数。--export_type为db时,输出交付件cluster_analysis.db,db文件中有分析结果PPAnalysisResult和P2PAnalysisResult。 | 否 | db | + | freq_analysis | 集群场景aicore frequency信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。打印输出是否aicore存在空闲(频率为800MHz)、异常(频率不为1800MHz或800MHz)的现象。如果有,则在输出交付件cluster_analysis.db增加对应的卡和频率信息。 | 否 | db | + | ep_load_balance | 集群场景moe负载信息汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出交付件cluster_analysis.db增加EPTokensSummary, TopEPTokensInfo分析表格。 | 否 | db | + | mstx2commop | 基于ascend_pytorch_profiler_{rank_id}.db文件,将通信内置打点数据转换成通信算子。 | 否 | db | + | slow_rank | 集群场景通信算子快慢卡汇总分析,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出交付件cluster_analysis.db中展示各个rank按照当前的快慢卡统计算法得出的快慢卡影响次数。 | 否 | db | + | p2p_pairing | 集群场景P2P算子生成全局关联索引,输入性能数据需要基于ascend_pytorch_profiler_{rank_id}.db文件。输出的关联索引会作为一个新的字段`opConnectionId`附在原性能数据ascend_pytorch_profiler_{rank_id}.db文件的`COMMUNICATION_OP`的表中。 | 否 | db | + | filter_db | 基于ascend_pytorch_profiler_{rank_id}.db文件,提取通信类大算子数据,计算类关键函数和框架关键函数,节约90%+ 内存,促进快速全局分析。 | 否 | db | + | pp_chart | 基于打点后的ascend_pytorch_profiler_{rank_id}.db文件,分析打点数据,还原pp流水图 | 否 | db | + | module_statistic | 基于打点后的ascend_pytorch_profiler_{rank_id}.db文件,分析打点数据,拆解模型结构与算子关联,具体指导请参见“[模型结构拆解指南](../docs/features/module_statistic.md)” | 否 | db, excel | + | 自定义分析参数 | 与cann_api_sum、compute_op_sum、hccl_sum等参数功能类似,用户可自定义一套性能数据的分析规则,需要详细了解性能分析的开发人员,具体开发指导请参见“[自定义分析规则开发指导](#自定义分析规则开发指导)”。 | 否 | / | --parallel_mode参数示例如下: diff --git a/profiler/msprof_analyze/cluster_analyse/cluster_analysis.py b/profiler/msprof_analyze/cluster_analyse/cluster_analysis.py index 6464bb732..43e716efa 100644 --- a/profiler/msprof_analyze/cluster_analyse/cluster_analysis.py +++ b/profiler/msprof_analyze/cluster_analyse/cluster_analysis.py @@ -140,7 +140,8 @@ def cluster_analysis_main(): parser.add_argument('--force', action='store_true', help="Indicates whether to skip file size verification and owner verification") parser.add_argument("--parallel_mode", type=str, help="context mode", default="concurrent") - parser.add_argument("--export_type", type=str, help="recipe export type", choices=["db", "notebook"], default="db") + parser.add_argument("--export_type", type=str, help="recipe export type", choices=["db", "notebook", "excel"], + default="db") parser.add_argument("--rank_list", type=str, help="Rank id list", default='all') args, extra_args = parser.parse_known_args() diff --git a/profiler/msprof_analyze/cluster_analyse/common_func/excel_utils.py b/profiler/msprof_analyze/cluster_analyse/common_func/excel_utils.py new file mode 100644 index 000000000..3ca5308b5 --- /dev/null +++ b/profiler/msprof_analyze/cluster_analyse/common_func/excel_utils.py @@ -0,0 +1,175 @@ +# Copyright (c) 2025, Huawei Technologies Co., Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List, Dict, Optional + +import pandas as pd + +from msprof_analyze.prof_common.logger import get_logger + +logger = get_logger() + + +class ExcelUtils: + DEFAULT_FORMAT = { + 'valign': 'vcenter', + 'border': 1, + 'font_name': 'Times New Roman' + } + DEFAULT_HEADER_FORMAT = { + 'valign': 'vcenter', + 'bold': True, + 'border': 1, + 'bg_color': '#AFEEEE', + 'font_name': 'Times New Roman' + } + + def __init__(self): + self.workbook = None + self.writer = None + self.worksheet = None + self.df = None + self._formats_cache = {} + + def clear(self) -> None: + """显式清理资源,允许实例重复使用""" + if self.writer: + self.writer.close() + self.workbook = None + self.writer = None + self.worksheet = None + self.df = None + self._formats_cache = {} + + def create_excel_writer(self, output_file: str, + df: pd.DataFrame, + column_format: Optional[Dict] = None, + header_format: Optional[Dict] = None, + sheet_name: str = 'Sheet1'): + """初始化Excel写入器并写入原始数据""" + self.writer = pd.ExcelWriter(output_file, engine='xlsxwriter') + self.workbook = self.writer.book + self.worksheet = self.workbook.add_worksheet(sheet_name) + self.df = df + + # 写入标题行 + header_fmt = self._get_format(header_format if header_format else self.DEFAULT_HEADER_FORMAT) + for col_idx, col_name in enumerate(df.columns): + self.worksheet.write(0, col_idx, col_name, header_fmt) + + # 写入数据行 + default_fmt = self._get_format(column_format if column_format else self.DEFAULT_FORMAT) + for row_idx, row in df.iterrows(): + for col_idx, col_name in enumerate(df.columns): + self.worksheet.write(row_idx + 1, col_idx, row[col_name], default_fmt) + + def save_and_close(self): + """保存并关闭 Excel 文件""" + if self.writer: + self.writer.close() + self.writer = None + self.workbook = None + self.worksheet = None + + def set_column_width(self, columns_config: Dict[str, int]): + """设置列宽""" + if not self.worksheet: + raise Exception("Worksheet has not been initialized!") + + for col, width in columns_config.items(): + col_idx = list(columns_config.keys()).index(col) + self.worksheet.set_column(col_idx, col_idx, width) + + def set_row_height(self, row: int, height: int): + """设置指定行的行高""" + if not self.worksheet: + raise Exception("Worksheet not initialized") + self.worksheet.set_row(row, height) + + def freeze_panes(self, row: int = 1, col: int = 0): + """冻结窗格""" + if not self.worksheet: + raise Exception("Worksheet has not been initialized!") + self.worksheet.freeze_panes(row, col) + + def merge_duplicate_cells( + self, + columns_to_merge: List[str], + merge_format: Optional[Dict] = None, + header_format: Optional[Dict] = None + ): + """ + 合并连续相同值的单元格 + + 参数: + df: 输入的 DataFrame + columns_to_merge: 需要合并的列名列表 + merge_format: 合并单元格的格式字典 + header_format: 标题行的格式字典 + """ + if not self.workbook or not self.worksheet: + raise Exception("Worksheet has not been initialized!") + + # 设置格式 + merge_fmt = self._get_format(merge_format if merge_format else self.DEFAULT_FORMAT) + header_fmt = self._get_format(header_format if header_format else self.DEFAULT_HEADER_FORMAT) + + # 应用标题行格式 + for col_num, value in enumerate(self.df.columns.values): + self.worksheet.write(0, col_num, value, header_fmt) + + # 遍历需要合并的列 + for col in columns_to_merge: + if col not in self.df.columns: + logger.warning(f"Invalid column: {col}, not in dataframe!") + continue + + col_idx = self.df.columns.get_loc(col) + current_value = None + start_row = 1 # 第一行是标题,从第二行开始 + merge_count = 0 + + for i in range(len(self.df)): + excel_row = i + 1 + + if self.df[col].iloc[i] == current_value: + continue + else: + if current_value is not None and (excel_row - 1) > start_row: + self.worksheet.merge_range( + start_row, col_idx, excel_row - 1, col_idx, + current_value, + merge_fmt + ) + merge_count += 1 + + current_value = self.df[col].iloc[i] + start_row = excel_row + + # 处理最后一组连续相同的值 + if current_value is not None and (len(self.df)) > start_row: + self.worksheet.merge_range( + start_row, col_idx, len(self.df), col_idx, + current_value, + merge_fmt + ) + merge_count += 1 + + def _get_format(self, format_dict: Dict): + """获取或创建格式对象(带缓存)""" + format_key = frozenset(format_dict.items()) + if format_key not in self._formats_cache: + self._formats_cache[format_key] = self.workbook.add_format(format_dict) + return self._formats_cache[format_key] \ No newline at end of file diff --git a/profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/__init__.py b/profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/module_statistic.py b/profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/module_statistic.py new file mode 100644 index 000000000..91a354f81 --- /dev/null +++ b/profiler/msprof_analyze/cluster_analyse/recipes/module_statistic/module_statistic.py @@ -0,0 +1,347 @@ +# Copyright (c) 2025, Huawei Technologies Co., Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from collections import defaultdict, deque + +import pandas as pd + +from msprof_analyze.cluster_analyse.common_func.excel_utils import ExcelUtils +from msprof_analyze.prof_common.db_manager import DBManager +from msprof_analyze.prof_exports.module_statistic_export import FrameworkOpToKernelExport, ModuleMstxRangeExport +from msprof_analyze.cluster_analyse.recipes.base_recipe_analysis import BaseRecipeAnalysis +from msprof_analyze.prof_common.constant import Constant +from msprof_analyze.prof_common.logger import get_logger + +logger = get_logger() + + +class NodeType: + MODULE_EVENT_NODE = 0 + CPU_OP_EVENT = 1 + KERNEL_EVENT = 2 + + +class TreeNode: + def __init__(self, start, end, node_type, name): + self.start = start + self.end = end + self.node_type = node_type + self.name = name + self.children = [] + + def add_child(self, node): + self.children.append(node) + + +class ModuleStatistic(BaseRecipeAnalysis): + TABLE_MODULE_STATISTIC = "ModuleStatistic" + KERNEL_RELATED_TABLE_LIST = [Constant.TABLE_COMPUTE_TASK_INFO, Constant.TABLE_COMMUNICATION_OP, + Constant.TABLE_COMMUNICATION_SCHEDULE_TASK_INFO] + MODULE_DOMAIN_NAME = 'Module' + MAX_TRAVERSE_DEPTH = 20 + + EXCEL_COLUMN_WIDTH_CONFIG = {"Parent Module": 40, + "Module": 40, + "Op Name": 40, + "Kernel List": 60, + "Total Kernel Duration(ns)": 15, + "Avg Kernel Duration(ns)": 15, + "Op Count": 15} + EXCEL_COLUMN_ALIGN_CONFIG = {"Total Kernel Duration(ns)": 'center', + "Avg Kernel Duration(ns)": 'center', + "Op Count": 'center'} + + def __init__(self, params): + super().__init__(params) + + @property + def base_dir(self): + return os.path.basename(os.path.dirname(__file__)) + + def run(self, context): + if self._export_type != Constant.DB and self._export_type != Constant.EXCEL: + logger.error(f"Invalid export type: {self._export_type} for module analysis, " + f"required to be {Constant.DB} or {Constant.EXCEL}") + mapper_res = self.mapper_func(context) + if self._export_type == Constant.DB: + total_df = self.reducer_func(mapper_res) + self.save_db(total_df) + elif self._export_type == Constant.EXCEL: + self.save_csv(mapper_res) + + def reducer_func(self, mapper_res): + valid_dfs = [stat_df.assign(rankID=rank_id) + for rank_id, stat_df in mapper_res + if not stat_df.empty] + return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else None + + def save_db(self, df): + if df is None or df.empty: + logger.warning(f"No module analysis result, skipping dump data") + return + self.dump_data(df, Constant.DB_CLUSTER_COMMUNICATION_ANALYZER, + self.TABLE_MODULE_STATISTIC, index=False) + + def save_csv(self, mapper_res): + columns_to_merge = ['Parent Module', 'Module'] + column_width_config = {"Parent Module": 40, + "Module": 40, + "Op Name": 40, + "Kernel List": 50, + "Total Kernel Duration(ns)": 10, + "Avg Kernel Duration(ns)": 10, + "Op Count": 10} + excel_utils = ExcelUtils() + for rank_id, stat_df in mapper_res: + if stat_df.empty: + logger.warning(f"No module analysis result for rank {rank_id}, skipping dump data") + continue + file_name = f"module_statistic_{rank_id}.xlsx" + file_path = os.path.join(self.output_path, file_name) + try: + excel_utils.create_excel_writer(file_path, stat_df) + excel_utils.merge_duplicate_cells(columns_to_merge) + excel_utils.set_column_width(column_width_config) + excel_utils.set_row_height(0, 27) # 标题行行高27 + excel_utils.save_and_close() + excel_utils.clear() + except Exception as e: + logger.error(f"Save excel failed, err: {e}") + + def _mapper_func(self, data_map, analysis_class): + """ + + kernel_df = pd.DataFrame() + """ + # Query Data + profiler_db_path = data_map.get(Constant.PROFILER_DB_PATH) + rank_id = data_map.get(Constant.RANK_ID) + module_export = ModuleMstxRangeExport(profiler_db_path, self._recipe_name, domain_name=self.MODULE_DOMAIN_NAME) + module_df = module_export.read_export_db() # module_df columns:["startNs", "endNs", "name"] + if module_df is None or module_df.empty: + logger.error(f"Can not export mstx range event with domain: {self.MODULE_DOMAIN_NAME}, from rank {rank_id}") + return rank_id, pd.DataFrame() + # kernel_df columns:["kernel_name", "kernel_ts", "kernel_end", "op_name", "op_ts", "op_end"] + kernel_df = self._query_framework_op_to_kernel(profiler_db_path) + if kernel_df is None or kernel_df.empty: + logger.error(f"Can not export framework op to kernel mapper from rank {rank_id}") + return rank_id, pd.DataFrame() + # Convert time related columns' type to be int + mstx_time_columns = ['startNs', 'endNs'] + module_df[mstx_time_columns] = module_df[mstx_time_columns].astype(int) + kernel_time_columns = ['kernel_ts', 'kernel_end', 'op_ts', 'op_end'] + kernel_df[kernel_time_columns] = kernel_df[kernel_time_columns].astype(int) + + # Build Tree + root = self._build_node_tree(module_df, kernel_df) + if not root: + logger.error(f"Empty event tree for rank {rank_id}") + return rank_id, pd.DataFrame() + # Convert to Dataframe + verbose_df = self._flatten_tree_to_dataframe(root) + if verbose_df.empty: + logger.error(f"Failed to extract event tree data for rank {rank_id}") + return rank_id, pd.DataFrame() + # 区分module与算子序列,并计算device_time平均值 + stat_df = self._aggregate_module_operator_stats(verbose_df.copy()) + return rank_id, stat_df + + def _query_framework_op_to_kernel(self, profiler_db_path): + valid_dfs = [] + for table_name in self.KERNEL_RELATED_TABLE_LIST: + if not DBManager.check_tables_in_db(profiler_db_path, table_name): + continue + export = FrameworkOpToKernelExport(profiler_db_path, self._recipe_name, table_name) + df = export.read_export_db() + if df is not None and not df.empty: + valid_dfs.append(df) + if not valid_dfs: + return None + try: + return pd.concat(valid_dfs, ignore_index=True) + except Exception as e: + logger.error(f"Failed to concatenate framework op to kernel dataframes: {str(e)}") + return None + + def _build_node_tree(self, module_df, kernel_df): + nodes = [] + + # 1. 创建mstx节点 + for _, row in module_df.iterrows(): + nodes.append(TreeNode( + row['startNs'], + row['endNs'], + NodeType.MODULE_EVENT_NODE, + row['name'] + )) + + # 2. 按op_name分组处理kernel数据 + op_groups = defaultdict(list) + for _, row in kernel_df.iterrows(): + op_groups[(row['op_name'], row['op_ts'], row['op_end'])].append(row) + + # 3. 创建op节点和对应的kernel节点 + for (op_name, op_ts, op_end), kernels in op_groups.items(): + # 创建op节点 + op_node = TreeNode( + op_ts, + op_end, + NodeType.CPU_OP_EVENT, + op_name + ) + + # 为每个op添加对应的kernel节点 + for kernel in kernels: + kernel_node = TreeNode( + kernel['kernel_ts'], + kernel['kernel_end'], + NodeType.KERNEL_EVENT, + kernel['kernel_name'] + ) + op_node.add_child(kernel_node) + + nodes.append(op_node) + + if not nodes: + logger.error(f"Empty node (module_event/cpu_op/kernel), skipping tree build") + return None + + # 4. 按开始时间升序、结束时间降序排序 + nodes.sort(key=lambda x: (x.start, -x.end)) + + # 5. 构建树结构 + root = self._create_root_node(module_df, kernel_df) + stack = [root] + + for node in nodes: + # 找到最近的能包含当前节点的父节点 + while stack[-1].start > node.start or stack[-1].end < node.end: + stack.pop() + + # 添加到父节点的children中 + stack[-1].add_child(node) + stack.append(node) + + return root + + def _create_root_node(self, module_df, kernel_df): + global_start = min(module_df['startNs'].min(), kernel_df['kernel_ts'].min(), kernel_df['op_ts'].min()) + global_end = max(module_df['endNs'].max(), kernel_df['kernel_end'].max(), kernel_df['op_end'].max()) + root = TreeNode(global_start, global_end, NodeType.MODULE_EVENT_NODE, "") + return root + + def _flatten_tree_to_dataframe(self, root_node): + results = [] + + def traverse(node, module_deque, depth=0): + if depth > self.MAX_TRAVERSE_DEPTH: + logger.warning(f"Max traversal depth {self.MAX_TRAVERSE_DEPTH} reached, traversal stopped. " + f"Some information may be lost") + return + if node.node_type != NodeType.MODULE_EVENT_NODE: + return + for op_child in node.children: + if op_child.node_type == NodeType.MODULE_EVENT_NODE: + module_deque.append(node.name) + traverse(op_child, module_deque, depth + 1) + module_deque.pop() + if op_child.node_type == NodeType.CPU_OP_EVENT: + module = node.name + module_parent = "/".join(module_deque) + # 跳过没有module归属的op + if not module and not module_parent: + continue + # 收集该op下的所有kernel信息 + kernel_names = [] + total_device_time = 0.0 + for kernel_child in op_child.children: + if kernel_child.node_type == NodeType.KERNEL_EVENT: + kernel_names.append(kernel_child.name) + duration = kernel_child.end - kernel_child.start + total_device_time += duration + results.append({ + 'module_parent': module_parent, + 'module': module, + 'module_start': node.start, + 'module_end': node.end, + 'op_name': op_child.name, + 'op_start': op_child.start, + 'op_end': op_child.end, + 'kernel_list': ', '.join(kernel_names), + 'device_time': total_device_time + }) + + traverse(root_node, deque(), 0) + if not results: + return pd.DataFrame() + # 转换为DataFrame并排序 + df = pd.DataFrame(results) + df = df.sort_values(by=['module_start', 'op_start'], ascending=[True, True]) + return df + + def _aggregate_module_operator_stats(self, df): + if df is None or df.empty: + logger.warning("Empty dataframe received for aggregation") + return pd.DataFrame() + + # 为每个算子添加在module下的顺序位置 + distinct_module_columns = ['module_parent', 'module', 'module_start', 'module_end'] + df['op_order'] = df.groupby(distinct_module_columns).cumcount() + # 创建seq_key保证唯一性,并分配ID + op_seq = df.groupby(distinct_module_columns)['op_name'].transform(lambda x: '/'.join(x)) + df['seq_key'] = df['module_parent'] + "|" + df['module'] + "|" + op_seq + df['seq_id'] = pd.factorize(op_seq)[0] + df.drop(columns=['seq_key'], inplace=True) + + stat_df = ( + df.groupby(['module_parent', 'module', 'op_name', 'op_order', 'kernel_list', 'seq_id']) + .agg( + module_start=('module_start', 'first'), # 取第一个 module_start + module_end=('module_end', 'first'), # 取第一个 module_end + total_kernel_duration=('device_time', 'sum'), # 计算 device_time 的总时间 + avg_kernel_duration=('device_time', 'mean'), # 计算 device_time 的平均时间 + op_count=('device_time', 'count') # 计算每组的行数(计数) + ).reset_index() + ) + stat_df = (stat_df.sort_values(by=['module_start', 'op_order']). + drop(columns=['module_start', 'module_end', 'seq_id', 'op_order']).reset_index(drop=True)) + + return self._format_stat_df_columns(stat_df) + + def _format_stat_df_columns(self, stat_df): + try: + if self._export_type == Constant.DB: + stat_df = stat_df.rename(columns={ + 'module_parent': 'parentModule', + 'op_name': 'opName', + 'kernel_list': 'kernelList', + 'op_count': 'opCount', + 'total_kernel_duration': 'totalKernelDuration(ns)', + 'avg_kernel_duration': 'avgKernelDuration(ns)'}) + elif self._export_type == Constant.EXCEL: + stat_df = stat_df.rename(columns={ + 'module_parent': 'Parent Module', + 'module': 'Module', + 'op_name': 'Op Name', + 'kernel_list': 'Kernel List', + 'op_count': 'Op Count', + 'total_kernel_duration': 'Total Kernel Duration(ns)', + 'avg_kernel_duration': 'Avg Kernel Duration(ns)'}) + except Exception as e: + logger.error(f"Failed to format statistic data's title, error message: {e}") + return pd.DataFrame() + return stat_df + diff --git a/profiler/msprof_analyze/docs/features/img/vllm_module_statistic.png b/profiler/msprof_analyze/docs/features/img/vllm_module_statistic.png new file mode 100644 index 0000000000000000000000000000000000000000..1ae07065dbd22c13d84de39bd2441820746e3048 GIT binary patch literal 94353 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%0AXXxR2W-3VLq#|irt5Al1^ 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 H*#G|nOi7X% literal 0 HcmV?d00001 diff --git a/profiler/msprof_analyze/docs/features/module_statistic.md b/profiler/msprof_analyze/docs/features/module_statistic.md new file mode 100644 index 000000000..bb7965831 --- /dev/null +++ b/profiler/msprof_analyze/docs/features/module_statistic.md @@ -0,0 +1,171 @@ +# 模型结构拆解指南 + +## 简介 + +msprof-analyze提供了针对PyTorch模型自动解析模型层级结构的分析能力(module_statistic),帮助精准定位性能瓶颈,为模型优化提供关键洞察。该分析能力提供: + +* 模型结构拆解:自动提取并展示模型的层次化结构,以及模型中的算子调用顺序 +* 算子与Kernel映射:框架层算子下与NPU上执行Kernel的映射关系 +* 性能分析:精确统计并输出Device侧Kernel的执行耗时 + + +## 操作指导 +### 性能数据准备 +#### 1. 添加mstx打点 + +在模型代码中调用`torch_npu.npu.mstx.range_start/range_end`性能打点接口,需重写PyTorch中的nn.Module调用逻辑 + +#### 2. 配置并采集 Profiling 数据 + +* 使用`torch_npu.profiler`接口采集性能数据 +* 在`torch_npu.profiler._ExperimentalConfig`设置`msprof_tx=True`,开启打点事件采集 +* 在`torch_npu.profiler._ExperimentalConfig`设置`export_type`导出类型,需要包含DB +* 性能数据落盘在`torch_npu.profiler.tensorboard_trace_handler`接口指定的路径下,将该路径下的数据作为msprof-analyze cluster的输入数据 + +完整样例代码,详见[性能数据采集样例代码](#性能数据采集样例代码) + +### 执行分析命令 + +使用以下命令启用分析功能: +``` +msprof-analyze cluster -m module_statistic -d ./result --export_type excel +``` +参数说明: +* `-d`: 集群性能数据路径 +* `--export_type`: 导出类型设置,可选db, excel +* 其余参数:与cluster集群分析功能支持的参数一致,详见[参数列表](../../cluster_analyse/README.md) + +### 输出说明 +输出结果体现模型层级,算子调用顺序,NPU上执行的Kernel以及统计时间 +* `export_type`设置为`excel`时,每张卡生成独立的module_statistic_{rank_id}.csv文件,如下图所示: +![vllm_module_statistic](img/vllm_module_statistic.png) + +* `export_type`设置为`db`时,结果统一保存到 cluster_analysis.db 的 ModuleAnalysisStatistic,字段说明如下: + + | 字段名称 | 类型 | 说明 | + |---------------------|---------|-----------------------------------| + | parentModule | TEXT | 上层Module名称 | + | module | TEXT | 最底层Module名称 | + | opName | TEXT | 框架侧算子名称,同一module下,算子按照调用顺序排列 | + | kernelList | TEXT | 框架侧算子下发到Device侧执行Kernel的序列 | + | totalKernelDuration | REAL | 框架侧算子对应Device侧Kerenl运行总时间,单位纳秒(ns) | + | avgKernelDuration | REAL | 框架侧算子对应Device侧Kerenl平均运行时间,单位纳秒(ns) | + | opCount | INTEGER | 框架侧算子在采集周期内运行的次数 | + | rankID | INTEGER | 集群场景的节点识别ID,集群场景下设备的唯一标识 | + + + +## 附录 +### 性能数据采集样例代码 + +对于复杂模型结构,建议采用选择性打点策略以降低性能开销,核心性能打点实现代码如下: +``` +module_list = ["Attention", "QKVParallelLinear"] +def custom_call(self, *args, **kwargs): + module_name = self.__class__.__name__ + if module_name not in module_list: + return original_call(self, *args, **kwargs) + mstx_id = torch_npu.npu.mstx.range_start(module_name, domain="Module") + tmp = original_call(self, *args, **kwargs) + torch_npu.npu.mstx.range_end(mstx_id, domain="Module") + return tmp +``` +完整样例代码如下: +``` +import random +import torch +import torch_npu +import torch.nn as nn +import torch.optim as optim + + +original_call = nn.Module.__call__ + +def custom_call(self, *args, **kwargs): + """自定义nn.Module调用方法,添加MSTX打点""" + module_name = self.__class__.__name__ + mstx_id = torch_npu.npu.mstx.range_start(module_name, domain="Module") + tmp = original_call(self, *args, **kwargs) + torch_npu.npu.mstx.range_end(mstx_id, domain="Module") + return tmp + + +class RMSNorm(nn.Module): + def __init__(self, dim: int, eps: float = 1e-6): + super().__init__() + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def _norm(self, x): + return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) + + def forward(self, x): + output = self._norm(x.float()).type_as(x) + return output * self.weight + + +class ToyModel(nn.Module): + def __init__(self, D_in, H, D_out): + super(ToyModel, self).__init__() + self.input_linear = torch.nn.Linear(D_in, H) + self.middle_linear = torch.nn.Linear(H, H) + self.output_linear = torch.nn.Linear(H, D_out) + self.rms_norm = RMSNorm(D_out) + + def forward(self, x): + h_relu = self.input_linear(x).clamp(min=0) + for i in range(3): + h_relu = self.middle_linear(h_relu).clamp(min=random.random()) + y_pred = self.output_linear(h_relu) + y_pred = self.rms_norm(y_pred) + return y_pred + + +def train(): + # 替换默认调用方法 + nn.Module.__call__ = custom_call + N, D_in, H, D_out = 256, 1024, 4096, 64 + + torch.npu.set_device(6) + input_data = torch.randn(N, D_in).npu() + labels = torch.randn(N, D_out).npu() + model = ToyModel(D_in, H, D_out).npu() + + loss_fn = nn.MSELoss() + optimizer = optim.SGD(model.parameters(), lr=0.001) + + experimental_config = torch_npu.profiler._ExperimentalConfig( + aic_metrics=torch_npu.profiler.AiCMetrics.PipeUtilization, + profiler_level=torch_npu.profiler.ProfilerLevel.Level2, + l2_cache=False, + msprof_tx=True, # 打开msprof_tx采集 + data_simplification=False, + export_type=['text', 'db'] # 导出类型中必须要包含db + ) + + prof = torch_npu.profiler.profile( + activities=[torch_npu.profiler.ProfilerActivity.CPU, torch_npu.profiler.ProfilerActivity.NPU], + schedule=torch_npu.profiler.schedule(wait=1, warmup=1, active=3, repeat=1, skip_first=5), + on_trace_ready=torch_npu.profiler.tensorboard_trace_handler("./result"), + record_shapes=True, + profile_memory=False, + with_stack=False, + with_flops=False, + with_modules=True, + experimental_config=experimental_config) + prof.start() + + for i in range(12): + optimizer.zero_grad() + outputs = model(input_data) + loss = loss_fn(outputs, labels) + loss.backward() + optimizer.step() + prof.step() + + prof.stop() + + +if __name__ == "__main__": + train() +``` diff --git a/profiler/msprof_analyze/prof_common/constant.py b/profiler/msprof_analyze/prof_common/constant.py index 5601a9f77..b05961137 100644 --- a/profiler/msprof_analyze/prof_common/constant.py +++ b/profiler/msprof_analyze/prof_common/constant.py @@ -141,9 +141,11 @@ class Constant(object): TABLE_STRING_IDS = "STRING_IDS" TABLE_TASK = "TASK" TABLE_TASK_MPU_INFO = "TASK_MPU_INFO" + TABLE_COMMUNICATION_SCHEDULE_TASK_INFO = "COMMUNICATION_SCHEDULE_TASK_INFO" # export_type NOTEBOOK = "notebook" + EXCEL = "excel" # db name DB_COMMUNICATION_ANALYZER = "analysis.db" @@ -480,3 +482,6 @@ class Constant(object): UINT32_MASK = 0xffffffff INVALID_RANK_NUM = 4294967295 + + SQL_PLACEHOLDER_PATTERN = r"\?|\%s" + diff --git a/profiler/msprof_analyze/prof_exports/base_stats_export.py b/profiler/msprof_analyze/prof_exports/base_stats_export.py index 59d58bdff..786e139d2 100644 --- a/profiler/msprof_analyze/prof_exports/base_stats_export.py +++ b/profiler/msprof_analyze/prof_exports/base_stats_export.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re +from typing import List + import pandas as pd from msprof_analyze.prof_common.db_manager import DBManager @@ -28,21 +31,34 @@ class BaseStatsExport: self._db_path = db_path self._analysis_class = analysis_class self._query = None + self._param = None self.mode = Constant.ANALYSIS def get_query(self): return self._query + def set_params(self, param: List): + if not isinstance(param, List) or not param: + logger.error("Export param type must be List and not empty") + return + self._param = param + def read_export_db(self): try: + if not self._db_path: + logger.error("db path is None.") + return None query = self.get_query() if query is None: logger.error("query is None.") return None - conn, cursor = DBManager.create_connect_db(self._db_path, self.mode) - data = pd.read_sql(query, conn) + conn, cursor = DBManager.create_connect_db(self._db_path, Constant.ANALYSIS) + if self._param is not None and re.search(Constant.SQL_PLACEHOLDER_PATTERN, query): + data = pd.read_sql(query, conn, params=self._param) + else: + data = pd.read_sql(query, conn) DBManager.destroy_db_connect(conn, cursor) return data except Exception as e: logger.error(f"File {self._db_path} read failed error: {e}") - return None \ No newline at end of file + return None diff --git a/profiler/msprof_analyze/prof_exports/module_statistic_export.py b/profiler/msprof_analyze/prof_exports/module_statistic_export.py new file mode 100644 index 000000000..800a142d6 --- /dev/null +++ b/profiler/msprof_analyze/prof_exports/module_statistic_export.py @@ -0,0 +1,113 @@ +# Copyright (c) 2025, Huawei Technologies Co., Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from msprof_analyze.prof_common.constant import Constant +from msprof_analyze.prof_common.logger import get_logger +from msprof_analyze.prof_exports.base_stats_export import BaseStatsExport + + +logger = get_logger() + + +QUERY_COMPUTE_TASK = """ + WITH task_connections AS ( + SELECT + str.value AS name, + task.startNs, + task.endNs, + conn.id AS api_conn_id + FROM + {compute_table} AS compute + LEFT JOIN + TASK task ON compute.globalTaskId = task.globalTaskId + LEFT JOIN + STRING_IDS str ON str.id = compute.name + LEFT JOIN + CONNECTION_IDS conn ON conn.connectionId = task.connectionId + )""" + +QUERY_COMMUNICATION_TASK = """ + WITH task_connections AS ( + SELECT + str.value AS name, + comm.startNs, + comm.endNs, + conn.id AS api_conn_id + FROM + COMMUNICATION_OP AS comm + JOIN + STRING_IDS str ON str.id = comm.opType + JOIN + CONNECTION_IDS conn ON conn.connectionId = comm.connectionId + )""" + + +QUERY_TASK_LINK_PYTORCH_API = """ + SELECT + tc.name as kernel_name, + tc.startNs as kernel_ts, + tc.endNs as kernel_end, + api_str.value AS op_name, + api.startNs as op_ts, + api.endNs as op_end + FROM + task_connections tc + JOIN + PYTORCH_API api ON tc.api_conn_id = api.connectionId + JOIN + STRING_IDS api_str ON api.name = api_str.id + ORDER BY op_ts, kernel_ts +""" + + + +QUERY_MSTX_RANGE_WITH_DOMAIN = """ + SELECT + mstx.startNs, + mstx.endNs, + str_name.value AS name + FROM + MSTX_EVENTS mstx + LEFT JOIN + STRING_IDS str_name ON mstx.message = str_name.id + LEFT JOIN + STRING_IDS str_domain ON mstx.domainId = str_domain.id + WHERE + mstx.eventType = 2 + {} + ORDER BY mstx.startNs +""" + + +class FrameworkOpToKernelExport(BaseStatsExport): + + def __init__(self, db_path, recipe_name, table_name): + super().__init__(db_path, recipe_name) + if table_name in [Constant.TABLE_COMPUTE_TASK_INFO, Constant.TABLE_COMMUNICATION_SCHEDULE_TASK_INFO]: + self._query = (QUERY_COMPUTE_TASK + QUERY_TASK_LINK_PYTORCH_API).format(compute_table=table_name) + elif table_name == Constant.TABLE_COMMUNICATION_OP: + self._query = QUERY_COMMUNICATION_TASK + QUERY_TASK_LINK_PYTORCH_API + else: + logger.error(f"FrameworkOpToKernelExport not support {table_name}") + + +class ModuleMstxRangeExport(BaseStatsExport): + + def __init__(self, db_path, recipe_name, domain_name=None): + super().__init__(db_path, recipe_name) + filter_statement = "" + if domain_name: + filter_statement = "AND str_domain.value = ?" + self.set_params([domain_name.replace('"', "'")]) # 用单引号替换双引号 + self._query = QUERY_MSTX_RANGE_WITH_DOMAIN.format(filter_statement) diff --git a/profiler/msprof_analyze/version.txt b/profiler/msprof_analyze/version.txt index 359a5b952..da1561810 100644 --- a/profiler/msprof_analyze/version.txt +++ b/profiler/msprof_analyze/version.txt @@ -1 +1 @@ -2.0.0 \ No newline at end of file +8.1.0 \ No newline at end of file -- Gitee