update
This commit is contained in:
576
engine/structure.py
Normal file
576
engine/structure.py
Normal file
@@ -0,0 +1,576 @@
|
||||
import ast
|
||||
import cProfile
|
||||
import functools
|
||||
import os
|
||||
import pstats
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from engine.schema import FlameNode, FunctionNode
|
||||
from engine.harness import _build_user_globals, _load_code, _restore_argv, _scrub_argv_for_user_code, load_source
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructureResult:
|
||||
functions: list
|
||||
flame: FlameNode
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 内部测试代码识别 + 量化噪声过滤
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# 用户原话:「软件内部的测试部分默认百分百过滤掉,不在统计范围内容。完全不显示。」
|
||||
# —— 所以测试代码 + tottime=0 的帧默认不进 result.json / functions[] / flame。
|
||||
#
|
||||
# 这是 v5 才加的过滤。在 App 入口(filterAnalysis)也有一份,这里再加一份是为了
|
||||
# 「不打开 UI 也想看干净数据」的场景:用户拿 result.json 跑自己的聚合脚本时,
|
||||
# 拿到的就是过滤后的数据,而不是 774 帧 + 90% 是噪声。
|
||||
#
|
||||
# 跟 App 层契约一致:只匹配 CONTEXT(文件路径 / 模块路径) + 引擎内部硬编码白名单,
|
||||
# 完全不看函数名 —— TS 端 isInternalTest 的同样设计原则,误伤 = bug。
|
||||
|
||||
import re as _re_noise
|
||||
|
||||
# 文件名是 test_*.py / *_test.py —— 任意位置都算测试代码(pytest 文件命名约定)
|
||||
_INTERNAL_TEST_FILE_RE = _re_noise.compile(r'[\\/](?:test_[^\\/]+|[^\\/]+_test)\.py$')
|
||||
# 在 tests/ / test/ / __tests__/ **直接下面**的文件 —— pytest 目录约定。
|
||||
# 收紧到要求「tests/ 后面紧接一个文件名,不能再有 / 子目录」:
|
||||
# * tests/foo.py → 算 (foo.py 直接在 tests/ 下)
|
||||
# * tests/fixtures/foo.py → 不算 (fixtures 是子目录,foo.py 不直接挂在 tests/ 下)
|
||||
# 之前用 `[\\/](tests?|__tests__)[\\/]` 任何含 `/tests/` 的路径都中招,把 fixtures 这类
|
||||
# 测试数据夹具也当成测试代码误伤 —— 用户脚本只要住在带 tests/ 子目录的路径下,
|
||||
# 函数表直接被过滤成 0 行。
|
||||
_INTERNAL_TEST_DIR_RE = _re_noise.compile(r'[\\/](tests?|__tests__)[\\/][^\\/]+$')
|
||||
_INTERNAL_TEST_MODULE_RE = _re_noise.compile(r'^(tests?|__tests__)([._]|$)')
|
||||
_INTERNAL_NAMES: frozenset = frozenset(["_pyrof_calib"])
|
||||
|
||||
|
||||
def _is_internal_test(name: str, file: str, module: str) -> bool:
|
||||
"""Python 版的 isInternalTest —— 命中即视作「噪声帧」,不进 functions[]。
|
||||
|
||||
设计原则(同 TS 版 utils/origin.ts):
|
||||
- 只看语境(文件 / 模块 / 引擎白名单),不看函数名 —— 函数名匹配太容易误伤
|
||||
(test_helper() / TestCase.test_login() 都是合法业务函数)
|
||||
- 引擎内部硬编码白名单(_pyrof_calib)是兜底防御,任何情况下都不该出现在用户 stats 里
|
||||
"""
|
||||
if name in _INTERNAL_NAMES:
|
||||
return True
|
||||
# 文件名是 test_*.py / *_test.py —— 任意位置
|
||||
if _INTERNAL_TEST_FILE_RE.search(file):
|
||||
return True
|
||||
# 在 tests/ / test/ / __tests__/ 直接下面的文件(不能是子目录)
|
||||
if _INTERNAL_TEST_DIR_RE.search(file):
|
||||
return True
|
||||
# module 一定非空(_top_module 不会返回空字符串),不用做 truthy 兜底
|
||||
if _INTERNAL_TEST_MODULE_RE.match(module):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_user_imports(src: str) -> set:
|
||||
"""从用户脚本源码里抽出顶层显式 import 的模块名集合。
|
||||
|
||||
只看模块级 Import / ImportFrom —— 函数/类内部的 import 是延迟副作用,不是
|
||||
「我需要分析的目标」。返回集合里保留顶级包名(numpy / json / os 等),不展开
|
||||
as 后的别名(as np → numpy 也在集合里,别名不进集合)。
|
||||
|
||||
解析失败(syntax error 等)时返回空集合 —— 调用方已经独立做了 compile 预检,
|
||||
走到 profile_and_measure 的代码一定是合法 Python,这里只是防御。
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError:
|
||||
return set()
|
||||
modules: set = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
# 'import a.b.c' → 'a' 就够了;子包自然跟着顶级包一起保留。
|
||||
modules.add(alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# 'from . import x' 的 level > 0 是相对导入,跳过 —— 它们依附于某个
|
||||
# 已知包(用户脚本 / 已导入的第三方),不展开根模块。
|
||||
# node.level 是 int(0/1/2/...),0 是 falsy,这里只需 if node.level。
|
||||
if node.level:
|
||||
continue
|
||||
if node.module:
|
||||
modules.add(node.module.split(".")[0])
|
||||
return modules
|
||||
|
||||
|
||||
def _fid(func):
|
||||
file, line, name = func
|
||||
return f"{file}:{line}:{name}"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def _norm(path):
|
||||
"""规范化路径 —— Windows NTFS 大小写不敏感 + 跨斜杠风格统一。
|
||||
|
||||
包 lru_cache:典型 profile 5000 帧 / 50 unique file path —— 之前每个
|
||||
frame 都跑 abspath + normcase,scope=user 的 hot loop 里反复调;
|
||||
缓存后只有 50 次真正的 abspath。同一路径跨多次 _top_module /
|
||||
_classify_origin / _make_frame_filter 复用同一结果。
|
||||
maxsize=4096 覆盖任何真实 profile 看不到的 unique file 数。
|
||||
"""
|
||||
try:
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
except Exception:
|
||||
return path
|
||||
|
||||
|
||||
def _make_frame_filter(script_path, scope="user"):
|
||||
"""构造帧过滤器。
|
||||
|
||||
scope=user(默认):只保留用户脚本里的函数 — 历史上一直如此。
|
||||
scope=all:保留所有非 cProfile 内部帧(包含标准库和第三方包)— 让耗时
|
||||
可以"进入到 import 的包里",否则 `pandas.read_csv` 永远是一个黑盒,
|
||||
看到 3s 也不知道是序列化慢、IO 慢还是解析慢。
|
||||
|
||||
`~` 帧的过滤有讲究 —— 之前一刀切 file == '~' 都丢,实际上:
|
||||
- cProfile 用 `~` 作为**所有 C 函数帧**的文件名(time.sleep / numpy C 核
|
||||
/ json 加速 / re 等),丢这些直接打瞎 scope=all
|
||||
- 真正的 cProfile 内部帧靠 name 区分:`'_lsprof.Profiler'` /
|
||||
`'Profiler' / '<method 'disable' of '_lsprof.Profiler' objects>'` 之类
|
||||
只剔除 name 含 '_lsprof.Profiler' 的帧,其它 C 函数保留 —— schema 里
|
||||
origin='builtin' / module='<built-in>' 也终于能命中真实 builtin 帧。
|
||||
"""
|
||||
target = _norm(script_path)
|
||||
|
||||
def _is_kept(func):
|
||||
file, _, name = func
|
||||
# cProfile 内部帧 —— file='~',name 含 "_lsprof.Profiler" 子串
|
||||
# (实际形态:'Profiler' / '_lsprof.Profiler' /
|
||||
# "<method 'disable' of '_lsprof.Profiler' objects>")
|
||||
if file == "~" and ("_lsprof.Profiler" in name or name == "Profiler"):
|
||||
return False
|
||||
if scope == "all":
|
||||
return True
|
||||
# 虚拟路径(<frozen ...> / <built-in ...> / <string> 等)不可能是用户脚本,
|
||||
# 直接 False 省一次 _norm —— _norm("<frozen ...>") 会跑 abspath 拼成
|
||||
# "<cwd>/<frozen ...>" 然后 normcase,跟用户脚本路径比必然不等,但白做一次
|
||||
# 文件系统查询。~ 在 cProfile 内部已上面短路,这里同等处理。
|
||||
if file.startswith("<") or file == "~":
|
||||
return False
|
||||
return _norm(file) == target
|
||||
|
||||
return _is_kept
|
||||
|
||||
|
||||
def _top_module(file_path: str, user_script_path: str, user_script_norm: str | None = None) -> str:
|
||||
"""把 cProfile 给的 file 路径归类成「顶层模块名」,给 UI 做分类用。
|
||||
|
||||
输出样例:
|
||||
- 用户脚本(==user_script_path) → "<user>"
|
||||
- "/usr/lib/python3.11/json/decoder.py" → "json"
|
||||
- "C:\\Python39\\Lib\\json\\decoder.py" → "json"
|
||||
- "C:\\Python39\\Lib\\functools.py" → "functools" ← 顶层 .py 必须剥掉后缀
|
||||
- "/.../site-packages/numpy/core/array.py" → "numpy"
|
||||
- "<frozen importlib._bootstrap>" → "<frozen>"
|
||||
- "<built-in method xxx>" → "<built-in>"
|
||||
- 其他 → 取倒数第二个目录段作 fallback(基本不会走到)
|
||||
|
||||
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同一进程内
|
||||
同一脚本会查几百次,提到外面省 abspath;不传则本函数内现算(保持单点调用方兼容)。
|
||||
"""
|
||||
if file_path == "~":
|
||||
# C 函数(time.sleep / numpy 加速 / json C decoder 等):
|
||||
# file==~ + name 是 "<built-in method ...>"
|
||||
# 之前丢光后这里 dead code,现在 _make_frame_filter 不再丢 C 帧,
|
||||
# module 字段需要给出有意义分类 —— 用 "<built-in>" 跟 origin 字段对齐。
|
||||
return "<built-in>"
|
||||
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
|
||||
return "<user>"
|
||||
# frozen / built-in / <string> 这种「虚拟」文件:整段作为标签
|
||||
if file_path.startswith("<"):
|
||||
if file_path.startswith("<frozen"):
|
||||
return "<frozen>"
|
||||
if file_path.startswith("<built-in"):
|
||||
return "<built-in>"
|
||||
return file_path
|
||||
# 路径规范化:跨平台 + 跨斜杠
|
||||
norm = file_path.replace("\\", "/")
|
||||
parts = [p for p in norm.split("/") if p]
|
||||
# site-packages / dist-packages:标记之后的第一个目录段就是包名
|
||||
for marker in ("site-packages", "dist-packages"):
|
||||
if marker in parts:
|
||||
idx = parts.index(marker) + 1
|
||||
if idx < len(parts):
|
||||
return parts[idx]
|
||||
# 标准库(Windows 安装布局):C:\Python39\Lib\<pkg>\... 或顶层 .py
|
||||
# `Lib\\functools.py` → "functools"(不是 "functools.py"),用 _strip_py 兜底
|
||||
if "Lib" in parts:
|
||||
idx = parts.index("Lib") + 1
|
||||
if idx < len(parts):
|
||||
return _strip_py(parts[idx])
|
||||
# 标准库(Linux / macOS 安装布局):/usr/lib/python3.X/<pkg>\...
|
||||
# 之前用 p.startswith("python") + p[6:7].isdigit() 太松散 —— "python3-extra"
|
||||
# 这种目录会被误识别;收紧到严格的 `pythonX(.Y)?` 形式。
|
||||
for i, p in enumerate(parts):
|
||||
if re.fullmatch(r"python\d+(\.\d+)?", p):
|
||||
if i + 1 < len(parts):
|
||||
return _strip_py(parts[i + 1])
|
||||
# 兜底:取倒数第二个目录段(例如 ".../myproj/src/utils/helper.py" → "utils")
|
||||
if len(parts) >= 2:
|
||||
return _strip_py(parts[-2])
|
||||
return _strip_py(file_path)
|
||||
|
||||
|
||||
def _strip_py(name: str) -> str:
|
||||
"""顶层 .py 文件剥掉扩展名 —— `functools.py` → `functools`。
|
||||
|
||||
只剥 `.py` 后缀;其它段('__init__'、'site-packages' 等)原样保留。
|
||||
非顶层文件不会被这个函数触碰 —— _top_module 把它包在 `parts[idx]` 之外的位置时
|
||||
返回的就是 `parts[-2]` 这种目录段,永远不带 `.py`;只有顶层 `<name>.py` 才走到这里。"""
|
||||
if name.endswith(".py"):
|
||||
return name[:-3]
|
||||
return name
|
||||
|
||||
|
||||
# sys.stdlib_module_names 是 3.10+ 才有的;3.9 及之前要走路径兜底。
|
||||
# 提前 frozen 一次 —— 同一进程内不变,反复判 in 走 frozenset 是 O(1)。
|
||||
_STDLIB_MODULES: frozenset | None
|
||||
try:
|
||||
_STDLIB_MODULES = frozenset(getattr(sys, "stdlib_module_names", set()))
|
||||
except Exception:
|
||||
_STDLIB_MODULES = None
|
||||
|
||||
|
||||
# 路径兜底"是不是真的在 stdlib 根下"用 sysconfig —— sysconfig.get_paths() 是
|
||||
# Python 官方给出的 stdlib 根解析工具,远比手算 <prefix> + 'Lib' / '<prefix>/lib/pythonX.Y'
|
||||
# 靠谱(venv / embed / framework 几种安装布局都覆盖)。
|
||||
#
|
||||
# M5 fix:之前只看路径里是否含 "/Lib/" 或 "/lib/pythonX.Y/" —— 用户项目
|
||||
# 里有 `myproject/Lib/foo.py` 这种就会被错认成 stdlib。现在锚到 sysconfig
|
||||
# 算出的真 stdlib 根上:不是真正的 Python 安装根下面的,一律不算 stdlib。
|
||||
#
|
||||
# 一次性算好缓存,frozen 之后 hot path 上 O(1) prefix 比对。
|
||||
def _stdlib_roots() -> tuple:
|
||||
roots: list = []
|
||||
try:
|
||||
import sysconfig
|
||||
stdlib_path = sysconfig.get_paths().get("stdlib", "")
|
||||
if stdlib_path:
|
||||
roots.append(_norm(stdlib_path))
|
||||
except Exception:
|
||||
pass
|
||||
# 兜底再放 sys.prefix/Lib —— 有些 embedded 安装 sysconfig 拿不到
|
||||
fallback = os.path.join(sys.prefix, "Lib")
|
||||
roots.append(_norm(fallback))
|
||||
return tuple(roots)
|
||||
|
||||
|
||||
_STDLIB_ROOTS_NORM: tuple = _stdlib_roots()
|
||||
|
||||
|
||||
def _is_under_real_stdlib(file_path: str) -> bool:
|
||||
"""判断 file_path 是否在真正的 Python stdlib 根下。
|
||||
|
||||
只走"路径兜底"分支 (3.9 / 未知模块名 兜底),已有 _STDLIB_MODULES 命中时不调用本函数,无谓开销。
|
||||
"""
|
||||
fp = _norm(file_path)
|
||||
for root in _STDLIB_ROOTS_NORM:
|
||||
if fp == root or fp.startswith(root + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _classify_origin(file_path: str, user_script_path: str, module_name: str, user_script_norm: str | None = None) -> str:
|
||||
"""给一帧函数归类来源(v3 新增字段 origin)。
|
||||
|
||||
返回值(即 JSON 里的字面量,UI 端按这个 group):
|
||||
"user" 用户脚本(路径与 user_script_path 一致)
|
||||
"frozen" <frozen importlib._bootstrap> 等冻结帧
|
||||
"builtin" <built-in method exec> 等 C 实现的 builtin
|
||||
"stdlib" Python 标准库(按 sys.stdlib_module_names 校准;3.10+
|
||||
才生效,老版本退化为路径启发式)
|
||||
"third_party" site-packages / dist-packages 下的第三方包
|
||||
"other" 兜底 —— 例如 <string>、未匹配任何已知布局的奇怪路径
|
||||
|
||||
顺序很关键:
|
||||
1) user / frozen / builtin 用文件路径前缀直接判,O(1)
|
||||
2) stdlib 先查 sys.stdlib_module_names(权威),命中即返回
|
||||
3) 路径里出现 site-packages / dist-packages → third_party
|
||||
4) 路径里出现 /Lib/ 或 /lib/pythonX.Y/ → stdlib(启发式兜底)
|
||||
5) 其它 → other
|
||||
|
||||
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同 _top_module,
|
||||
热路径上几百次调用,提到外面省一次 abspath。
|
||||
"""
|
||||
# C 扩展函数(time.sleep / numpy C 核 / json C 加速器 等)cProfile 把 file 标成 "~"。
|
||||
# _top_module 已经把 module 字段定为 "<built-in>"(与 origin 字段对齐的契约见那里),
|
||||
# 这里也要走 builtin 分支,否则 origin = "other" 与 module = "<built-in>" 错位,UI
|
||||
# 端按 origin 分组时这条帧会落到别的桶里 —— 之前一直漏到这里。
|
||||
if file_path == "~":
|
||||
return "builtin"
|
||||
# 虚拟文件路径(<frozen ...> / <built-in ...> / <string> 等)优先短路 —— 之前
|
||||
# 先 _norm 再判 < 是浪费 abspath,而且 "<..." 这种路径跟用户脚本路径无论如何
|
||||
# 都不可能相等,白调一次 norm。顺序调成「<... 优先」后 hot path 上少 100+
|
||||
# 次 _norm 调用(典型 scope=all 的 profile 里 <frozen>/<built-in> 帧占大头)。
|
||||
if file_path.startswith("<"):
|
||||
if file_path.startswith("<frozen"):
|
||||
return "frozen"
|
||||
if file_path.startswith("<built-in"):
|
||||
return "builtin"
|
||||
return "other"
|
||||
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
|
||||
return "user"
|
||||
|
||||
# sys.stdlib_module_names 校准:module_name 已由 _top_module 算好,
|
||||
# 直接问「这个包名是不是 stdlib」。
|
||||
if _STDLIB_MODULES is not None and module_name in _STDLIB_MODULES:
|
||||
return "stdlib"
|
||||
|
||||
# 路径兜底(兼容 3.9 + 处理 _STDLIB_MODULES 偶发漏判的边角包)
|
||||
norm = file_path.replace("\\", "/")
|
||||
if "site-packages" in norm or "dist-packages" in norm:
|
||||
return "third_party"
|
||||
# 必须锚到真正的 stdlib 根下 —— 之前只看 "Lib/" 子串会把用户项目里的
|
||||
# /home/x/myproject/Lib/foo.py 误认成 stdlib。
|
||||
# 锚点用 sysconfig.get_paths()['stdlib'] (官方权威,覆盖 venv/framework/embed),
|
||||
# 拿不到时回退到 sys.prefix + "Lib"。
|
||||
if _is_under_real_stdlib(file_path):
|
||||
return "stdlib"
|
||||
|
||||
return "other"
|
||||
|
||||
|
||||
def profile_and_measure(
|
||||
script_path: str,
|
||||
scope: str = "user",
|
||||
hide_internal: bool = True,
|
||||
code=None,
|
||||
src: str | None = None,
|
||||
) -> tuple:
|
||||
"""v4 单跑架构:一次 exec(code) under cProfile,同时拿到函数归因和 instrumented wall-time。
|
||||
|
||||
替代 v3 的两阶段执行(先裸跑测 wall_time 再 cProfile 跑):
|
||||
- 用户脚本只 exec 一次 → plot / print / file-write 等副作用只发生一次
|
||||
- 返回的 instrumented_wall 含 cProfile 自身开销(典型 1.5~3x 膨胀)
|
||||
- 调用方需要配合 calibrate_cprofile_overhead 折算:wall_time = instrumented / ratio
|
||||
|
||||
scope: "user"(默认)只归因用户脚本里的函数;"all" 包含所有非 cProfile 内部帧
|
||||
(标准库 + 第三方包 + 用户代码),让用户能下钻到 import 的包里。
|
||||
|
||||
hide_internal (v5 新增,默认 True):过滤掉测试代码 + tottime=0 的量化噪声帧。
|
||||
- 测试代码:tests/ / test_*.py / _pyrof_calib 等(见 _is_internal_test)
|
||||
- tottime=0:scope=all 时 typing / inspect / functools 等内部展开常被 cProfile 量化精度截到 0,
|
||||
这些不是优化目标,默认剔除能让 result.json 干净到「只剩真正在跑的代码」
|
||||
- 设为 False 时不过滤 —— 给想排查调用栈 / 自定义聚合的用户留一条后路
|
||||
|
||||
code / src:可选的预读 code object 和源码文本。runner.py 已经在做 syntax
|
||||
precheck 时 read + compile 过一份,这里直接复用 —— 避免重复 IO(原来
|
||||
profile_and_measure 自己又 read 两次 + AST parse 一次)。这两个参数
|
||||
给 None 时回退到「自己 load_source + compile」,供老调用方 / 单测继续工作。
|
||||
|
||||
NOTE: 不在 cProfile exec 周围禁用 GC —— cProfile 应该看到真实的执行环境
|
||||
(包括 GC 暂停),折算后的 wall-time 才能反映真实耗时。calibration 那两次
|
||||
tight-loop 跑各跑各的 GC 策略(详见 harness.calibrate_cprofile_overhead)。
|
||||
"""
|
||||
# 复用 caller 读好的 code / src —— runner.py 的 syntax precheck 已经 read+compile 过一次,
|
||||
# 再读一次等于把同样的字节流从磁盘捞 2 次 + AST parse 一次。None 时退回到旧的「自己读」路径。
|
||||
if code is None:
|
||||
code = _load_code(script_path)
|
||||
if src is None and hide_internal:
|
||||
src = load_source(script_path)
|
||||
g = _build_user_globals(script_path)
|
||||
saved_argv = _scrub_argv_for_user_code(script_path)
|
||||
pr = cProfile.Profile()
|
||||
pr.enable()
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
exec(code, g)
|
||||
instrumented = time.perf_counter() - t0
|
||||
finally:
|
||||
pr.disable()
|
||||
_restore_argv(saved_argv)
|
||||
|
||||
stats = pstats.Stats(pr)
|
||||
is_kept = _make_frame_filter(script_path, scope)
|
||||
# 规范化一次:每个函数帧都会把 file_path 与 script_path 比对;规范化结果
|
||||
# 与具体帧无关 —— 提到循环外,几百行的 functions 表能省几百次 abspath 调用。
|
||||
script_norm = _norm(script_path)
|
||||
# 解析用户脚本里显式 import 的模块集合。stdlib 内部帧如果来自「用户没
|
||||
# 显式 import 的包」(typing/inspect/functools/re/_py_warnings/...),
|
||||
# 一律视为 numpy/pandas 这类第三方包触发的「间接调用链」—— 用户无法优化,
|
||||
# 默认剔除。结果:scope=all 时 result.json 也只剩用户代码 + 显式导入的
|
||||
# 第三方包 + 真正大头的 stdlib 模块(json/os/etc.,用户写了 `import json`
|
||||
# 就看 json,否则不看)。
|
||||
# 之前无条件 ast.parse 整个 src —— 即便用户脚本 module 全是 "<user>"(scope=user)
|
||||
# 根本进不到这条 stdlib 过滤分支,几百行的 fixture 也走一遍 AST。改成只在真正会
|
||||
# 消费 user_imports 的组合里算(scope=all + hide_internal=True)。
|
||||
if hide_internal and scope == "all":
|
||||
user_imports = _extract_user_imports(src)
|
||||
else:
|
||||
user_imports = set()
|
||||
# file → (module, origin) 缓存:同一文件的多个函数帧(numpy 几百帧共享一个 file)
|
||||
# 只算一次 module + origin。_top_module 和 _classify_origin 各自又会再调一次
|
||||
# _norm(file),加 cache 后这两个调用也都省了 —— 典型 profile 5000 帧 / 50 文件,
|
||||
# _top_module 从 5000 次降到 50 次,_classify_origin 同。
|
||||
file_info_cache: dict[str, tuple] = {}
|
||||
def _classify_file(file_path: str) -> tuple:
|
||||
cached = file_info_cache.get(file_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
module = _top_module(file_path, script_path, script_norm)
|
||||
origin = _classify_origin(file_path, script_path, module, script_norm)
|
||||
cached = (module, origin)
|
||||
file_info_cache[file_path] = cached
|
||||
return cached
|
||||
|
||||
# ── v6 「用户直接调用」过滤 ──
|
||||
# 用户原话:「只要代码的本身和import 调用的耗时统计,其他的不需要」/「目前好像
|
||||
# 仍然统计到内部测试的代码了,不合理」—— 之前虽然过滤掉了 stdlib 内部噪声帧
|
||||
# (typing/inspect/functools 等),但 487 帧里仍有:
|
||||
# - numpy 内部 250+ 帧(np.array 调用的 _core.fromnumeric 等)→ 间接
|
||||
# - <frozen> importlib._bootstrap 90 帧 → 间接导入机制
|
||||
# - <built-in> C 函数 131 帧(len / numpy C 核 / _warnings / dict.keys 等)
|
||||
# - _distutils_hack 2 帧、mkl 7 帧 → setup machinery,非用户调用
|
||||
# 用户其实只要:
|
||||
# 1) 自己写的函数(模块名 == "<user>")
|
||||
# 2) 自己「直接调用」的 import 入口(np.array / np.mean / json.dumps 等)
|
||||
# 实现:cProfile 的 callers 字段自带调用方信息。「某帧的 caller 含用户脚本
|
||||
# 里的帧」=「用户直接调用」。再加 origin 闸门:仅 third_party / 用户显式 import
|
||||
# 的 stdlib 才算「import 调用」—— builtin / frozen / other / 没显式 import 的
|
||||
# stdlib 一律不保留,即使技术上确实被用户代码调用到(len / print / numpy C 核)。
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
functions = []
|
||||
if hide_internal:
|
||||
# Pass 1:收集用户帧 + 把通过基础过滤的帧的 (module, origin) 缓存下来。
|
||||
# 注意:这里不再做 tt <= 0 过滤 —— 用户代码 + import 入口里常有纯 C 分派的
|
||||
# 薄包装(np.random.rand / numpy.__getattr__ 等),cumtime 远大于 0 但 tottime
|
||||
# 恰好压在 cProfile 量化精度地板上,被滤掉就把"用户调用了哪个 API"这条信息丢了。
|
||||
# 用户代码 + import 入口自然就少,即便有 tt=0 也只是干净 user-function 占位,
|
||||
# 全保留就行。
|
||||
user_frames: set = set()
|
||||
func_info_cache: dict = {}
|
||||
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
|
||||
if not is_kept(func):
|
||||
continue
|
||||
file = func[0]
|
||||
module_name, origin = _classify_file(file)
|
||||
if _is_internal_test(func[2], file, module_name):
|
||||
continue
|
||||
func_info_cache[func] = (module_name, origin)
|
||||
if module_name == "<user>":
|
||||
user_frames.add(func)
|
||||
|
||||
# Pass 2:边判断「是不是 import_callee」边构造 FunctionNode(原版是分两个独立
|
||||
# pass 跑 import_callees 再跑 functions,合一遍能省掉一次 stats.stats 全量迭代。
|
||||
# import_callees 设单独 set 也不必要 —— 这里用 include 标志位本地决定,跳出本
|
||||
# 帧判断后立即 append / continue。
|
||||
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
|
||||
info = func_info_cache.get(func)
|
||||
if info is None:
|
||||
continue # pass 1 已过滤
|
||||
module_name, origin = info
|
||||
if func in user_frames:
|
||||
include = True
|
||||
else:
|
||||
include = False
|
||||
for caller in callers:
|
||||
if caller in user_frames:
|
||||
if origin == "third_party" or (
|
||||
origin == "stdlib" and module_name in user_imports
|
||||
):
|
||||
# 用户代码确实调到了这个 import 入口 —— 但还要看 ct:
|
||||
# 低于 cProfile 量化精度(1µs)的「被调到的帧」(典型:
|
||||
# numpy._mean_dispatcher 这种注册期被触发的辅助分发器,
|
||||
# cProfile 把调用方记成 <module> 但实际不干活)是噪声。
|
||||
# 用户代码写的空函数另算(user_frames 不受这条约束)。
|
||||
include = ct >= 1e-6
|
||||
break
|
||||
if not include:
|
||||
continue
|
||||
file = func[0]
|
||||
functions.append(
|
||||
FunctionNode(
|
||||
id=_fid(func),
|
||||
file=file,
|
||||
line=func[1],
|
||||
name=func[2],
|
||||
cumtime=ct,
|
||||
tottime=tt,
|
||||
ncalls=nc,
|
||||
percallTot=(tt / nc if nc else 0.0),
|
||||
module=module_name,
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# hide_internal=False:用户要的是全量原始数据 —— 不做 import_callee / 测试代码 / 零耗时过滤,
|
||||
# 直接把 is_kept 通过的帧全收下来。
|
||||
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
|
||||
if not is_kept(func):
|
||||
continue
|
||||
file = func[0]
|
||||
module_name, origin = _classify_file(file)
|
||||
functions.append(
|
||||
FunctionNode(
|
||||
id=_fid(func),
|
||||
file=file,
|
||||
line=func[1],
|
||||
name=func[2],
|
||||
cumtime=ct,
|
||||
tottime=tt,
|
||||
ncalls=nc,
|
||||
percallTot=(tt / nc if nc else 0.0),
|
||||
module=module_name,
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
|
||||
functions.sort(key=lambda f: f.tottime, reverse=True)
|
||||
total = sum(f.tottime for f in functions)
|
||||
flame = _build_flame(functions, total)
|
||||
return StructureResult(functions=functions, flame=flame), instrumented
|
||||
|
||||
|
||||
def profile_structure(
|
||||
script_path: str,
|
||||
scope: str = "user",
|
||||
hide_internal: bool = True,
|
||||
) -> StructureResult:
|
||||
"""Backward-compat shim:v3 时期暴露的「只拿归因、不读 wall-time」接口。
|
||||
|
||||
v4 起实际工作在 profile_and_measure 里完成;保留这个包装是为了不破坏直接
|
||||
import engine.structure.profile_structure 的测试 / 旧调用方。语义跟 v3 一样:
|
||||
只跑一次 cProfile exec、返回 functions + flame。
|
||||
"""
|
||||
result, _ = profile_and_measure(script_path, scope=scope, hide_internal=hide_internal)
|
||||
return result
|
||||
|
||||
|
||||
def _build_flame(functions: list, total: float) -> FlameNode:
|
||||
"""构造火焰图根节点。
|
||||
|
||||
火焰图第一层有两个 layout 选项:
|
||||
- 单模块(scope=user 或刚好只 import 一个包):保持扁平(函数列表),
|
||||
和 v2 之前完全一致,向后兼容。
|
||||
- 多模块(scope=all 且命中 ≥2 个不同的顶层模块):按 module 聚合——
|
||||
用户问「时间花在了哪个包」时第一眼就能看到;点模块 tile 下钻看内部函数。
|
||||
"""
|
||||
# 单模块时维持扁平 —— 同名兄弟不会被 module 节点挤占,截断阈值不变
|
||||
modules = {f.module for f in functions}
|
||||
if len(modules) <= 1:
|
||||
return FlameNode(
|
||||
name="root",
|
||||
value=total,
|
||||
children=[FlameNode(name=f.name, value=f.tottime) for f in functions],
|
||||
)
|
||||
|
||||
# 多模块:按 module 聚合;模块自身 value 是该模块下所有函数 tottime 之和
|
||||
by_module: dict = {}
|
||||
for f in functions:
|
||||
node = by_module.get(f.module)
|
||||
if node is None:
|
||||
node = FlameNode(name=f.module, value=0.0, children=[])
|
||||
by_module[f.module] = node
|
||||
node.value += f.tottime
|
||||
node.children.append(FlameNode(name=f.name, value=f.tottime))
|
||||
# 模块按总 tottime 降序,让最贵的包排最左(icicle 布局的视觉约定)
|
||||
sorted_modules = sorted(by_module.values(), key=lambda m: m.value, reverse=True)
|
||||
return FlameNode(name="root", value=total, children=sorted_modules)
|
||||
Reference in New Issue
Block a user