Files
FinDataHub 11d36d1c1e fix(repo): 修正 Scripts 目录索引大小写并补齐未入库脚本
问题
- 索引中该目录记为小写 `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 条。
2026-09-18 22:37:32 +08:00

295 lines
12 KiB
Python
Executable File
Raw Permalink 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.
#!/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())