This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

576
engine/structure.py Normal file
View 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 shimv3 时期暴露的「只拿归因、不读 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)