问题 - 索引中该目录记为小写 `scripts/`,工作区实为 `Scripts/`。因 core.ignorecase=true, 本地 `git diff -- Scripts/bin/x.py` 会**静默返回空**(看似无改动), 且在大小写敏感环境(Linux CI / Docker 挂载)会检出 `scripts/`, 与全仓所有 `Scripts/...` 引用不匹配。 - 该目录此前 10 个脚本(check_python_version.py、perf_snapshot.py、 install_collector_launchd.py、mysql_toggle.sh 等)从未入库, 而 CI 第 31 行正调用 `Scripts/bin/check_python_version.py --strict` —— 全新检出必然失败。 改动 - 索引路径统一为 `Scripts/`(工作区文件未移动,仅索引改名)。 - 补齐上述 10 个未跟踪脚本及其测试。 影响 - 修正前索引 35 条(全为小写路径),修正后 45 条。
295 lines
12 KiB
Python
Executable File
295 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
FinDataHub 性能快照 CLI
|
||
========================
|
||
|
||
用途
|
||
----
|
||
测量 FinDataHub 及其配套服务(快讯采集/后端、MySQL、Ollama)的 CPU、内存、线程、
|
||
上下文切换、系统调用、缺页速率。**不依赖 ps / top**(WorkBuddy 沙箱下这两个命令会被
|
||
拒绝),直接通过 libproc 的 proc_pidinfo 读取内核任务统计,无需 root。
|
||
|
||
用法
|
||
----
|
||
python3 perf_snapshot.py # 10 秒窗口,汇总模式
|
||
python3 perf_snapshot.py -w 30 # 30 秒窗口
|
||
python3 perf_snapshot.py -g 1 -n 32 # 1 秒粒度 × 32 次,观察突发模式
|
||
python3 perf_snapshot.py --json # JSON 输出,便于存档对比
|
||
python3 perf_snapshot.py --baseline # 打印本项目改造前后的基线供对照
|
||
|
||
输出解读
|
||
--------
|
||
- **UNIX/s**:每秒 UNIX 系统调用数。这是「唤醒频率」的核心指标——CPU 接近 0 但该项
|
||
很高,说明进程在不断被唤醒做零碎工作,会持续阻断 CPU 深度 C-state 与 App Nap,
|
||
是电池续航的主要杀手。
|
||
- **faults/s**:每秒缺页次数。持续偏高说明有频繁的内存分配/回收(如反复创建
|
||
URLSession、JSON 解析、派生子进程)。
|
||
- **CSW/s**:每秒上下文切换。与 UNIX/s 同向,反映唤醒密度。
|
||
|
||
基线(2026-08-30 实测,非交易时段)
|
||
-----------------------------------
|
||
FinDataHub 主进程改造前:UNIX 503.2/s、峰值 2503、faults 38.6/s、RSS 100.1 MB
|
||
FinDataHub 主进程改造后:UNIX 15.8/s、峰值 15、faults 0.7/s、RSS 77.8 MB
|
||
(改造 = nc 子进程改 socket 探测 + Ollama 端口预检缓存 + 定时器 tolerance + 泄漏修复)
|
||
|
||
注意
|
||
----
|
||
- 交易时段(09:15–15:05)采集器高频运行,数值会显著高于非交易时段,
|
||
两者不可直接比较。建立基线时请固定在同一时段。
|
||
- proc_pidpath 对 Python 脚本进程返回的是解释器路径,因此本脚本用 pgrep 按命令行
|
||
匹配目标进程,再按 PID 采样。
|
||
"""
|
||
|
||
import argparse
|
||
import ctypes
|
||
import ctypes.util
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
|
||
# ── libproc 绑定 ────────────────────────────────────────────────────────────
|
||
|
||
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||
|
||
PROC_PIDTASKINFO = 4
|
||
|
||
|
||
class proc_taskinfo(ctypes.Structure):
|
||
"""对应 <sys/proc_info.h> 的 struct proc_taskinfo(macOS arm64)"""
|
||
_fields_ = [
|
||
("pti_virtual_size", ctypes.c_uint64),
|
||
("pti_resident_size", ctypes.c_uint64),
|
||
("pti_total_user", ctypes.c_uint64), # 纳秒
|
||
("pti_total_system", ctypes.c_uint64), # 纳秒
|
||
("pti_threads_user", ctypes.c_uint64),
|
||
("pti_threads_system", ctypes.c_uint64),
|
||
("pti_policy", ctypes.c_int32),
|
||
("pti_faults", ctypes.c_int32),
|
||
("pti_pageins", ctypes.c_int32),
|
||
("pti_cow_faults", ctypes.c_int32),
|
||
("pti_messages_sent", ctypes.c_int32),
|
||
("pti_messages_received", ctypes.c_int32),
|
||
("pti_syscalls_mach", ctypes.c_int32),
|
||
("pti_syscalls_unix", ctypes.c_int32),
|
||
("pti_csw", ctypes.c_int32),
|
||
("pti_threadnum", ctypes.c_int32),
|
||
("pti_numrunning", ctypes.c_int32),
|
||
("pti_priority", ctypes.c_int32),
|
||
]
|
||
|
||
|
||
_libc.proc_pidinfo.restype = ctypes.c_int
|
||
_libc.proc_pidinfo.argtypes = [
|
||
ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int
|
||
]
|
||
|
||
# 进程匹配表:(显示名, pgrep 模式, 命令行)
|
||
# -x 精确匹配进程名(适用于有独立可执行文件名的守护进程)
|
||
# -f 匹配完整命令行(适用于 Python 脚本,其进程名统一是 "Python")
|
||
# 注意:MySQL/Ollama 若用 -f 模糊匹配会命中命令行里含该字样的无关进程,
|
||
# 例如曾把某个 1.1 MB 的进程误认为 mysqld。
|
||
TARGETS = [
|
||
("FinDataHub 主进程", "-x", "FinDataHub"),
|
||
("快讯后端", "-f", "newsflash_backend.py"),
|
||
("快讯采集器", "-f", "newsflash_collect.py"),
|
||
("MySQL", "-x", "mysqld"),
|
||
("Ollama", "-x", "ollama"),
|
||
]
|
||
|
||
|
||
def taskinfo(pid):
|
||
"""读取单个进程的任务统计;进程已退出返回 None"""
|
||
ti = proc_taskinfo()
|
||
n = _libc.proc_pidinfo(pid, PROC_PIDTASKINFO, 0,
|
||
ctypes.byref(ti), ctypes.sizeof(ti))
|
||
return ti if n == ctypes.sizeof(ti) else None
|
||
|
||
|
||
def discover():
|
||
"""定位目标进程,返回 [(显示名, pid, 命令行片段)]"""
|
||
found = []
|
||
for name, flag, pattern in TARGETS:
|
||
# -f 需配合 -l 才会输出命令行(否则只输出 PID,无法解析);
|
||
# -x 只输出 PID,不加 -l。
|
||
argv = ["pgrep", "-l", flag, pattern] if flag == "-f" \
|
||
else ["pgrep", flag, pattern]
|
||
try:
|
||
p = subprocess.run(argv, capture_output=True, text=True, timeout=5)
|
||
lines = [ln for ln in p.stdout.strip().splitlines() if ln.strip()]
|
||
except Exception:
|
||
lines = []
|
||
|
||
if not lines:
|
||
continue
|
||
|
||
if flag == "-f":
|
||
# pgrep -f 输出 "PID 命令行"
|
||
parts = lines[0].split(None, 1)
|
||
if len(parts) != 2:
|
||
continue
|
||
try:
|
||
pid = int(parts[0])
|
||
except ValueError:
|
||
continue
|
||
found.append((name, pid, parts[1][:60]))
|
||
else:
|
||
# pgrep -x 只输出 PID
|
||
try:
|
||
pid = int(lines[0].strip())
|
||
except ValueError:
|
||
continue
|
||
found.append((name, pid, pattern))
|
||
return found
|
||
|
||
|
||
def diff_row(name, a, b, window):
|
||
"""按窗口计算速率"""
|
||
cpu = ((b.pti_total_user + b.pti_total_system)
|
||
- (a.pti_total_user + a.pti_total_system)) / 1e9 / window * 100
|
||
return {
|
||
"name": name,
|
||
"cpu_pct": round(cpu, 2),
|
||
"rss_mb": round(b.pti_resident_size / 1048576, 1),
|
||
"threads": b.pti_threadnum,
|
||
"csw_per_s": round((b.pti_csw - a.pti_csw) / window, 1),
|
||
"unix_syscalls_per_s": round((b.pti_syscalls_unix - a.pti_syscalls_unix) / window, 1),
|
||
"mach_syscalls_per_s": round((b.pti_syscalls_mach - a.pti_syscalls_mach) / window, 1),
|
||
"faults_per_s": round((b.pti_faults - a.pti_faults) / window, 1),
|
||
}
|
||
|
||
|
||
# ── 模式 ────────────────────────────────────────────────────────────────────
|
||
|
||
def run_summary(procs, window):
|
||
before = {pid: taskinfo(pid) for _, pid, _ in procs}
|
||
time.sleep(window)
|
||
rows = []
|
||
for name, pid, cmd in procs:
|
||
a, b = before[pid], taskinfo(pid)
|
||
if not a or not b:
|
||
continue
|
||
r = diff_row(name, a, b, window)
|
||
r["pid"] = pid
|
||
rows.append(r)
|
||
return rows
|
||
|
||
|
||
def run_spike(procs, granularity, count):
|
||
"""细粒度采样,观察突发模式"""
|
||
target = procs[0] if procs else None
|
||
if not target:
|
||
return [], []
|
||
name, pid, _ = target
|
||
series = []
|
||
prev = taskinfo(pid)
|
||
peak = 0
|
||
for _ in range(count):
|
||
time.sleep(granularity)
|
||
cur = taskinfo(pid)
|
||
if not prev or not cur:
|
||
break
|
||
d = cur.pti_syscalls_unix - prev.pti_syscalls_unix
|
||
peak = max(peak, d)
|
||
series.append({
|
||
"t": len(series) + 1,
|
||
"unix": d,
|
||
"faults": cur.pti_faults - prev.pti_faults,
|
||
})
|
||
prev = cur
|
||
return series, (name, pid, peak)
|
||
|
||
|
||
def print_table(rows):
|
||
hdr = f"{'进程':<16}{'PID':>8}{'CPU%':>7}{'RSS(MB)':>10}{'线程':>6}" \
|
||
f"{'CSW/s':>9}{'UNIX/s':>9}{'MACH/s':>9}{'faults/s':>10}"
|
||
print(hdr)
|
||
print("-" * 84)
|
||
for r in rows:
|
||
print(f"{r['name']:<16}{r['pid']:>8}{r['cpu_pct']:>7.2f}{r['rss_mb']:>10.1f}"
|
||
f"{r['threads']:>6}{r['csw_per_s']:>9.1f}{r['unix_syscalls_per_s']:>9.1f}"
|
||
f"{r['mach_syscalls_per_s']:>9.1f}{r['faults_per_s']:>10.1f}")
|
||
print("-" * 74)
|
||
print(f"{'合计':<16}{'':>8}{sum(r['cpu_pct'] for r in rows):>7.2f}"
|
||
f"{'':>26}{sum(r['unix_syscalls_per_s'] for r in rows):>9.1f}")
|
||
|
||
|
||
BASELINE = """
|
||
改造前后基线对照(2026-08-30 实测,非交易时段,10 秒窗口)
|
||
──────────────────────────────────────────────────────────
|
||
指标 改造前 改造后(稳态) 变化
|
||
UNIX 系统调用/s 503.2 15.8 −97%
|
||
突发峰值 2503 15 −99%
|
||
上下文切换/s 57.3 5.5 −90%
|
||
缺页/s 38.6 0.7 −98%
|
||
RSS 100.1 MB 77.8 MB −22 MB
|
||
CPU 0.04% 0.00% —
|
||
|
||
改造内容:
|
||
1. ServiceMonitor 每 30s 派生 /usr/bin/nc 子进程 → 纯 Swift socket 探测(enum TcpProbe)
|
||
2. Ollama 探测加 socket 端口预检 + 结果缓存(TTL 300s)
|
||
3. DebugView 的 Timer.scheduledTimer 未持有引用、永不 invalidate → 改 SwiftUI 托管
|
||
4. LogArchiveView 5s 定时器加 tolerance
|
||
5. build-app.sh 清理陈旧产物 + 精简 venv dev 依赖
|
||
6. Package.swift release 开启 -cross-module-optimization
|
||
|
||
⚠ 交易时段(09:15–15:05)采集器高频运行,数值会显著高于上述非交易时段基线,
|
||
两者不可直接比较。建立基线时请固定在同一时段。
|
||
"""
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description="FinDataHub 性能快照(基于 libproc,无需 ps/top/root)")
|
||
ap.add_argument("-w", "--window", type=float, default=10.0,
|
||
help="汇总模式采样窗口秒数(默认 10)")
|
||
ap.add_argument("-g", "--granularity", type=float, default=0,
|
||
help="突发模式粒度秒数;给定时启用细粒度采样(如 1)")
|
||
ap.add_argument("-n", "--count", type=int, default=30,
|
||
help="突发模式采样次数(默认 30)")
|
||
ap.add_argument("--json", action="store_true", help="JSON 输出")
|
||
ap.add_argument("--baseline", action="store_true", help="打印改造前后基线并退出")
|
||
args = ap.parse_args()
|
||
|
||
if args.baseline:
|
||
print(BASELINE)
|
||
return 0
|
||
|
||
procs = discover()
|
||
if not procs:
|
||
print("未发现任何 FinDataHub 相关进程。", file=sys.stderr)
|
||
return 1
|
||
|
||
if args.granularity > 0:
|
||
series, (name, pid, peak) = run_spike(procs, args.granularity, args.count)
|
||
if args.json:
|
||
print(json.dumps({"target": name, "pid": pid, "peak": peak,
|
||
"series": series}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
print(f"突发模式 — {name} (PID {pid}),{args.granularity}s × {args.count}")
|
||
print(f"{'t(s)':>5}{'ΔUNIX':>8}{'Δfault':>8} 分布")
|
||
for s in series:
|
||
bar = "#" * min(int(s["unix"] / 20), 40)
|
||
print(f"{s['t']:>5}{s['unix']:>8}{s['faults']:>8} {bar}")
|
||
print(f"\n峰值 {peak};基线参考:改造前 2503,改造后 15")
|
||
return 0
|
||
|
||
rows = run_summary(procs, args.window)
|
||
if args.json:
|
||
print(json.dumps({"window_s": args.window,
|
||
"at": datetime.now().isoformat(timespec="seconds"),
|
||
"processes": rows}, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
print(f"FinDataHub 性能快照 — {datetime.now():%Y-%m-%d %H:%M:%S},"
|
||
f"{args.window} 秒窗口")
|
||
print_table(rows)
|
||
print("\n提示:加 --baseline 查看改造前后对照;"
|
||
"加 -g 1 -n 32 观察突发模式。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|