Files
Python-Profiler-Visualizer/engine/runner.py
2026-09-12 14:19:56 +08:00

257 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import io
import shutil
import sys
import tempfile
import traceback
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
from engine.schema import SCHEMA_VERSION, AnalysisResult, Calibration, FlameNode, WallTime
from engine.structure import profile_and_measure
# Result 文件大小防护(防用户脚本异常把磁盘撑爆)
# - error.message 上限 1KB:1KB 一般足够覆盖正常异常说明
# - error.traceback 上限 8KB:100 帧左右 traceback 的体量,UI 可读范围
# - functions 列表上限 5000:UI 表格默认只看前 200,再多只是给后续解析加负载
_MAX_ERROR_MESSAGE_BYTES = 1024
_MAX_TRACEBACK_BYTES = 8 * 1024
_MAX_FUNCTIONS = 5000
def _truncate_text(text: str, max_bytes: int) -> str:
"""把文本按 UTF-8 字节截断到 max_bytes 以内,补一个明确截断标记。
直接 [:max_chars] 在多字节字符中间切会破坏 UTF-8,UI 解析挂。encode → 截
→ 走 errors='ignore' 让不完整尾部自然掉落 → decode,保证结果仍是合法 UTF-8。
"""
if not text:
return text
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= max_bytes:
return text
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
# 留 64 字节给标记(总长仍 ≤ max_bytes)
suffix = f"\n\n[... 截断:共 {len(encoded)} 字节,仅保留前 {max_bytes} 字节 ...]"
# 如果 suffix 太长导致总超,再砍一次
return (truncated + suffix)[:max_bytes]
def _cap_functions(functions: list, flame: FlameNode | None) -> tuple:
"""截断 functions 列表到 _MAX_FUNCTIONS,并把超出部分累加到 flame 的 root。
为什么不只截 functions 不动 flame?
- flame 是 UI 看到的"时间分布",丢了 functions 里那些小函数会让 flame
总和 < 用户报的总耗时,UI 显示出现 100% 但 sum 不到 80% 的诡异情况。
- 把被丢函数的 tottime 加到 root.value 上,UI 那边仍按 flame 内部占比显示,
多出来的部分归到 root 节点(最大最宽的 tile,用户视觉上只是「其他」变
大了一点 —— 总和不撒谎。
"""
if len(functions) <= _MAX_FUNCTIONS:
return functions, flame
kept = functions[:_MAX_FUNCTIONS]
dropped_tottime = sum(f.tottime for f in functions[_MAX_FUNCTIONS:])
if flame is None:
flame = FlameNode(name="root", value=dropped_tottime, children=[])
else:
flame.value += dropped_tottime
return kept, flame
def _compute_wall_time(instrumented_sec: float, calibration: Calibration) -> float:
"""用校准 ratio 折算 instrumented 时间为估计的干净耗时。
思路:cProfile 是插桩式的,wall-time 会被膨胀 ratio 倍(instrumented / clean);
用同一段 tight-loop 测出 ratio 后,把用户的 instrumented 时间除回去,就是
「去掉 cProfile 开销后的估计耗时」。
边界:calibration.ratio 是 0 / 负数 / NaN 时直接信 instrumented 时间 —— 跟
calibrate_cprofile_overhead 在「校准耗时过短」时回退 ratio=1.0 的兜底策略
保持一致,UI 显示真实测得的 instrumented 时间而不是除以无效值。
"""
ratio = calibration.ratio
# `ratio == ratio` 排除 NaN;`ratio > 0` 排除 0 / 负数
if ratio == ratio and ratio > 0:
return instrumented_sec / ratio
return instrumented_sec
def _cap_result(result: AnalysisResult) -> AnalysisResult:
"""给 result.json 上一道防线,防止异常情况把磁盘/IPC 通道撑爆。
触发场景:
- 用户代码 raise 一个带 50MB 文件内容的 ValueError → error.message 撑爆
- 用户代码触发深递归异常,Python 自动 dump 几千层 traceback → 撑爆
- 用户脚本 import 一个庞大的库(numpy/pandas)scope=all 把整棵树都归因 →
38MB+ 的 functions 列表
截断而非拒绝:UI 仍能看到「有错」「发生了什么」,只是具体细节截断;functions
截断后仍按 tottime 排序,前 5000 一定是最值得用户关注的热点。
"""
if result.error:
msg = result.error.get("message")
if isinstance(msg, str) and msg:
result.error["message"] = _truncate_text(msg, _MAX_ERROR_MESSAGE_BYTES)
tb = result.error.get("traceback")
if isinstance(tb, str) and tb:
result.error["traceback"] = _truncate_text(tb, _MAX_TRACEBACK_BYTES)
if result.functions:
result.functions, result.flame = _cap_functions(result.functions, result.flame)
return result
def _progress(phase, pct):
# 前导 \ntqdm / 进度条类库会写"working..."到 stderr 但不换行,
# 直接接 PROGRESS 会拼成 "working...PROGRESS running 10",主进程正则匹配挂、
# 进度事件掉一档且把工程协议行混进 stderrTail 让用户看到 \r 进度条残片。
print(f"\nPROGRESS {phase} {pct}", file=sys.stderr, flush=True)
def _emit(result: AnalysisResult, out_path=None):
"""发出结果。
out_path 给定时结果写入独立文件stdout/stderr 就完整留给用户代码 ——
否则用户代码里一句 sys.stdout.write("done") 或 print(..., end='') 就会和
JSON 挤在同一行,主进程按"最后一行"取 JSON 时解析失败,
用户看到的却是"结果解析失败"。不给 out_path 时退回 stdout直接跑 CLI / 老测试)。
"""
payload = result.to_json()
if out_path:
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(payload)
else:
print(payload, flush=True)
def _base(env, config, status, error=None, wall_time=None, functions=None, flame=None, calibration=None):
return AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=env,
config=config,
status=status,
error=error,
wallTime=WallTime(seconds=wall_time, unit="s") if wall_time is not None else None,
functions=functions or [],
flame=flame,
calibration=calibration,
)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--script", required=True)
ap.add_argument("--out", default=None,
help="结果 JSON 的输出文件;不给则打到 stdout")
ap.add_argument("--scope", choices=["user", "all"], default="user",
help="剖析范围user默认只归因用户脚本里的函数"
"all 包含所有非 cProfile 内部帧(标准库 + 第三方包 + 用户代码),"
"让耗时可以下钻到 import 的包里。")
ap.add_argument("--workdir", default=None,
help="校准文件落盘的临时目录;不给则 runner 自己 mkdtemp。"
"profiler-service 一般传过来(它创建的 pyprof-xxx),"
"直跑 CLI 不传也行。")
ap.add_argument("--hide-internal", dest="hide_internal",
action=argparse.BooleanOptionalAction, default=True,
help="过滤掉测试代码(tests/ / test_*.py / _pyrof_calib 等)+ "
"tottime=0 的量化噪声帧。默认 on,用户原话:「软件内部的测试部分"
"默认百分百过滤掉」。--no-hide-internal 关掉这条过滤,看完整数据。")
args = ap.parse_args(argv)
# 统一走这个 emit结果去 --out 指定的文件stdout 留给用户代码。
# 每条 emit 之前过 _cap_result 给 result.json 上一道防线 —— error.traceback /
# error.message / functions 列表都设硬上限,防止用户异常把磁盘 / IPC 撑爆。
def emit(result):
_emit(_cap_result(result), args.out)
env = collect_environment()
config: dict = {}
# 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御——
# 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。
# 低于 3.9 时后面的 list[X] 泛型注解会直接 SyntaxError拿不到结构化错误。
if sys.version_info < (3, 9):
got = ".".join(str(p) for p in sys.version_info[:3])
emit(_base(env, config, "runtime_error",
error={"type": "PythonTooOld",
"message": f"引擎需要 Python 3.9 或更高版本,当前为 {got}"}))
return
# 一次 IO 拿到 src语法预检和后续 profile 都用这份文本
# 之前没 try/except临时目录被删 / 路径权限不够 → 进程崩,无 JSON 输出 → UI 显示 "引擎无输出"
# UnicodeDecodeError 不是 OSError 的子类:二进制文件 / 非 UTF-8 脚本会绕过这层保护再次崩溃。
try:
src = load_source(args.script)
except (OSError, UnicodeDecodeError) as e:
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": f"无法读取脚本: {e}"}))
return
# Syntax precheck (用文本而非再读一次)
# compile() 还会在源码含 null 字节时抛 ValueError也一并按 syntax_error 归类
# 把 code object 留起来 —— profile_and_measure 还需要再 exec 一次,
# 直接复用这份省一次 read + compile。
try:
code = compile(src, args.script, "exec")
except (SyntaxError, ValueError) as e:
emit(_base(env, config, "syntax_error", error={"type": type(e).__name__, "message": str(e)}))
return
# v4 单跑架构:校准 + 用户代码 under cProfile 一次拿到归因 + instrumented 时间。
# 跟 v3 的「先裸跑测 wall_time 再 cProfile 跑」相比,
# 用户脚本的副作用(plot / file write / etc)只发生一次,语义更直观。
# workdir 优先用 caller 传的(profiler-service 已经 mkdir pyprof-xxx 落 _pyrof_calib.py
# 和用户脚本);caller 不传则自己 mkdtemp —— 这种情况跑完自己清掉。
workdir = args.workdir
workdir_owned = False
if not workdir:
workdir = tempfile.mkdtemp(prefix="pyprof-")
workdir_owned = True
try:
try:
_progress("calibrating", 5)
# 校准脚本落在 caller 提供的 workdir 里 —— profiler-service 已经 mkdir
# pyrof-xxx 装用户脚本,把 _pyrof_calib.py 放一起能让 rmtree(workdir) 一次
# 清干净。runner 自己 mkdtemp 时(直跑 CLI 场景)workdir_owned=True,
# calibration 拿到的是同一个目录,临时文件一起被 finally 兜底删。
calibration = calibrate_cprofile_overhead(workdir)
_progress("running", 30)
st, instrumented = profile_and_measure(
args.script, scope=args.scope, hide_internal=args.hide_internal,
code=code, src=src,
)
wall_time = _compute_wall_time(instrumented, calibration)
_progress("done", 100)
emit(_base(env, config, "ok",
wall_time=wall_time,
functions=st.functions,
flame=st.flame,
calibration=calibration))
return
except SystemExit as e:
# sys.exit() 抛 SystemExitBaseException 的子类)—— 不被下面的 except BaseException 捕获,
# 子进程直接退出无 JSONUI 就会显示 "引擎无输出 (exit N)"。转成 runtime_error。
# e.code 默认 None无参 sys.exit()),文案要单独处理,否则会出现"sys.exit(None)"
code = e.code if isinstance(e.code, int) else (0 if e.code is None else f"非整数 {e.code!r}")
emit(_base(env, config, "runtime_error",
error={"type": "SystemExit", "message": f"用户代码调用 sys.exit({code})"}))
except BaseException as e:
# KeyboardInterrupt / GeneratorExit / 自定义 BaseException 子类 以及
# 普通 Exception 一并兜底 —— 之前再写一条 `except Exception` 是 dead code
# BaseException 是 Exception 的父类,先匹配的赢)。父进程端有 cancel +
# 进程树 kill 在管 Ctrl+C,这里只负责兜底写 JSON。
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": str(e),
"traceback": traceback.format_exc()}))
finally:
# runner 自己 mkdtemp 的目录要自己清 —— profiler-service 已经传 --workdir 时
# 整个 workdir 由 caller 管(主进程清理临时目录的逻辑在 before-quit),这里只兜
# 「直接 python -m engine.runner」的直跑场景。
if workdir_owned:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()