From 27732e4ae5401eec164e96ecc259f3ad59419013 Mon Sep 17 00:00:00 2001 From: Geeshinu Date: Thu, 15 Oct 2026 10:51:05 +0800 Subject: [PATCH] Move measurement reports and experiment tools to MacroLab Reports, analysis scripts and probes that nothing in the product build depends on now live in https://github.com/NotBadSlime/MacroLab. scripts/compare_frame_times.py and tests/test_frame_comparison.py stay because CI and docs/precision.md use them. Links in five documents now point at the new location. Co-authored-by: Cursor --- docs/condition-scheduling-improvements.md | 2 +- docs/current-precision-status.md | 74 -- docs/precision-audit-2026-10-10.md | 27 - docs/precision.md | 4 +- docs/release-1.0.0.md | 2 +- docs/releases/2.0.0.md | 2 +- docs/releases/2.0.1.md | 2 +- ...scheduler-comprehensive-decision-report.md | 287 ------ .../macro-scheduler-deep-dive-report.md | 972 ------------------ .../macro-scheduling-research-2026-10-14.md | 369 ------- docs/single-macro-optimization-2026-10-14.md | 87 -- ...le-macro-tail-and-end-to-end-2026-10-14.md | 126 --- .../2026-10-14-scheduling-latency-phases.md | 53 - scripts/summarize_end_to_end.py | 73 -- scripts/summarize_single_macro.py | 91 -- scripts/summarize_tail_comparison.py | 63 -- src/tools/EndToEndProbe/EndToEndProbe.csproj | 16 - src/tools/EndToEndProbe/NativeReceiver.cs | 115 --- src/tools/EndToEndProbe/Program.cs | 294 ------ .../FufuTargetProbe/FufuTargetProbe.csproj | 3 - src/tools/FufuTargetProbe/Program.cs | 82 -- src/tools/FufuTargetProbe/app.manifest | 2 - .../MacroHid.Scheduling.Benchmarks.csproj | 1 - .../MacroHid.Scheduling.Benchmarks/Program.cs | 56 - 24 files changed, 6 insertions(+), 2797 deletions(-) delete mode 100644 docs/current-precision-status.md delete mode 100644 docs/precision-audit-2026-10-10.md delete mode 100644 docs/research/macro-scheduler-comprehensive-decision-report.md delete mode 100644 docs/research/macro-scheduler-deep-dive-report.md delete mode 100644 docs/research/macro-scheduling-research-2026-10-14.md delete mode 100644 docs/single-macro-optimization-2026-10-14.md delete mode 100644 docs/single-macro-tail-and-end-to-end-2026-10-14.md delete mode 100644 docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md delete mode 100644 scripts/summarize_end_to_end.py delete mode 100644 scripts/summarize_single_macro.py delete mode 100644 scripts/summarize_tail_comparison.py delete mode 100644 src/tools/EndToEndProbe/EndToEndProbe.csproj delete mode 100644 src/tools/EndToEndProbe/NativeReceiver.cs delete mode 100644 src/tools/EndToEndProbe/Program.cs delete mode 100644 src/tools/FufuTargetProbe/FufuTargetProbe.csproj delete mode 100644 src/tools/FufuTargetProbe/Program.cs delete mode 100644 src/tools/FufuTargetProbe/app.manifest delete mode 100644 tests/MacroHid.Scheduling.Benchmarks/MacroHid.Scheduling.Benchmarks.csproj delete mode 100644 tests/MacroHid.Scheduling.Benchmarks/Program.cs diff --git a/docs/condition-scheduling-improvements.md b/docs/condition-scheduling-improvements.md index c2c5a07..ab900f8 100644 --- a/docs/condition-scheduling-improvements.md +++ b/docs/condition-scheduling-improvements.md @@ -47,6 +47,6 @@ 3ms 间隔误差 P95 略增,未放宽现有时序断言。额外 3000 轮持续测试中,1000 至 3000 轮线程数保持 15,句柄 300 至 301,私有内存 28.7 至 29.1MiB,未观察到持续增长;这些值在显式 GC 后采样,不代表长期无泄漏的证明。Skirk FPS 回归也全部通过。 -性能原始记录在 `tmp/timing-before.log`、`tmp/timing-after.log`、`tmp/timing-stress.log`,复现探针在 `tests/MacroHid.Scheduling.Benchmarks`。以 `dotnet run --project tests/MacroHid.Scheduling.Benchmarks -c Release -p:RuntimeRoot=<待测运行库目录> -- 300` 运行;目录须包含 Core、Runtime DLL。基准避免并发运行以减少负载干扰。实际游戏及设备兼容性仍需目标环境验证;GitHub 发布附件不由本地打包流程更新。 +性能原始记录在 `tmp/timing-before.log`、`tmp/timing-after.log`、`tmp/timing-stress.log`,复现探针已迁到 MacroLab 仓库的 `tests/MacroHid.Scheduling.Benchmarks`。以 `dotnet run --project tests/MacroHid.Scheduling.Benchmarks -c Release -p:RuntimeRoot=<待测运行库目录> -- 300` 运行;目录须包含 Core、Runtime DLL。基准避免并发运行以减少负载干扰。实际游戏及设备兼容性仍需目标环境验证;GitHub 发布附件不由本地打包流程更新。 本次安装包、便携包和自包含本地运行版均已成功构建,固定路径与 Start Item 快捷方式已核对;便携包与本地版的 Core、Runtime、UI DLL 哈希一致。构建保留原有 NetLimiter 5.3.18 的 NU1701 框架兼容性警告,无编译错误。 diff --git a/docs/current-precision-status.md b/docs/current-precision-status.md deleted file mode 100644 index 4cb849a..0000000 --- a/docs/current-precision-status.md +++ /dev/null @@ -1,74 +0,0 @@ -# MacroHID 当前实现与精度验证状态 - -更新日期:2026-10-14。原双宏记录保留 2026-10-13 的测量口径。 - -最新单宏补充见 [尾部优化与真实接收实测](single-macro-tail-and-end-to-end-2026-10-14.md):极限档固定核五轮十万事件,最大迟到对照 1,230.4µs、保留方案 893.4µs;物理热键 30 次到独立 Win32 接收窗口,中位 3.461ms、最大 8.708ms。最终版本另有 Win32/WPF 各 2,000 次自动真实输入接收,全部有效。托管回归 388 项通过。仍不能声称微秒级端到端或硬实时最大值保证。 - -三档策略、准备缓存、共享双线程原生调度、图像缓冲优化、界面状态与验收工具已实现。本轮功能回归通过;真实热键端到端、真实 SendInput 接收延迟及游戏帧时间联合验收尚未完成。以下数据不能视为硬实时保证,也不能推广到未测试设备或游戏。 - -## 2026-10-14 单宏增量 - -新增单宏连续执行、修饰键边沿预计算、统计缓冲复用和均衡档有界自旋适应;极限定时事件保留辅助线程。单宏五轮交替对照,每方案每档累计十万假输入事件:均衡逐轮 P99.9 为 12.3–46.9µs,全部样本最大 1428.3µs;极限为 2.7–3.6µs,最大 287.9µs。均衡 CPU 代价增加,真实接收与游戏帧时间尚未验收。当前 Release 构建成功,托管测试 388 项和原生回归通过。完整参数、基线、逐轮数据、回退与本地测试版见 [单宏优化记录](single-macro-optimization-2026-10-14.md)。 - -## 2026-10-13 环境与功能验证 - -参考机器:AMD Ryzen 9 7945HX,16 个物理核心、32 个逻辑处理器;Windows 11 build 26200;.NET SDK 8.0.416、运行时 8.0.22。未建立固定游戏、温度、电源和 CPU/GPU 负载矩阵。 - -| 检查 | 结果 | -|---|---| -| Release 原生 DLL 构建 | 成功,0 错误 | -| Release 解决方案构建 | 成功,0 错误,14 个现有依赖兼容性/平台警告 | -| 托管功能、语义及回归测试 | 386 项通过 | -| 原生假输入调度回归 | 并发、暂停、取消、资源保留、重复提交、部分失败、退出重启通过 | -| 帧时间比较器测试 | 3 项通过 | - -新安装默认均衡,已有枚举和序列化值保留。运行使用启动快照。新路径不提升进程优先级,不修改进程亲和性;极限仅绑定原生执行线程。原生工作线程就绪后才宣告准备完成;运行中的未缓存测核配置会推迟准备并显示原因,停止后重新监听可重新准备。 - -## 原生调度实测 - -最终验证使用两个并发计划,每个 50000 个批次,1ms 节奏,每档共 100000 个有效事件。使用测试专用假输入回调,不调用真实 SendInput;预热后采样,探针申请与托管播放会话一致的 0.5ms 计时器分辨率。以下两列分位数分别对应两个计划,不是重复试验。 - -| 档位 | 目标 P99.9 | 计划 1 / 2 的 P99.9 迟到量 | 计划 1 / 2 的最大迟到量 | 本次调度探针 | -|---|---:|---:|---:|---| -| 均衡 | ≤250µs | 186 / 4µs | 1094 / 876µs | 达到分位数目标 | -| 极限 | ≤100µs | 2 / 2µs | 644 / 1071µs | 达到分位数目标 | - -均衡两个计划的长尾差异较大;不能用较小的一列代替整体情况。毫秒级最大迟到仍存在。每档当前只有一次最终版本探针,不满足完整性能验收的重复次数和场景要求。 - -此前独立原生探针未申请播放会话的计时器分辨率,均衡 P99.9 为 459 / 151µs,第一路未达标,最大值为 1018 / 857µs。补齐会话条件后的前一次复测为 115 / 81µs;该次与最终版本之间还包含工作线程准备和资源生命周期修正,不能将变化全部归因于单一因素。保留这些结果用于后续复现实验,不删除未达标样本。 - -最终原始 QPC 数据和文件校验值保存在本地工作区: - -- [均衡十万事件 CSV](../tmp/native-balanced-verified-100k.csv) -- [极限十万事件 CSV](../tmp/native-extreme-verified-100k.csv) -- [测试 EXE、生产 DLL 与 CSV 的 SHA-256](../tmp/performance-provenance.json) -- [原生功能回归日志](../tmp/native-scheduler-regressions.log) -- [托管测试日志](../tmp/performance-tests.log) - -这些是本次本地生成的证据,不是随发行版附带的性能保证。 - -## 确定性瓶颈修复与微基准 - -| 项目 | 优化前 / 对照 | 本次结果 | 解释 | -|---|---|---|---| -| 300ms 长计划旁的 1ms 短计划 | 整段锁等待导致约 256–265ms | 均衡 1.020ms;极限 1.006ms | 批次调度消除了整计划串行阻塞 | -| 独立取消等待中的短计划 | 约 237–247ms | 两档均为 4µs | 仅本次回归样本,不是取消 P99 | -| 640×360 颜色转换分配 | 对象数组 7,372,829B | BGRA 视图 40B | 转换阶段下降超过 99%,不包含截图和识别 | -| 同一转换中位耗时 | 2359.2µs | 0.1µs | 新路径复用现有 BGRA,仅建立视图;接近计时分辨率,不应解释为完整图像处理吞吐 | - -转换探针 31 次测量满足分配下降至少 85%、中位耗时下降至少 50% 的阶段目标。模板近阈值、平坦图像、像素颜色、哈希和缓冲租约回归通过;完整条件链路耗时仍需另测。 - -补充托管假输入探针共 1000 个样本,RunAsync 调用至首次提交的 P99 为 100µs。该探针没有覆盖真实热键回调,样本量也不足十万,仅供启动路径诊断,不作为低占用档响应验收结论。 - -## 尚未通过完整验收的项目 - -- 三档及完整主窗口路线的真实热键 P99、游戏处理延迟;极限单宏独立窗口的物理 30 次及自动真实输入接收已完成,但物理样本不足估计 P99。SendInput 返回不等于游戏收到输入。 -- 三档取消请求至清理完成的十万事件 P99;当前原生取消回归验证正确性和单次耗时。 -- 低占用档完整十万事件调度迟到测量;原生探针仅覆盖均衡和极限。 -- 固定游戏回放下关闭宏、低占用、均衡、极限的 PresentMon 对照。尚无游戏 FPS 下降和 P99 帧时间退化结论。 -- 纯播放与混合识别分别进行至少五次交替顺序、每次十分钟测试;冷启动、预热、CPU/GPU 受限、像素、模板/OCR 并发与长期运行矩阵。 -- 不同设备、处理器拓扑及游戏版本的兼容性验证。本机 32 逻辑处理器结果不证明跨处理器组适用性。 - -同键并发回归验证宏内顺序和提交数量,不把操作系统共享的键状态改造成每宏独立设备状态。实际游戏还需检查同键冲突行为。 - -目标、命令、原始时间戳口径、回退开关和游戏帧时间预算见 [性能档位与验收](precision.md)。极限精度达标而游戏帧时间超预算时,仍推荐均衡;不自动更改用户选择,也不降低验收标准。 diff --git a/docs/precision-audit-2026-10-10.md b/docs/precision-audit-2026-10-10.md deleted file mode 100644 index 45f29af..0000000 --- a/docs/precision-audit-2026-10-10.md +++ /dev/null @@ -1,27 +0,0 @@ -# 精度与稳定性复核(2026-10-10) - -## 修复 - -- 条件监控线程捕获非取消异常并请求停止播放,退出时把错误交回播放控制。 -- 先取消所有条件线程,等待退出后才释放取消源和评估器相关资源;避免原先 200ms 超时后的资源竞争。不可取消的自定义评估器会延长停止等待,需要自行保证有界返回。 -- 慢识别跳过错过的轮询时刻,避免持续追赶旧时刻。 -- 托管进程优先级、GC 和节能设置采用共享会话计数,最后一个相关会话结束才恢复。 -- 托管和原生极限模式不再提高到实时进程优先级,使用 High;保持线程级调度、绑核和待命执行策略。 - -351 项回归测试通过,包含条件线程异常、慢评估器退出和调度状态恢复测试。 - -## 三种模式测量 - -测试机器为当前本机。每种模式串行测试 3 轮,每轮 5000 个 1ms 间隔,共每模式 15000 个时刻。编译完成后测量,原生引擎预热并预创建计划,自动选择执行路径。不发送真实输入;未施加固定游戏/CPU 压力负载。 - -| 模式 | 路径 | 三轮 P99.9(微秒) | 三轮最大偏差(微秒) | -| --- | --- | --- | --- | -| 基础 | managed | 23 / 46 / 24 | 188 / 153 / 82 | -| 高性能 | native-inline | 63 / 76 / 2 | 104 / 121 / 64 | -| 极限 | native-standby | 1 / 1 / 1 | 2 / 2 / 1 | - -以上是计划时刻到执行线程到达时刻的偏差,不是应用接收输入或画面响应延迟。原生与托管采用各自统计器,因此这是实际模式路径对比,不是完全同一实现的微基准。 - -附加托管路径对比:基础 P99 5–7us,高性能 5–9us,极限 10–19us。附加原生测量期间存在后台编译,高性能最大出现 1638us,极限最大出现 174us,说明空载结果不能当作最大延迟保证。 - -下一步应测游戏负载、多宏并发、长时间循环、停止响应以及真实输入提交延迟。本轮未重新发布 GitHub 的 1.6 安装附件。 diff --git a/docs/precision.md b/docs/precision.md index 41744eb..04b1cf1 100644 --- a/docs/precision.md +++ b/docs/precision.md @@ -1,6 +1,6 @@ # MacroHID 用户态性能档位与验收 -2026-10-14 单宏尾部与真实接收补充:保留原生线程预配置与空闲配置保留,撤回两项尾部退化的实验;最终版本通过 30 次物理热键及 Win32/WPF 各 2,000 次自动接收。具体数值、最大值波动、测量起止点和复现命令见 [实测报告](single-macro-tail-and-end-to-end-2026-10-14.md)。原生微秒级调度误差不能代替端到端接收验收。 +2026-10-14 单宏尾部与真实接收补充:保留原生线程预配置与空闲配置保留,撤回两项尾部退化的实验;最终版本通过 30 次物理热键及 Win32/WPF 各 2,000 次自动接收。具体数值、最大值波动、测量起止点和复现命令见 [实测报告](https://github.com/NotBadSlime/MacroLab/blob/main/docs/single-macro-tail-and-end-to-end-2026-10-14.md)(已迁到 MacroLab 仓库)。原生微秒级调度误差不能代替端到端接收验收。 ## 当前策略 @@ -93,4 +93,4 @@ CSV 保留 run、batch、due_qpc、submit_qpc、frequency、late_us。这是原 - 历史 Measure-PrecisionProfiles.ps1、-FailOnTargetMiss、-AutoTuneAffinity、Tune-LatencyAffinity.ps1、-TunePasses 与 edge-drop 核心候选策略仍可用于单路径诊断,不能代替双宏 + 游戏联合验收。 - Trace-LatencyOutliers.ps1 可捕获 WPR CPU 数据;用 Windows Performance Analyzer 对齐 DPC/ISR、上下文切换与 QPC 长尾。 -当前实测和未完成的验收见 [current-precision-status.md](current-precision-status.md)。本机结果不能推广到未测试的游戏、版本和设备。 +当前实测和未完成的验收见 [current-precision-status.md](https://github.com/NotBadSlime/MacroLab/blob/main/docs/current-precision-status.md)(已迁到 MacroLab 仓库)。本机结果不能推广到未测试的游戏、版本和设备。 diff --git a/docs/release-1.0.0.md b/docs/release-1.0.0.md index 13d1483..188bb2b 100644 --- a/docs/release-1.0.0.md +++ b/docs/release-1.0.0.md @@ -17,7 +17,7 @@ MacroHID `1.0.0` 是当前正式版。这个版本聚焦在 MacroStudio 可用 构建和核心测试都通过;极限档在本机使用 `0xE` affinity 后,这轮 5000 轮长测单步最大偏差 `45us`,整轮最大偏差 `43us`,没有 `>100us` outlier。 -详见:[当前精度水平](current-precision-status.md)。 +详见:[当前精度水平](https://github.com/NotBadSlime/MacroLab/blob/main/docs/current-precision-status.md)。 ## 验证 diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md index 823078b..979efde 100644 --- a/docs/releases/2.0.0.md +++ b/docs/releases/2.0.0.md @@ -35,7 +35,7 @@ - 物理测量从低层钩子入口计时,未覆盖机械开关、USB 传输、游戏动作与显示器响应;独立探针未覆盖完整主窗口进程筛选路线。 - 发行前托管功能回归、原生调度回归及帧时间比较器检查通过;逐项结果见发行验证记录。 -以上数据来自一台 Windows 11 / Ryzen 9 7945HX 电脑。极限档持续自旋有明显 CPU 成本;未承诺跨设备表现、游戏帧时间或微秒级端到端。测量方法与更差的探索结果也保留在 [实测报告](../single-macro-tail-and-end-to-end-2026-10-14.md)。 +以上数据来自一台 Windows 11 / Ryzen 9 7945HX 电脑。极限档持续自旋有明显 CPU 成本;未承诺跨设备表现、游戏帧时间或微秒级端到端。测量方法与更差的探索结果也保留在 [实测报告](https://github.com/NotBadSlime/MacroLab/blob/main/docs/single-macro-tail-and-end-to-end-2026-10-14.md)。 ## 打包与兼容性 diff --git a/docs/releases/2.0.1.md b/docs/releases/2.0.1.md index 92d3f97..d35c897 100644 --- a/docs/releases/2.0.1.md +++ b/docs/releases/2.0.1.md @@ -17,7 +17,7 @@ ## 未包含与未验证 -- 评估后不实施或撤回:把热键处理移出钩子线程(2.2)、定向唤醒(3.1);自旋窗口衰减(3.2)未实施。依据见 [阶段计划](../superpowers/plans/2026-10-14-scheduling-latency-phases.md)。 +- 评估后不实施或撤回:把热键处理移出钩子线程(2.2)、定向唤醒(3.1);自旋窗口衰减(3.2)未实施。依据见 [阶段计划](https://github.com/NotBadSlime/MacroLab/blob/main/docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md)。 - 本版**没有**重新测量真实热键到目标接收的端到端数字,也没有对上述三项启动/热键入口改动做冷启动首样本的交替对照。`v2.0.0` 的性能数据不能直接套用到本版。 - 0.1–1.5ms 的调度尖峰尚未归因。托管测试 394 项通过。 diff --git a/docs/research/macro-scheduler-comprehensive-decision-report.md b/docs/research/macro-scheduler-comprehensive-decision-report.md deleted file mode 100644 index 71928f0..0000000 --- a/docs/research/macro-scheduler-comprehensive-decision-report.md +++ /dev/null @@ -1,287 +0,0 @@ -# 宏软件调度架构综合优化决策报告 - -**版本**: 1.0
-**日期**: 2026 年 10 月 13 日
-**状态**: 评审完成,待实施
- ---- - -## 目录 - -1. [执行摘要](#执行摘要) -2. [技术评审概览](#技术评审概览) -3. [调研发现汇总](#调研发现汇总) -4. [优化建议评估](#优化建议评估) -5. [代码改进优先级](#代码改进优先级) -6. [实施路线图](#实施路线图) -7. [附录](#附录) - ---- - -## 执行摘要 - -本报告整合了以下内容: - -1. **深度调研报告**(8 万 + 字):涵盖主流宏软件架构、GitHub 开源项目、Windows 输入 API 特性 -2. **专家评审报告**:评级 A-,提出多项可行性评估和改进建议 -3. **综合优化决策**:明确立即实施、中期深化、前瞻探索三个阶段的任务清单 - -### 核心结论 - -- **MacroHID 已达到业界领先水平**,优化空间集中在微优化层面 -- **推荐立即落地的 3 项高 ROI 优化**:内存池化、CPU 亲和性自动化、GC 压力优化 -- **驱动级方案风险过高**,建议转为长期技术储备 -- **纯用户态方案的理论下限**约为 50-100μs(理想环境) - ---- - -## 技术评审概览 - -### 整体评级:A- - -**优点**: -- 技术深度充分,对 Windows 调度器和输入 API 栈理解准确 -- 现实边界清晰,避免过度承诺 -- 分级优化策略合理,便于渐进式落地 -- 数据驱动思维,强调基准测试和性能回归预防 - -**风险点**: -- 驱动级方案风险被低估(合规成本、HVCI 限制、反作弊检测) -- 硬件宏 Offload 可行性分析不足 -- 部分优化建议缺乏代码基础支持 - ---- - -## 调研发现汇总 - -### 主流宏软件对比 - -| 软件 | 架构特点 | 调度精度 | 备注 | -|------|----------|----------|------| -| **AutoHotKey** | 脚本解释型,事件驱动 | 约 1-5ms | 社区活跃但延迟较高 | -| **Razer Synapse** | 云配置 + 驱动层执行 | <1ms | 依赖特定硬件 | -| **Logitech G Hub** | 本地 + 云混合 | <1ms | 配置系统复杂 | -| **MacroHID** | Native 播放 + Managed 调度 | <1ms | **当前实现已接近理论极限** | - -### Windows 输入 API 性能对比 - -| API | 延迟特征 | 适用场景 | 注意事项 | -|-----|----------|----------|----------| -| **SendInput** | ~1-2ms(标准路径) | 通用宏注入 | 受 UIPI 限制 | -| **SetWindowsHookEx** | ~0.5-1ms(低级别钩子) | 全局监听 | 回调中避免 GC 分配 | -| **Raw Input** | ~0.3-0.8ms | 高精度游戏输入 | 实现复杂度较高 | -| **驱动级虚拟 HID** | <0.1ms | 极致延迟需求 | 合规风险极高 | - -### 关键开源项目分析 - -1. **ZMacro**:高性能 GUI 宏工具,C++ 实现,注重低延迟 -2. **InputStitch**:可视化宏编排,支持多层路由 -3. **Interception**:驱动级输入拦截,适合高级定制场景 - ---- - -## 优化建议评估 - -### 立即实施的优化(高 ROI)⭐⭐⭐⭐⭐ - -#### 1. 内存池化 -**评分**: 4/5 - -- **实施难度**:低 -- **预期收益**:Gen0 分配减少 50%+,GC 频率降低 40-60% -- **实施要点**: - ```csharp - // 使用 ConcurrentQueue 实现线程安全的 INPUT[] 池 - private static readonly ConcurrentQueue InputBufferPool = new(); - ``` - -#### 2. CPU 亲和性自动化 -**评分**: 4/5 - -- **实施难度**:低 -- **预期收益**:新用户无需手动运行 PowerShell 脚本 -- **实施要点**: - ```csharp - // 首次启动时自动扫描最优核心组合 - // 缓存结果到 affinity-cache.json - ``` - -#### 3. GC 压力优化 -**评分**: 4.5/5 - -- **实施难度**:中 -- **预期收益**:突发抖动减少 15-25% -- **影响范围**:所有使用 Native 回退的场景 - ---- - -### 建议暂缓的优化 ⏸️ - -#### 1. 驱动级原型 -**评分**: 2/5 - -- **技术风险**:高 -- **合规风险**:极高(EV 证书 $400+/年 + HVCI 限制 + 反作弊检测) -- **建议**:转为长期技术储备,不作为短期目标 - -#### 2. 中断协同优化 -**评分**: 3.5/5 - -- **技术难点**:Windows 无官方 API 直接查询 IRQ 分布 -- **预期收益**:最大抖动降低 10-20% -- **实施成本**:需通过 ETW 或 WMI 间接获取,复杂度高 - -#### 3. 条件监控分离 -**评分**: 3/5 - -- **技术难点**:增加状态同步复杂度 -- **缺失前提**:当前代码库未见 ConditionMonitor 实现基础 -- **建议**:先补齐条件评估框架基础设施 - ---- - -## 代码改进优先级 - -基于评审 Agent 的代码审查,以下是最需要优先改进的位置: - -### 🥇 优先级 1: `NativePlaybackEngine.cs` - 异常捕获路径的 GC 压力 - -**位置**:`src/shared/MacroHid.Runtime/NativePlaybackEngine.cs:329-346` - -**问题**: -```csharp -catch (DllNotFoundException ex) { - fallbackReason = $"native DLL unavailable: {ex.Message}"; // 字符串插值产生临时分配 - diagnostics = EmptyDiagnostics("managed", fallbackReason); - return false; -} -``` - -**改进方案**: -```csharp -// 使用预定义常量 -private static readonly string NativeDllUnavailableMsg = "native DLL unavailable"; -// 或在 Plan 创建阶段就预验证,避免运行时异常 -``` - ---- - -### 🥈 优先级 2: `MacroScheduler.cs` - 动态 List 增长的内存浪费 - -**位置**:`src/shared/MacroHid.Core/MacroScheduler.cs:32, 38-63` - -**问题**: -```csharp -var scheduled = new List(); // 默认容量 4,复杂宏会多次扩容 -``` - -**改进方案**: -```csharp -var estimatedCapacity = CountScheduledSteps(document.Steps); -var scheduled = new List(estimatedCapacity); -``` - ---- - -### 🥉 优先级 3: `GlobalKeyboardHook.cs` - 钩子回调中的 Marshal 开销 - -**位置**:`src/ui/MacroStudio/GlobalKeyboardHook.cs:300, 330` - -**问题**: -```csharp -var data = Marshal.PtrToStructure(lParam); // 每次都 Marshal -``` - -**改进方案**: -```csharp -// 使用预先分配的 struct 复用 -private static readonly ThreadLocal KeyboardStructCache = - new(() => new KbdLlHookStruct()); - -// 或使用 Unsafe 直接内存拷贝 -Unsafe.CopyBlock(ref structField, ref lParam.ToPointer(), sizeof(KbdLlHookStruct)); -``` - ---- - -## 实施路线图 - -### 第一阶段(1-2 周):立即可行 - -**目标**:快速见效,建立信心,完善测试基础设施 - -| 任务 | 负责人 | 预计工时 | 验收标准 | -|------|--------|----------|----------| -| 1.1 实现 InputBufferPool 内存池化 | TBD | 8h | GC 频率降低 40%+(基准测试) | -| 1.2 MacroScheduler 容量预分配 | TBD | 4h | List 扩容次数归零(profiling) | -| 1.3 AffinityOptimizer 首次启动扫描 | TBD | 12h | 用户无需手动运行 PS 脚本 | -| 1.4 补充基准测试套件 | TBD | 8h | CI/CD集成,自动回归检测 | -| **合计** | - | **32h** | - | - ---- - -### 第二阶段(1-2 月):中期深化 - -**目标**:系统性优化,提升长期可维护性 - -| 任务 | 负责人 | 预计工时 | 验收标准 | -|------|--------|----------|----------| -| 2.1 零分配播放循环 | TBD | 40h | p99.9 抖动降低 20%+ | -| 2.2 ETW 追踪基础设施 | TBD | 16h | 支持自定义 EventSource | -| 2.3 Power Platform 集成 | TBD | 8h | 播放期间禁用睡眠/待机 | -| 2.4 MMCSS 分类优化 | TBD | 8h | 适配 Game 类别的优先级 | -| 2.5 多线程救援机制升级 | TBD | 24h | 量化 p99.9 延迟改善 | -| **合计** | - | **96h** | - | - ---- - -### 第三阶段(3-6 月):前瞻探索 - -**目标**:技术储备,验证创新方向 - -| 任务 | 负责人 | 预计工时 | 验收标准 | -|------|--------|----------|----------| -| 3.1 硬件宏 Offload 兼容性研究 | TBD | 40h | 验证主流外设的 API 开放程度 | -| 3.2 ML 预测调度原型 | TBD | 80h | 积累足够系统负载数据 | -| 3.3 驱动级方案合规评估 | TBD | 24h | 完成法律风险评估 | -| **合计** | - | **144h** | - | - ---- - -## 附录 - -### A. 遗漏的技术点清单 - -1. ✅ **MMCSS 注册细节缺失** - 建议补充 `AvSetMmThreadCharacteristics("Game")` 的使用策略 -2. ✅ **timer 分辨率副作用** - 需讨论长期高解析度对功耗的影响 -3. ✅ **NUMA 感知实际价值** - 对大多数消费级硬件(单 NUMA 节点)无效 -4. ✅ **CPU 缓存预热策略** - 建议增加"warm-up run"机制 -5. ✅ **Power Platform API** - 播放期间应调用 `SetThreadExecutionState` -6. ✅ **ETW 追踪集成** - 建议集成 `EventSource` 实现自描述追踪 - -### B. 参考文献 - -1. Microsoft Learn. *SendInput function (winuser.h)*. https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-sendinput -2. Microsoft Learn. *SetWindowsHookEx function (winuser.h)*. https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwindowshookex -3. Microsoft Learn. *Multiple Processors*. https://learn.microsoft.com/windows/win32/procthread/multiple-processors -4. Russell, M., et al. *Windows Internernals* (7th ed.). Microsoft Press. -5. Draconis: Network-Accelerated Scheduling for Microsecond Latencies. ACM SIGCOMM 2024. - -### C. 相关文件清单 - -- 调研报告:`docs/research/macro-scheduler-deep-dive-report.md` -- 评审报告:`docs/research/macro-scheduler-review-report.md` -- 优化方案:本文档 -- 代码位置:`src/shared/MacroHid.*`, `src/ui/MacroStudio/*` - ---- - -## 变更记录 - -| 版本 | 日期 | 变更内容 | 作者 | -|------|------|----------|------| -| 1.0 | 2026-10-13 | 初稿,整合调研 + 评审结果 | AutoGenerated | - ---- - -**文档结束** diff --git a/docs/research/macro-scheduler-deep-dive-report.md b/docs/research/macro-scheduler-deep-dive-report.md deleted file mode 100644 index de30934..0000000 --- a/docs/research/macro-scheduler-deep-dive-report.md +++ /dev/null @@ -1,972 +0,0 @@ -# 宏软件调度架构深度调研与优化方案 - -## 执行摘要 - -本报告对市面上主流宏软件的调度算法架构进行了全面调研,重点分析了 Windows 平台下高精度任务调度的技术边界与优化路径。通过对商业软件(AutoHotKey、Razer Synapse、Logitech G Hub)、GitHub 高星开源项目及 Windows 输入 API 的深度剖析,结合现有 MacroHID 项目的代码分析,提出了一套系统的优化建议,旨在实现微秒级响应精度与极低输入延迟。 - -**核心结论:** -- 纯用户态方案的最佳实践已接近极限,MacroHID 当前的 QPC 自旋 + CPU 亲和性策略已达到业界领先水平 -- 驱动级方案可实现更确定性的延迟,但面临反作弊兼容性、UAC 安全桌面限制和安全风险 -- 现有项目的优化空间集中在:多核调度策略细化、内存分配零化、中断协同优化 -- 微秒级精度需要软硬件协同设计,纯软件方案在复杂系统负载下存在固有不确定性 - ---- - -## 第一部分:市面主流宏软件架构分析 - -### 1.1 商业软件调度架构对比 - -#### AutoHotKey (AHK v2) - -**架构特点:** -- **解释器模式**:脚本编译为字节码,运行时逐条解释执行 -- **单线程事件循环**:主要任务在一个消息循环中处理,通过 `SetTimer` 实现周期性任务 -- **消息队列集成**:与 Windows 消息系统集成,键鼠输入通过 `SendInput` 注入 - -**调度机制:** -``` -脚本解析 → 字节码编译 → 事件调度器 → SendInput 批处理 - ↓ - SetTimer (基于多媒体定时器) -``` - -**性能特征:** -- 典型延迟:5-50ms(取决于脚本复杂度) -- 1ms 等待精度:约±3-8ms 抖动 -- 优势:易用性强,社区生态完善 -- 劣势:解释器开销大,无法保证亚毫秒精度 - -**参考资料:** -- AutoHotKey v2 源码:https://github.com/Lexikos/AutoHotkey_L -- 官方文档:https://www.autohotkey.com/docs/v2/ - -#### Razer Synapse 3 / Logitech G Hub - -**架构特点:** -- **硬件加速**:部分宏在设备固件层面执行,绕过操作系统 -- **分层执行**:简单宏走硬件路径,复杂宏走驱动路径 -- **Ring 0/Ring 3 混合**:内核驱动负责低层通信,用户态应用负责配置 - -**调度机制:** -``` -触发检测 (硬件/驱动) → 宏引擎 (固件/驱动) → HID 报告发送 - ↓ - 驱动层环形缓冲区 → USB/HID 传输队列 -``` - -**性能特征:** -- 硬件宏:延迟<1ms,抖动<50μs -- 驱动宏:延迟 1-5ms,抖动 200-500μs -- 优势:极低延迟,不受用户态调度影响 -- 劣势:配置复杂度高,需要管理员权限 - -**调研发现:** -- Razer Synapse 使用自定义内核驱动 `rps.sys` 管理设备通信 -- Logitech G HUB 采用 `LVHSrv64.sys` 驱动,支持硬件宏和软件宏混合模式 -- 两者都实现了 MMCSS 多媒体类调度器注册,确保高优先级 - -#### 学术文献参考 - -**相关研究论文:** -1. **"A real-time process scheduling policy in Windows"** (西北工业大学,2011) - - 提出了基于 Windows 的软实时调度扩展 - - 核心思想:改造线程调度策略,引入截止时间感知 - -2. **"Draconis: Network-Accelerated Scheduling for Microsecond Latency"** (ACM 2024) - - 针对网络应用的微秒级调度系统 - - 使用专用内核模块绕过标准调度器 - - 可达 50-500μs 级调度精度 - -3. **Anderson et al., "Scheduler activations" (ACM TOCS 1992)** - - 经典的用户态线程管理与内核协作模型 - - 解释了为何绑核能减少迁移但不能消除抖动 - -### 1.2 GitHub 高星开源项目分析 - -#### 筛选标准与方法 -- Star 数 ≥ 500 -- 最近更新 ≤ 12 个月 -- 聚焦低延迟、高精度场景 - -#### 代表性项目 - -##### 1. **ZMacro** (zaidbyte/ZMacro) -**亮点:** -- C++ 编写的原生性能优化 -- 预编译输入批次,减少运行时分配 -- 使用 `QueryPerformanceCounter` 进行高精度计时 - -**核心代码片段:** -```cpp -// 从 GitHub 提取的典型调度模式 -void ScheduleInput(const std::vector& events, int64_t qpcFreq) { - int64_t startTick = ReadPerformanceCounter(); - for (const auto& evt : events) { - while (ReadPerformanceCounter() < evt.dueTick) { - // QPC 自旋等待 - _mm_pause(); - } - SendInput(1, &evt.input, sizeof(INPUT)); - } -} -``` - -**性能评估:** -- 1ms 间隔测试:平均延迟 1.2ms,最大抖动 150μs -- CPU 占用:约 5-15%(单核 100% 占用时) - -##### 2. **InputStitch** (ZhiHanyu-H57/InputStitch) -**特色功能:** -- 可视化层管理(layer system) -- 并发宏执行支持 -- 虚拟设备输出(vhid/vmouse) - -**调度策略:** -- 独立播放线程,最高优先级 -- 输入路由表,支持条件分支 -- 支持宏嵌套与子宏调用 - -##### 3. **keymouse-studio** (Geo-ff/keymouse-studio) -**架构简评:** -- Python 实现,易扩展但性能受限 -- 基于 `pynput` 和 `pyautogui` 库 -- 适合原型验证,不适合生产环境的高精度需求 - -##### 4. **Interception-mouse-compensator** -**关键技术:** -- 使用 Interception 驱动(Ring 0) -- 异步鼠标运动补偿 -- 低延迟模拟路径 - ---- - -## 第二部分:Windows 输入 API 深度分析 - -### 2.1 输入 API 全景图 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 应用程序层 │ -├─────────────────────────────────────────────────────────────┤ -│ SendInput │ SetWindowsHookEx │ Raw Input │ PostMessage │ -├─────────────────────────────────────────────────────────────┤ -│ User32.dll / UserMode │ -├─────────────────────────────────────────────────────────────┤ -│ Win32k.sys (Kernel) │ -├─────────────────────────────────────────────────────────────┤ -│ 输入堆栈 (Input Stack) │ -├─────────────────────────────────────────────────────────────┤ -│ HID Class Driver │ Keyboard Driver │ Mouse Driver │ -├─────────────────────────────────────────────────────────────┤ -│ 硬件中断 │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 2.2 SendInput API 详细分析 - -#### 技术规格 -- **位置**:`user32.dll` → `Win32k.sys` -- **调用成本**:用户态→内核态切换约 800-1500 纳秒 -- **批量处理**:单次调用可提交多个 `INPUT` 结构,推荐批大小≤10 - -#### 性能瓶颈 - -1. **上下文切换开销** - ``` - 用户态准备 INPUT 数组 (~100ns) - ↓ - Syscall 进入内核 (~500ns) - ↓ - Win32k 消息队列插入 (~300ns) - ↓ - 目标窗口消息派发 (变量,50ns-5ms) - ``` - -2. **UIPI 安全检查** - - 完整性级别检查增加 100-500ns - - 跨权限调用可能失败(需提升权限) - -3. **消息队列合并** - - 短时间内的多次 `SendInput` 可能被合并 - - 影响亚毫秒级精度的时间间隔 - -#### 优化方案 - -**方案 A:预编译批处理(已实现)** -```csharp -// MacroHID 当前实现 -var batch = new INPUT[steps.Count]; -// 预填充所有 INPUT 结构 -SendInput(steps.Count, batch, sizeof(INPUT)); -``` -**效果**:减少系统调用次数,但无法消除单个批次的内部抖动 - -**方案 B:驱动级注入(待研究)** -- 绕过 `SendInput`,直接调用 `NtSetInformationThread` 等未公开 API -- 或使用虚拟化技术(如 Hyper-V 隔离环境) -- **风险**:违反 WDK 规范,可能被反作弊标记 - -### 2.3 SetWindowsHookEx 分析 - -#### 应用场景 -- 全局键盘/鼠标监听 -- 热键触发检测 -- 输入拦截与修改 - -#### 性能特性 -- **钩子链延迟**:每个钩子增加 50-200ns -- **线程劫持**:钩子函数在不同线程上下文中执行 -- **内存驻留**:钩子 DLL 需加载到所有目标进程 - -#### 低延迟优化 - -1. **最小化钩子链长度** - - 仅安装必要钩子 - - 尽早卸载不用的钩子 - -2. **高效钩子函数** - ```csharp - // 当前 GlobalKeyboardHook.cs 实现 - private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) - { - // 关键路径:避免 GC 分配,使用预先分配的缓存 - if (nCode >= 0) { - // 快速路径:直接判断,不调用复杂方法 - return CallNextHookEx(...); - } - return CallNextHookEx(...); - } - ``` - -3. **分离监听与处理** - - 钩子只负责检测,将实际工作交给高优先级工作线程 - - 当前实现已基本遵循此原则 - -### 2.4 Raw Input vs SendInput 对比 - -| 指标 | Raw Input | SendInput | 差异原因 | -|------|-----------|-----------|----------| -| 输入延迟 | ~0.5ms | ~1-2ms | Raw Input 跳过消息队列 | -| 采样频率 | 设备原生 (最高 8kHz) | 受限于系统消息速率 | 硬件直连 | -| CPU 占用 | 较低(事件驱动) | 中等(轮询 + 消息) | 轮询开销 | -| 适用场景 | 游戏输入捕获 | 宏回放/自动化 | 单向 vs 双向 | - -**关键发现:** -- Raw Input 更适合**输入采集**,SendInput 更适合**输出模拟** -- 两者配合可实现完整的闭环宏系统 - ---- - -## 第三部分:并发策略与任务队列管理 - -### 3.1 任务队列架构演进 - -#### 第一代:线性队列 -``` -触发 → [步 1] → [步 2] → [步 3] → 完成 -``` -**问题**:无法处理并行任务,条件判断阻塞主线 - -#### 第二代:树状调度(当前 MacroHID 实现) -``` -触发 → 编译计划树 → 预分配时间片 → 并行执行分支 - ↓ - RepeatStep 展开 → 条件分支预编译 -``` - -**优点**:支持复杂控制流,预编译减少运行时开销 -**缺点**:内存占用随复杂度指数增长 - -#### 第三代:事件驱动调度(建议方向) -``` -事件总线 ←→ 触发器池 ←→ 动作执行器 - ↓ - 动态编译即时计划 -``` -**预期优势**:按需编译,内存友好,支持动态决策 - -### 3.2 当前 MacroHID 并发策略分析 - -#### PlaybackWorkerPool 实现 -```csharp -// src/shared/MacroHid.Runtime/PlaybackWorkers.cs -internal sealed class PlaybackWorkerPool(int maximumIdle) -{ - private readonly List idle = []; - - internal Task Run(Func action) - { - // 复用空闲 worker,避免频繁创建线程 - lock (gate) { - if (idle.Count > 0) { /* 复用 */ } - else { /* 创建工作线程 */ } - } - } -} -``` - -**性能评估:** -- 线程创建成本:~1ms(首次) -- 线程复用收益:显著(避免 1ms 创建开销) -- 上限设置合理(8 个空闲 worker) - -**改进建议:** -1. **自适应池大小**:根据历史负载动态调整 worker 数量 -2. **优先级队列**:区分实时任务与非实时任务 -3. **跨池隔离**:播放任务与工作线程任务物理隔离 - -### 3.3 条件监控与触发机制 - -#### 当前实现 -```csharp -// ConditionMonitor.cs - 独立线程轮询 -public async Task MonitorLoop(CancellationToken ct) -{ - while (!ct.IsCancellationRequested) { - foreach (var condition in conditions) { - if (await Evaluate(condition)) { - await ExecuteThenActions(condition); - } - } - await Task.Delay(checkInterval, ct); // 潜在抖动源 - } -} -``` - -**问题诊断:** -- `Task.Delay` 在低精度模式下抖动较大(10-50ms) -- 条件评估可能阻塞监测循环 - -**优化方案:** - -**方案 1:高精度等待替代** -```csharp -// 替换 Task.Delay 为 QPC 自旋 -private async Task HighPrecisionDelay(TimeSpan duration, CancellationToken ct) -{ - var targetTick = ReadQpc() + ToTicks(duration); - while (ReadQpc() < targetTick && !ct.IsCancellationRequested) { - Thread.Yield(); // 比 Sleep(0) 更轻量的让出 - } -} -``` - -**方案 2:事件驱动条件** -- 对于窗口状态、像素颜色等条件,使用事件通知而非轮询 -- 例如:使用 `SetWinEventHook` 监听窗口事件 - ---- - -## 第四部分:微秒级调度模型研究 - -### 4.1 理论边界分析 - -#### Windows 调度粒度的物理限制 - -| 层级 | 典型延迟 | 可控性 | -|------|----------|--------| -| 用户态线程调度 | 1-15ms | 部分可控 | -| 内核调度器 | 0.5-5ms | 几乎不可控 | -| DPC/ISR | 10-500μs | 不可控(硬件相关) | -| CPU 缓存失效 | 100-300ns | 部分可控 | - -**关键结论:** -- 纯用户态方案的理论下限约为**50-100μs**(理想环境) -- 实际环境中,DPC/ISR 干扰使**最大延迟不可预测** - -### 4.2 现有最优实践对比 - -#### Chromium 浏览器(参考实现) -```cpp -// base/time/time_win.cc -// Chrome 的高精度时间基实现 -void InitializeHighResolutionTimer() { - // 仅在需要时启用高精度 - if (NeedsHighResolution()) { - NtSetTimerResolution(100); // 100μs 分辨率 - timeBeginPeriod(1); - } -} -``` - -**启示:** -- 按需启用高精度模式,减少长期占用的副作用 -- 组合使用多种机制(timer 分辨率 + 自旋) - -#### React Native / Flutter 动画引擎 -- 使用 `requestAnimationFrame` 与垂直同步对齐 -- 在 Windows 上映射为 `CreateWaitableTimerEx` + V-Sync 信号 - -### 4.3 推荐的调度模型 - -#### 分层等待策略(已在 MacroHID 部分实现) - -``` -等待时长分类: -├── 长等待 (>50ms): CreateWaitableTimerEx + Sleep(1) -├── 中等待 (5-50ms): Sleep(0) + QPC 校验 -└── 短等待 (<5ms): QPC 自旋 + _mm_pause() -``` - -#### 改进版本:预测性调度 - -```csharp -public class PredictiveScheduler -{ - private readonly RollingBuffer _jitterHistory; - - public long AdjustedDeadline(long nominalDeadline) - { - // 基于历史抖动预测提前量 - var predictedJitter = CalculatePredictedJitter(); - return nominalDeadline - predictedJitter; - } -} -``` - -**理论收益:** -- 在已知系统负载模式下,可将最大延迟降低 20-30% -- 需要足够长的观测窗口(≥1000 次采样) - -### 4.4 硬件级优化可能性 - -#### CPU 亲和性深化 - -当前 MacroHID 实现已使用 `Tune-LatencyAffinity.ps1` 扫描最优核心。进一步深化: - -**1. NUMA 节点感知** -```csharp -// 获取线程所在 NUMA 节点 -var nodeId = LogicalProcessorInformation.GetNumaProcessorNode(coreIndex); -// 优先选择与 GPU 相同 NUMA 节点的核心(减少 PCIe 延迟) -``` - -**2. 超线程隔离** -- 检测物理核心与逻辑核心关系 -- 避免绑定到共享 L3 缓存的竞争核心 -- 使用 `LogicalProcessorInformation` API 获取拓扑 - -**3. IRQ 亲和性调整(需管理员权限)** -```powershell -# 将特定设备中断绑定到非播放核心 -Get-WindowsDeviceInterrupt | Where-Object {$_.Device -like "*keyboard*"} | - Set-ProcessorAffinity -Mask 0xFFFFFFF0 # 避开核心 0-3 -``` - -**风险提示**:IRQ 调整可能影响系统稳定性,需谨慎测试 - ---- - -## 第五部分:针对 MacroHID 的具体优化路径 - -### 5.1 优先级最高的优化(预计收益:15-25%) - -#### 5.1.1 零分配播放循环 - -**现状:** -```csharp -// 当前 managed 路径可能存在隐式分配 -foreach (var step in plan.Steps) { - var action = step.Compile(); // 可能分配 - await action.Execute(); -} -``` - -**优化方案:** -```csharp -// 预编译所有步骤,播放时无分配 -public sealed class ZeroAllocationPlaybackPlan -{ - private readonly ReadOnlySpan _actions; - private readonly Span _timingOffsets; // 固定大小数组 - - public void Execute(Span buffer, long startTime) - { - // 无 GC 压力的纯栈操作 - for (int i = 0; i < _actions.Length; i++) { - _actions[i].Apply(buffer.Slice(i)); - } - } -} -``` - -**实现步骤:** -1. 分析现有 `CompiledPlaybackPlan` 的分配热点 -2. 引入 `Span` 重构时间线数据结构 -3. 使用 `[MethodImpl(MethodImplOptions.NoInlining)]` 标注热路径 - -**预期收益:** 减少 GC Gen0/Gen1 分配,降低突发抖动 - -#### 5.1.2 CPU 亲和性自动化完善 - -**当前问题:** -- `Tune-LatencyAffinity.ps1` 需要手动运行 -- 推荐结果未持久化到配置 - -**改进方案:** -```csharp -// 首次启动时自动扫描并缓存结果 -public static class AffinityOptimizer -{ - private static readonly string CachePath = - Path.Combine(AppData, "affinity-cache.json"); - - public static int GetOptimalMask() - { - if (CacheExists() && CacheIsValid()) { - return LoadCachedMask(); - } - - var result = RunQuickScan(windowMs: 100); // 快速扫描 - SaveCache(result); - return result.Mask; - } -} -``` - -**预期收益:** 新用户开箱即用,无需手动调优 - -#### 5.1.3 中断协同优化 - -**背景:** -- 键盘/鼠标中断通常分配到核心 0 -- 如果宏播放也绑定核心 0,会产生竞争 - -**解决方案:** -```csharp -// 检测 IRQ 分布并避开 -public static int[] DetectNoisyCores() -{ - var counter = Stopwatch.StartNew(); - var measurements = new List<(int Core, long Jitter)>(); - - foreach (var core in AvailableCores) { - SetThreadAffinity(core); - var jitter = MeasureBackgroundInterrupts(timespan: 100.ms); - measurements.Add((core, jitter)); - } - - return measurements.OrderByDescending(x => x.Jitter).Select(x => x.Core).ToArray(); -} -``` - -**预期收益:** 避开高噪声核心,降低最大抖动 10-20% - -### 5.2 中等优先级优化(预计收益:10-15%) - -#### 5.2.1 多线程救援机制深化 - -**当前实现:** -- 已有 2-worker delayed rescue -- 仅在 Extreme 模式启用 - -**改进方向:** -```cpp -// NativePlaybackEngine 扩展:三阶段救援 -enum RescueTier { - Tier1_Primary, // 主 worker 正常执行 - Tier2_Helper, // 检测到延迟时 helper 接管 - Tier3_FastFail // 严重延迟时立即放弃该批次 -} - -class DelayedRescueCoordinator { - void OnBatchStart(int batchId) { - RegisterDeadline(batchId, deadline); - StartWatchdogTimer(batchId, threshold: 50us); - } - - void OnWatchdogTriggered(int batchId) { - // 无缝切换到备用 worker - TransferWorkload(batchId, backupWorker); - LogRescueEvent(batchId); - } -}; -``` - -**预期收益:** 大幅减少长尾延迟(p99.9) - -#### 5.2.2 内存池化 - -**问题:** -- 每次播放都会分配新的 `INPUT[]` 数组 -- 高频触发时 GC 压力显著 - -**解决方案:** -```csharp -public sealed class InputBufferPool -{ - private readonly ConcurrentQueue _pool = new(); - private const int MaxCachedBuffers = 10; - - public INPUT[] Rent(int size) - { - if (_pool.TryDequeue(out var buffer) && buffer.Length >= size) { - return buffer; - } - return new INPUT[size]; - } - - public void Return(INPUT[] buffer) - { - if (_pool.Count < MaxCachedBuffers) { - _pool.Enqueue(buffer); - } - } -} -``` - -**预期收益:** 降低 GC 频率,减少内存碎片 - -#### 5.2.3 条件监控优化 - -**当前瓶颈:** -- 条件评估在主播放线程中进行 -- 可能阻塞后续步骤 - -**重构方案:** -```csharp -// 分离条件监控与动作执行 -public class AsyncConditionEvaluator -{ - private readonly CancellationTokenSource _monitorCts = new(); - private readonly ConcurrentDictionary> _evaluations = new(); - - public void StartMonitoring(IEnumerable conditions) - { - _ = Task.Run(async () => { - while (!_monitorCts.Token.IsCancellationRequested) { - foreach (var cond in conditions) { - var result = await EvaluateAsync(cond); - _evaluations[cond.Id] = Task.FromResult(result); - } - await Task.Delay(MonitorInterval, _monitorCts.Token); - } - }); - } - - public bool IsConditionMet(ConditionId id) - => _evaluations.TryGetValue(id, out var task) && task.Result; -} -``` - -**预期收益:** 条件触发延迟降低 30-50% - -### 5.3 前瞻性优化(预计收益:5-10%,需深入研发) - -#### 5.3.1 驱动级原型验证 - -**可行性分析:** -- 可使用 KMDF 编写轻量级虚拟 HID 驱动 -- 通过 `IoCreateDevice` 注册虚拟键盘/鼠标 -- 绕过用户态消息队列 - -**技术路线:** -1. 参考 `HIDDriver` 开源项目(MIT 许可) -2. 实现最小化的 `IRP_MJ_WRITE` 处理 -3. 在隔离环境中测试(避免影响系统稳定性) - -**风险评估:** -- 需要签名才能加载(测试可用 test-signing) -- 可能被游戏反作弊标记 -- 调试难度大,崩溃导致蓝屏 - -#### 5.3.2 硬件中断模拟器 - -**概念验证:** -```csharp -// 使用物理 HID 设备的特性 -// 某些高端宏键盘支持板载宏存储 -// 通过配置板载内存实现真正的硬件级执行 - -public class HardwareMacroOffloader -{ - public bool OffloadToDevice(HidDevice device, MacroPlan plan) - { - // 1. 计算板载内存占用 - // 2. 上传宏序列到设备固件 - // 3. 绑定触发键位 - // 4. 设备独立执行,无需主机参与 - } -} -``` - -**适用设备:** -- Razer BlackWidow / DeathAdder(支持 Synapse 板载内存) -- Logitech G Pro / G502(支持 G HUB 板载配置) -- Corsair K95 / Mouse(支持 iCUE) - -#### 5.3.3 机器学习预测调度 - -**研究方向:** -- 训练轻量级模型预测系统负载模式 -- 动态调整调度策略(激进 vs 保守) -- 基于强化学习的自适应参数调优 - -**技术栈:** -- ONNX Runtime(嵌入式推理) -- 特征:CPU 使用率、磁盘 IO、网络流量、DPC 延迟 -- 输出:推荐的时间偏移量、核心选择 - -**可行性:** 需要大量数据积累,短期难以落地 - ---- - -## 第六部分:性能基准测试建议 - -### 6.1 测试方法论 - -#### 基准测试矩阵 - -| 测试维度 | 工具 | 指标 | 通过标准 | -|----------|------|------|----------| -| 静态延迟 | LatencyProbe | p50/p95/p99/max | p99 < 250μs | -| 循环稳定性 | LoopStressTest | 连续迭代标准差 | σ < 50μs | -| 高压竞争 | LoadConcurrentTest | 系统负载下的表现 | max < 1ms | -| 长时间运行 | SoakTest(24h) | 内存泄漏、漂移 | 无泄漏 | -| 极端边界 | ChaosMonkey | 随机中断/暂停 | 可恢复 | - -#### 环境控制 - -**必需条件:** -- 电源计划:高性能/卓越性能 -- 关闭不必要的后台服务(Windows Search、Defender 实时扫描等) -- 禁用 Windows 更新自动下载 -- 笔记本必须插电 - -**测量工具链:** -``` -LatencyProbe → ETL 跟踪 → WPA 分析 - ↓ ↓ ↓ - 数值汇总 系统日志 可视化图谱 -``` - -### 6.2 推荐的测试用例集 - -#### 用例 1:基本精度验证 -```powershell -# 基础档位测试 -.\artifacts\local-run\LatencyProbe.exe ` - --backend native ` - --profile balanced ` - --loop-steps 100 ` - --loop-interval-us 1000 ` - --iterations 10000 ` - --target-us 500 ` - --trace-outliers artifacts\balanced-outliers.csv -``` - -#### 用例 2:压力测试 -```powershell -# 极限压力下连续运行 -.\scripts\Measure-PrecisionProfiles.ps1 ` - -Iterations 50000 ` - -AutoTuneAffinity ` - -TunePasses 3 ` - -FailOnTargetMiss -``` - -#### 用例 3:真实场景模拟 -```powershell -# 模拟游戏场景(假设目标帧率 144Hz) -.\scripts\Simulate-GameScenario.ps1 ` - -TargetFps 144 ` - -MacroComplexity high ` - -BackgroundLoad moderate ` - -DurationMinutes 30 -``` - -### 6.3 性能回归预防 - -#### CI/CD 集成 - -**GitHub Actions / Azure DevOps 示例��** -```yaml -# .github/workflows/latency-regression.yml -name: Latency Regression Test - -on: [push, pull_request] - -jobs: - benchmark: - runs-on: windows-latest - steps: - - uses: actions/checkout@v3 - - - name: Build Release - run: dotnet build -c Release - - - name: Run Latency Tests - run: | - dotnet test tools/LatencyProbe -- -c Release --ci - - - name: Upload Results - uses: actions/upload-artifact@v3 - with: - name: latency-reports - path: artifacts/precision-profiles/ - - - name: Fail on Regressions - run: | - ./scripts/Check-Regression.ps1 ` - --baseline artifacts/baseline.csv ` - --current artifacts/latest.csv ` - --threshold-percent 10 -``` - ---- - -## 第七部分:实施路线图 - -### 7.1 第一阶段:立即可行(1-2 周) - -**目标**:快速收益,最小风险 - -**任务清单:** -1. ✅ 完善 CPU 亲和性自动化(5.1.2) -2. 🔄 实现内存池化(5.2.2) -3. ⬜ 优化条件监控分离(5.2.3) -4. ⬜ 增强诊断工具(追加 IRQ 检测) - -**验收标准:** -- 新用户无需手动调优即可获得推荐核心掩码 -- GC 频率降低 30% -- 条件触发延迟下降 20% - -### 7.2 第二阶段:中期深化(1-2 月) - -**目标**:系统性优化,架构改进 - -**任务清单:** -1. 零分配播放循环重构(5.1.1) -2. 多线程救援机制升级(5.2.1) -3. 开发中断协同优化模块(5.1.3) -4. 建立自动化性能测试流水线 - -**验收标准:** -- p99.9 延迟降低 25% -- 长时间运行无内存泄漏 -- CI 自动拦截性能回归 - -### 7.3 第三阶段:前瞻探索(3-6 月) - -**目标**:技术储备,差异化竞争力 - -**任务清单:** -1. 驱动级原型开发与隔离测试 -2. 硬件宏 Offload 适配(支持主流品牌) -3. 机器学习预测调度研究 -4. 发布独立性能评测基准工具 - -**验收标准:** -- 驱动版在沙箱环境稳定运行 -- 支持至少 3 款外设的板载宏 -- 发表技术博客/白皮书 - ---- - -## 第八部分:风险与限制 - -### 8.1 技术边界重申 - -**不可逾越的红线:** -1. **UAC 安全桌面**:无法在非交互式会话中注入输入 -2. **受保护进程**:Anti-Cheat、浏览器 sandbox 等环境会拦截 -3. **硬件保护**:EAC、BattlEye 等会检测并封禁驱动级外挂 -4. **物理限制**:USB 轮询率上限、HID 报告速率限制 - -**透明告知:** -- MacroHID 无法超越 Windows 用户态的理论极限 -- 任何声称"100% 确定性"的方案都是虚假宣传 -- 在系统高负载下,亚毫秒级延迟无法保证 - -### 8.2 安全与合规 - -**驱动程序开发风险:** -- 内核驱动需数字签名(EV 证书成本约$400/年) -- Windows 11 强制启用 HVCI 时,未签名驱动无法加载 -- 游戏反作弊可能将自定义驱动视为 cheating 工具 - -**建议策略:** -- 驱动版作为可选组件,默认不启用 -- 提供明确的免责声明和使用协议 -- 限制在合法用途(自动化测试、无障碍辅助) - -### 8.3 用户体验权衡 - -**性能 vs 功耗:** -- 极致优化会增加 CPU 占用(自旋等待) -- 需要在电池续航与延迟之间做出取舍 - -**复杂度 vs 可用性:** -- 高级功能(CPU 扫描、IRQ 调优)对普通用户过于复杂 -- 保持"一键优化"与"专家模式"的分层设计 - ---- - -## 结语 - -本次调研全面梳理了宏软件调度领域的技术现状,从商业软件、开源项目到学术研究的三维视角,揭示了 Windows 平台下高精度输入模拟的技术边界。 - -**核心洞察:** -1. MacroHID 已处于业界第一梯队,当前的优化重点是**微优化**而非**架构革命** -2. 驱动级方案虽能提供更好确定性,但面临合规、安全和兼容性三重挑战 -3. 真正的突破可能需要跳出纯软件框架,探索**软硬协同**的新范式 - -**行动号召:** -按照本报告的"实施路线图",分阶段推进优化工作。建议优先落实**零分配播放循环**与**CPU 亲和性自动化**,这两项投入产出比最高且风险最低。 - ---- - -## 附录 - -### A. 术语表 - -- **QPC** (QueryPerformanceCounter): Windows 高精度计时器 -- **MMCSS** (Multimedia Class Scheduler Service): 多媒体任务调度服务 -- **DPC** (Deferred Procedure Call): 延迟过程调用,驱动级中断处理 -- **IRQ** (Interrupt Request): 硬件中断请求 -- **NUMA** (Non-Uniform Memory Access): 非一致性内存访问架构 - -### B. 参考文献完整列表 - -**Microsoft 官方文档:** -1. [SetWindowsHookEx function](https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwindowshookex) -2. [SendInput function](https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-sendinput) -3. [QueryPerformanceCounter](https://learn.microsoft.com/windows/win32/api/profileapi/nf-profileapi-queryperformancecounter) -4. [SetThreadAffinityMask](https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-setthreadaffinitymask) -5. [CPU Sets](https://learn.microsoft.com/windows/win32/procthread/cpu-sets) - -**开源项目:** -1. [AutoHotkey v2](https://github.com/Lexikos/AutoHotkey_L) -2. [Interception](https://github.com/Detox/interception) - 开源 HID 驱动框架 -3. [RazerOpen](https://github.com/roderik/razer-open) - Razer 协议逆向 - -**学术论文:** -1. Anderson et al., "Scheduler activations", ACM TOCS 1992 -2. Chen et al., "A real-time process scheduling policy in Windows", 2011 -3. Grotberg et al., "Draconis: Network-Accelerated Scheduling", ACM 2024 - -**社区资源:** -- Random ASCII 博客系列:《The cost of synchronization》 -- ChakraCore 源码:timer resolution 实践 -- WineHQ 代码库:Windows API 参考实现 - -### C. 工具链清单 - -**必备工具:** -- Windows Performance Analyzer (WPA) -- Process Explorer / Process Hacker -- LatencyMon -- HWiNFO64(传感器监控) - -**脚本工具:** -- `Tune-LatencyAffinity.ps1` - CPU 亲和性调优 -- `Measure-PrecisionProfiles.ps1` - 性能基准 -- `Trace-LatencyOutliers.ps1` - 异常追踪 - ---- - -**文档版本:** v1.0
-**最后更新:** 2026-10-13
-**作者:** AI Technical Research Assistant
-**审核状态:** 待评审 diff --git a/docs/research/macro-scheduling-research-2026-10-14.md b/docs/research/macro-scheduling-research-2026-10-14.md deleted file mode 100644 index fa68a3d..0000000 --- a/docs/research/macro-scheduling-research-2026-10-14.md +++ /dev/null @@ -1,369 +0,0 @@ -# 宏软件调度架构、Windows 输入延迟与 MacroHID 优化实施研究 - -调研日期:2026-10-14(Asia/Shanghai)。对象:当前 MacroHID 工作区、Windows 桌面宏及相关开源输入项目。 - -**结论:优先保留现有预编译、QPC 绝对时间轴、共享原生执行器,补齐分段测量,再优化热键入口、无效唤醒、条件检测和输入状态仲裁。Windows 上可以争取微秒量级的软件调度误差,但普通 Windows、SendInput、虚拟 HID 或 USB 宏键盘都不能保证任意负载下的微秒级端到端响应。** 如果“所有事件的最坏响应都小于若干微秒”是硬要求,需要把确定性输出放到有时限分析的 MCU/FPGA/实时控制系统;Windows 目标应用的处理和显示仍须单独验收。 - -本文交付调研与实施指南,本次未修改运行时代码、安装驱动、改变系统设置或运行真实输入注入。历史 CSV 已重新统计并核对哈希;没有把历史构建和测试结果当作当前版本的新测试。 - -## 1. 调研方法与证据边界 - -检索路径分四组:①项目官方源码与文档;②Microsoft Learn 的 Win32、驱动及 USB 文档;③ACM/IEEE 论文、USENIX 会议论文、作者/大学公开版本;④当前工作区源码及原始性能记录。检索关键词覆盖 macro scheduling、hook event dispatch、input queue、EDF、CBS、priority inversion、microsecond tail latency、USB input latency、Windows high-resolution timers。补充检索了较新的 kernel bypass 研究,纳入 NSDI 2024 Junction。 - -这是面向工程决策的定向文献综述,不声称穷尽市场、完成市场占有率调查或按 PRISMA 做元分析。“主流”指具有代表性的产品类别;“高星”按本次 GitHub API 快照筛选,**星数不代表速度或可靠性**。 - -证据分级: - -| 标记 | 含义 | 使用边界 | -|---|---|---| -| 源码事实 | 本次读取到具体实现 | 限于所读版本、后端、路径;不是整个产品保证 | -| 官方行为 | 厂商/API 文档明确描述 | 不据此猜测内部堆、时间轮、线程池 | -| 论文结果 | 原文实验或理论 | 保留平台、模型和假设,不能直接外推 Windows 宏 | -| 历史测量复核 | 本次重算既有原始 CSV | 数据来自此前运行,不代表当前二进制重新测量 | -| 工程建议/目标 | 基于上述资料提出的设计 | 必须通过 A/B 和真实接收端验证 | - -GitHub 元数据、源码下载清单和 SHA-256、历史数据复核输出见本目录 `evidence-2026-10-14`。部分源文件取自移动分支,哈希记录了实际读取内容;AHK 代码固定在 v2.0.19,未把仓库默认 alpha 当稳定版本。下载失败条目也保留在清单中,不作为依据。按键精灵搜索命中的 `help.anjian.com` 是含触摸/Android API 的文档,**不能证明 Windows 版线程实现**,因此该项标注未知。 - -## 2. 市场产品:队列、分发与并发 - -| 产品/类别 | 可核实的任务及事件模型 | 并发与队列结论 | 对 MacroHID 的价值 | -|---|---|---|---| -| AutoHotkey v2 | 热键/定时器/GUI 事件进入消息处理;脚本执行流可被事件打断并恢复 | 脚本“线程”主要是嵌套执行流,不等于每个宏一个 OS 线程;Critical 与线程限制控制可中断性。内部仍有钩子等辅助 OS 线程 | 参考短回调、消息分离、每热键重入策略,不把脚本伪线程当硬实时 | -| Pulover’s Macro Creator | 录制、可视化编辑并生成/运行 AutoHotkey 脚本 | 依赖其 AHK 运行时语义;不能视为独立的高精度 EDF 引擎 | 参考编辑器与执行计划分离 | -| AutoIt | AdlibRegister 周期调用函数,执行期间主脚本暂停 | 官方行为体现脚本回调串行/中断式处理;未公开证明存在微秒 deadline 队列 | 参考定时回调必须短小;避免识别阻塞控制流 | -| Jitbit Macro Recorder | 按命令播放,Delay 使用毫秒间隔 | 私有内部队列、锁及线程策略未知;播放速度控制不等于精确时限保证 | 用作功能/成功率对照,不能只比“无延时”吞吐 | -| Bartels Media Macro Recorder | 录制时间轴、播放速度、等待像素/图像/文本等条件 | 文档说明行为,未披露底层队列及并行算法 | 条件等待可比固定长 Sleep 更有效;测条件新鲜度 | -| Logitech G HUB | 配置宏类型,并区分软件与板载模式;部分宏在板载模式不可用 | 主机和固件是不同执行路径;不能把所有宏都称为硬件执行 | 对比时记录设备、固件、宏类型和是否依赖后台服务 | -| Razer Synapse 4 | 录制按键/鼠标及延时,宏分配给支持设备 | 内部调度与最坏延迟未公开;不能从“游戏外设”推定驱动级零延迟 | 作为产品行为对照,不能作为算法证据 | -| Quicker | 组合动作提供并行分支,支持向剩余分支发协作式取消 | 文档可确认控制流语义;底层线程数量、计时算法未知 | 借鉴取消作用域、分支完成策略,避免 Stop 后遗留动作 | -| 按键精灵 Windows | 本轮未获得足够匹配 Windows 版本的官方调度实现证据 | 队列结构、抢占方式、真实线程映射均未知 | 列入后续黑盒对照;不采用手机文档解释 Windows 实现 | - -来源:[AHK 线程官方文档源码](https://github.com/AutoHotkey/AutoHotkeyDocs/blob/v2/docs/misc/Threads.htm)、[AHK 消息处理 v2.0.19](https://github.com/AutoHotkey/AutoHotkey/blob/v2.0.19/source/application.cpp)、[Pulover 项目说明](https://www.macrocreator.com/project/)、[AutoIt AdlibRegister](https://www.autoitscript.com/autoit3/docs/functions/AdlibRegister.htm)、[Jitbit 命令](https://www.jitbit.com/macro-recorder/macro-commands/)、[Macro Recorder 播放](https://www.macrorecorder.com/doc/playback/)、[条件等待](https://www.macrorecorder.com/doc/wait/)、[G HUB 板载限制](https://support.logi.com/hc/en-001/articles/22102343233943-Some-macros-do-not-work-when-in-Onboard-Memory-mode)、[Razer 宏配置](https://mysupport.razer.com/app/answers/detail/a_id/2003)、[Quicker 并行语义](https://docs.getquicker.net/v2/what%27s-new/actions/xaction-steps/)。 - -**比较原则:**闭源产品只能比较可观察的输入成功率、录制/播放时间偏差、并发行为和资源开销。没有公开源码、符号或可复核跟踪,就不能给它贴上“时间轮”“无锁队列”“内核 EDF”等算法标签。 - -## 3. GitHub 代表项目及源码启示 - -以下精确星数来自本次 API 查询,时间戳见 `github-repositories.json`。最后 push 时间不是最后发行日期或安全维护承诺。 - -| 项目 | Stars | 队列/分发/并发 | 适用性判断 | -|---|---:|---|---| -| [QMK](https://github.com/qmk/qmk_firmware) | 20,757 | 固件循环与输入状态机;deferred executor 使用固定容量表、毫秒触发时刻,按旧触发时刻推进周期 | 固件状态管理范例;现有 deferred API 本身并非微秒调度器 | -| [AutoHotkey](https://github.com/AutoHotkey/AutoHotkey) | 13,287 | 钩子向主消息处理投递热键,脚本执行受消息泵、可中断状态及重入限制控制 | 最相关的完整宏脚本参考;不要照搬 v1 SetBatchLines 到 v2 | -| [PyAutoGUI](https://github.com/asweigart/pyautogui) | 12,732 | 同步动作调用;所读源码默认 PAUSE=0.1 秒,鼠标还有最短时长/步进设置;Windows 路径仍使用 keybd_event/mouse_event | 易用自动化框架;默认延时会主导结果,不能用于证明 OS 注入慢 | -| [Kanata](https://github.com/jtroo/kanata) | 8,002 | 输入通道与单独处理逻辑、按键状态机、毫秒 tick;Windows 有低级 hook 和 Interception 路径 | 优先参考事件通道、tap/hold 与状态语义;不把 tick_ms 推断成微秒保证 | -| [KMonad](https://github.com/kmonad/kmonad) | 5,071 | 事件源→Dispatch→Hooks→Sluice→Keymap;STM 状态/重放缓冲;独立 emitter 从 TMVar 取输出 | 适合参考可组合事件处理和回放;TMVar 交接与 Haskell runtime 不提供硬时限 | -| [keyboard](https://github.com/boppreh/keyboard) | 3,972 | GenericListener 中监听和处理两条线程;Queue FIFO,消费者依次执行 handler | 清晰的采集/处理解耦样例;无界积压与慢 handler 会影响尾延迟,阻断输入路径须另看 | -| [AutoKey](https://github.com/autokey/autokey) | 3,896 | Linux 桌面自动化/脚本项目,本轮未深入其队列源码 | 跨平台功能参考,不作 Windows 输入架构证据 | -| [nut.js](https://github.com/nut-tree/nut.js) | 2,857 | async/await 编排与输入 provider;所读分支键盘 autoDelayMs=300 | provider 可插拔值得借鉴;Promise 并发不代表硬件同时输入 | -| [pynput](https://github.com/moses-palmer/pynput) | 2,173 | Listener 线程;Windows 上回调可能直接处于 OS 回调线程;官方建议把工作投到队列 | 采集层参考,不能在回调里跑识别、IO 或长计算 | -| [Pulover Macro Creator](https://github.com/Pulover/PuloversMacroCreator) | 2,026 | AHK 生态的可视化录制/编排 | 适合参考宏表达和导出;本次快照最后 push 为 2022 年 | -| [Interception](https://github.com/oblitum/Interception) | 2,019 | 键鼠过滤驱动及用户态等待、接收、发送接口 | 适合研究设备路径;不是调度算法,也没有证明比 SendInput 更快;快照最后 push 为 2021 年 | - -具体源码入口:[AHK hook.cpp](https://github.com/AutoHotkey/AutoHotkey/blob/v2.0.19/source/hook.cpp)、[keyboard GenericListener](https://github.com/boppreh/keyboard/blob/master/keyboard/_generic.py)、[PyAutoGUI 配置](https://github.com/asweigart/pyautogui/blob/master/pyautogui/__init__.py)、[Windows 后端](https://github.com/asweigart/pyautogui/blob/master/pyautogui/_pyautogui_win.py)、[Kanata 低级 hook](https://github.com/jtroo/kanata/blob/c391826f6bbc8d5dc1645bbedc5596e22ae5982a/src/kanata/windows/llhook.rs)、[Kanata 调度状态](https://github.com/jtroo/kanata/blob/main/src/kanata/mod.rs)、[KMonad 入口](https://github.com/kmonad/kmonad/blob/master/src/KMonad/App/Main.hs)、[KMonad Dispatch](https://github.com/kmonad/kmonad/blob/master/src/KMonad/Model/Dispatch.hs)、[pynput 回调约束](https://github.com/moses-palmer/pynput/blob/master/docs/keyboard-usage.rst)、[nut.js 键盘编排](https://github.com/nut-tree/nut.js/blob/e413fa1f19a19c4631812e4e1eaf47aa732b5cbe/core/nut.js/lib/keyboard.class.ts)、[QMK deferred executor](https://github.com/qmk/qmk_firmware/blob/master/quantum/deferred_exec.c)。 - -这些项目的共同价值是**事件所有权、输入状态机、采集和执行解耦**。本轮没有发现可以直接移植后就承诺 Windows 微秒级端到端响应的通用宏引擎。Python/JavaScript 并非不能做宏,而是其默认动作节流、运行时停顿和事件循环应与精密时序执行层分开。源码借鉴与复制实现不同,若直接引入代码,另核对实际文件许可证;API 的 null/NOASSERTION 不表示无许可约束。 - -## 4. Windows 调度与输入链路 - -### 4.1 先定义测量对象 - -设 t0 为物理按键闭合,t1 为本程序 hook 回调入口,t2 为任务入队,t3 为执行器开始,d 为该批次目标 QPC 时刻,t4/t5 为 SendInput 进入/返回,t6 为受控接收程序处理事件,t7 为关联画面实际发光。 - -| 指标 | 定义 | 能证明什么 | -|---|---|---| -| 调度有符号误差 | t4 − d | 是否早发/迟发;必须保留符号 | -| 调度迟到 | max(0, t4 − d) | deadline 服务水平,不代表端到端 | -| 热键软件启动 | t4 − t1 | 本程序从收到 hook 到首次提交 | -| API 调用时长 | t5 − t4 | 调用开销及其间可能发生的阻塞/抢占 | -| 软件输入处理 | t6 − t4 | 注入开始到接收处理,需关联同一事件 | -| 物理到接收 | t6 − t0 | 包含键盘固件、USB 和主机输入路径 | -| 动作到发光 | t7 − t0 | 用户体验链路,需要光学/硬件证据 | - -**不要默认 t6 ≥ t5。** 接收方可能在 SendInput 返回前已经运行。用 t6−t5 作为唯一“系统排队时间”可能出现负数;应关联 t4、t5、t6 并保留并发关系。各段 P99 也不能相加后称为端到端 P99。 - -```mermaid -flowchart LR - H[物理设备与 USB] --> I[Windows 输入栈] - I --> K[专用 hook 消息线程] - K --> Q[触发队列与取消标志] - Q --> S[预编译计划与 QPC 调度] - S --> A[输入状态仲裁] - A --> N[SendInput 或 HID 后端] - N --> R[目标程序处理] - R --> F[渲染与显示] - C[截图与条件识别] --> Q -``` - -这是测量/职责示意,不表示所有输入 API 都走完全相同的内核路径。 - -### 4.2 优先级、时钟和定时器 - -Windows 使用基于线程优先级的抢占调度;计时器到期只使等待可被满足,线程仍须获得 CPU。提升线程优先级不能压过硬件中断、某些 DPC 工作或固件停顿。CPU 迁移、缺页、锁竞争、频率/电源状态和同核竞争都是要测的变量。`REALTIME_PRIORITY_CLASS` 甚至会妨碍输入相关系统线程,不宜作为宏软件默认设置。[Windows 调度优先级](https://learn.microsoft.com/en-us/windows/win32/procthread/scheduling-priorities)、[硬件 IRQL](https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/managing-hardware-priorities)。 - -QPC/Stopwatch 是测量和绝对时间轴的基准,不是唤醒机制。QPC 频率是接口刻度,不等于 CPU 主频;不要用未经验证的裸 RDTSC 替换它。相对定时器参数的 100ns 单位也不代表 100ns 实际精度。[QPC 官方指南](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps)。 - -高分辨率 waitable timer 自 Windows 10 1803 起可用。建议远期阻塞等待、临近 deadline 短自旋,每次恢复后重新读取 QPC。原点使用 QPC,转换为相对 timer 间隔;不要把 QPC tick 当作绝对 FILETIME。`timeBeginPeriod` 在 Windows 10 2004 后具有进程相关行为,Windows 11 对完全遮挡/最小化等场景还有约束;它不提高 QPC 精度,更不能视为硬实时开关。[CreateWaitableTimerExW](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createwaitabletimerexw)、[timeBeginPeriod](https://learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod)。 - -MMCSS 可以改善时间敏感线程获得 CPU 的机会,但不是任意应用时限保证。本项目的 Pro Audio 配置应作为可回退实验档,比较目标应用帧时间、CPU 和功耗,不能以提高优先级次数作为成功指标。[MMCSS](https://learn.microsoft.com/en-us/windows/win32/procthread/multimedia-class-scheduler-service)。 - -### 4.3 SetWindowsHookEx 的瓶颈与优化 - -`WH_KEYBOARD_LL/WH_MOUSE_LL` 属于监听/拦截入口。低级键盘 hook 在安装线程上下文执行,必须持续泵消息;它不是“回调在目标进程运行”的普通 DLL hook。超时可能导致静默移除。Windows 11 已不支持传统 journaling hooks,不能用旧宏录制教程的 WH_JOURNALPLAYBACK 作为新架构。[LowLevelKeyboardProc](https://learn.microsoft.com/en-us/windows/win32/winmsg/lowlevelkeyboardproc)、[SetWindowsHookExW](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw)。 - -工程建议: - -1. 回调只做时间戳、注入标记过滤、必要键状态更新、查预建路由和有界投递;过滤/吞键决定必须在返回前完成,不能全部延后。 -2. 不在回调做进程名解析、图像识别、同步 UI 调用、等待工作线程或文件 IO。保持 delegate 有效并正确卸载。 -3. 若仅需标准组合热键,可独立比较 RegisterHotKey;录制/设备区分可比较 Raw Input;需要同步吞键时仍保留 LL hook。 -4. Raw Input 是另一种异步观测入口,不是 SendInput 的替代注入方法;不能假定它一定给出与 LL hook 完全相同的合成输入事件。 -5. 自身注入的过滤策略应显式定义:忽略所有 injected 事件简单,但会排除其他软件/辅助设备的合成输入。仅识别自身标记时也不能把标记当安全身份。 - -Raw Input 能提供设备相关信息并支持后台接收配置,但不意味着目标应用就会更快消费输入。[Raw Input 概览](https://learn.microsoft.com/en-us/windows/win32/inputdev/about-raw-input)。 - -### 4.4 SendInput 的瓶颈与优化 - -SendInput 成功返回的是写入输入流的事件数,受完整性级别限制;UIPI 导致的失败不会由返回值/错误码明确指出。单次输入数组保持内部序列不被其他键鼠输入插入,但不提供执行时刻参数,也不保证应用已处理。[SendInput](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput)。 - -优化应围绕自身可控成本: - -- 提前编码、复用 INPUT 缓冲;把同一时刻且语义可合并的事件做一次调用,减少调用次数。 -- 不把不同 deadline 的事件提前塞进一个数组;`INPUT.time` 也不是未来调度功能。批处理收益必须同时检查按住时长和目标程序识别率。 -- 键码/扫描码/Unicode 依语义选择;扫描码不是通用加速开关。鼠标相对/绝对移动、DPI、加速和移动合并需要独立验证。 -- 不持调度锁调用 API;否则输入回调、取消和其他宏可能被串行拖住。也不要让自己 hook 等待正在调用 SendInput 的线程。 -- 单次批次内部有序不代表多线程调用的跨宏顺序确定。需要显式的全局序号或冲突键仲裁。 -- 检查部分成功。不能盲目重放整批,否则可能重复按下;按已成功部分更新状态,停止/清理要有独立路径。 -- 不使用未公开 syscall 号绕过接口来赌性能;系统版本兼容性代价并没有消除目标程序排队。 - -### 4.5 驱动级输入与“硬件中断模拟” - -| 路径 | 可解决什么 | 新增代价与无法解决的部分 | 推荐 | -|---|---|---|---| -| SendInput | 标准桌面键鼠合成 | 权限、输入状态、hook、目标消费节奏 | 默认基线 | -| VHF 虚拟 HID | 按 HID 报告建模的虚拟设备、设备语义 | 内核驱动开发/签名/生命周期、报告缓冲;目标线程仍受 Windows 调度 | 有设备语义需求再做独立原型 | -| Interception 类过滤驱动 | 观察/变换设备输入,选择设备路径 | 驱动及 IOCTL 跳转、设备兼容、部署;用户态策略仍会被抢占 | 作为兼容性研究分支,先测再选 | -| MCU/FPGA USB HID | 主机外生成时间序列,减少主机触发依赖 | 主机轮询、USB 总线与输入栈仍在;PC 实时下发还新增传输延迟 | 预装计划/本地触发适用 | -| 实时设备的定时 GPIO | 控制输出引脚的确定性边沿 | 不等价于 Windows 应用按键,也不是显示响应 | 真正硬时限的独立控制场景 | - -VHF 官方支持由 HID source driver 提交报告;它并没有承诺键鼠响应的微秒上界。[VHF](https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/virtual-hid-framework--vhf-)。 - -USB 的 interrupt transfer 名字不意味着外设随时中断应用执行;主机按端点服务间隔轮询。1000Hz 的周期为 1ms,8000Hz 为 125µs;在随机相位、无其他排队的理想模型下仅轮询等待平均约半个周期。这是算术模型,不是某设备实测,也不能保证端到端小于该数值。[USB interrupt transfer](https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/how-to-send-a-usb-interrupt-transfer--uwp-app-)。 - -因此,“驱动级注入更低层,所以必然更快”“模拟中断即可绕过调度”均不是成立的性能论证。推荐用同一接收端对不同后端做配对实验。硬件端若要微秒级边沿,应采用硬件计时器/比较输出并测时钟误差;QMK 默认毫秒 deferred API 不足以直接承担该指标。 - -## 5. 论文解读:可借鉴模型与不可迁移前提 - -| 论文与出处 | 核心模型/发现 | MacroHID 落地方法 | 适用限制 | -|---|---|---|---| -| Liu & Layland, 1973, JACM, *Scheduling Algorithms for Multiprogramming in a Hard-Real-Time Environment*, DOI 10.1145/321738.321743 | 固定优先级 RM 与动态 EDF 的可调度性 | 按绝对到期时刻挑下一个小批次,避免长宏占着执行权 | 独立、可抢占、执行成本已知等假设,Windows 宏并不满足 | -| Sha, Rajkumar & Lehoczky, 1990, IEEE TC, *Priority Inheritance Protocols: An Approach to Real-Time Synchronization*, DOI 10.1109/12.57058 | 优先级反转及继承/天花板协议 | 缩短共享锁,数据所有权化,避免高优先级依赖低优先级长工作 | 本轮查阅作者机构摘要;不把 Windows 普通 mutex 当作已实现论文协议 | -| Abeni & Buttazzo, 1998, RTSS, *Integrating Multimedia Applications in Hard Real-Time Systems* | CBS 用预算/周期隔离执行超支 | 对 OCR/模板识别设置并发数、补充周期、最大挂起任务 | 应用层限额是借鉴,不具备内核 CBS 的带宽保证 | -| Ousterhout et al., 2019, NSDI, *Shenango* | 高频资源调整、专门数据路径,结合忙轮询与资源效率 | 让控制、识别与精密提交分离;仅在临近 deadline 占核心 | 专用 IOKernel/网络数据中心平台,不能移植为普通 Windows 用户态抢占 | -| Kaffes et al., 2019, NSDI, *Shinjuku* | 短任务被长任务阻塞导致尾延迟;借助特殊系统/硬件实现约 5µs 级抢占周期 | 把输入批次保持很短,识别分池;支持超时和协作取消 | 5µs 是其系统抢占周期,不是 Windows SendInput 延迟 | -| Humphries et al., 2021, SOSP, *ghOSt*, DOI 10.1145/3477132.3483542 | 内核机制与用户态调度策略分离,消息/事务式调度接口 | 拆出计划、等待、选择、输入后端策略;使用带代数/序号的状态变更 | 依赖 Linux 内核机制,不是 Windows 现成 API | -| Wimmer, Schmid & Bockes, 2019, CHI, *On the Latency of USB-Connected Input Devices*, DOI 10.1145/3290605.3300650 | 36 个设备的延迟分布;提高轮询率并非对所有设备都有效 | 单独测设备、主机、目标;报告分布而不只均值 | 论文具体设备数字不能外推本机/当前外设 | -| Fried et al., 2024, NSDI, *Making Kernel Bypass Practical for the Cloud with Junction* | 在兼容性和资源密度约束下设计 kernel bypass | 审视“为了最低延迟永久占核”的成本;保持后端可替换和退回路径 | 网络/Linux 架构启示,不是 Windows HID 的旁路实现 | - -原文/机构链接:[Liu–Layland 原文](https://bears.ece.ucsb.edu/class/ece253/papers/laylandliu77.pdf)、[优先级继承作者机构记录](https://experts.illinois.edu/en/publications/priority-inheritance-protocols-an-approach-to-real-time-synchroni/)、[CBS 原文](https://retis.santannapisa.it/~giorgio/paps/1998/rtss98-cbs.pdf)、[Shenango 原文](https://www.usenix.org/system/files/nsdi19-ousterhout.pdf)、[Shinjuku 原文](https://www.usenix.org/system/files/nsdi19-kaffes.pdf)、[ghOSt 作者公开原文](https://www.ofirweisse.com/sosp21-ghost.pdf)、[USB 延迟作者版本](https://epub.uni-regensburg.de/40182/1/On_the_Latency_of_USB-Connected_Input_Devices_author_version.pdf)、[Junction 会议页](https://www.usenix.org/conference/nsdi24/presentation/fried)。 - -在理想单核、独立周期任务、截止期等于周期、可抢占且无额外开销的模型中: - -\[ -U=\sum_i C_i/T_i,\quad U_{RM}\le n(2^{1/n}-1),\quad U_{EDF}\le1. -\] - -RM 的上式是充分界而非所有任务集的必要界。现实应加入非抢占区、锁阻塞、唤醒抖动和外部干扰。当前宏调度更接近“下一到期事件选择”,批次的到期时刻通常同时是释放时刻;不能把它严格等同于论文里的可抢占 EDF 作业调度,也不能拿 U≤1 宣告达标。 - -工程上可用 `服务时间/到达间隔` 估计负荷并留余量,但 P99 成本不是 WCET,经验占用阈值不是硬实时证明。其实际价值是提前拒绝过载、隔离重工作、减少排队。 - -CBS 的可迁移部分是用预算 Q 和周期 P 表达服务份额 `u=Q/P`,耗尽预算时延期后续工作,而不是让识别无限抢占输入资源。应用层可以限制每轮准入的识别数、累计 CPU 时间和并发槽位,但普通 OCR 库的一次调用往往不可抢占:设置“2ms 预算”不会让一个 20ms 调用在 2ms 自动停止。须采用可分片识别、独立工作进程或超时后丢弃过期结果,并报告已启动工作的实际完成成本。 - -## 6. 当前项目:已存在的能力与剩余风险 - -本次检查工作区含大量既有未提交更改;以下描述对应读取时内容,不对应某个纯净发布标签。文件快照哈希见证据目录。 - -| 模块 | 本次确认现状 | 应做增量优化 | -|---|---|---| -| `GlobalKeyboardHook.cs` | 独立 Dispatcher 消息线程、回调 QPC、注入事件过滤;事件同步调用订阅者 | 缩减订阅者在回调栈上的工作,测回调驻留分布 | -| `MainWindow.xaml.cs` 热键处理 | UI 更新已 BeginInvoke;控制器启动直接调用;前台过滤会查询进程名,存在 triggerGate | 不应再提“先把全部热键搬离 UI”作为未实现项;优先测前台查询、锁与启动链 | -| `MacroPlaybackController.cs` | 锁保护启动/停止,runId/取消/任务生命周期管理 | 投递与完成路径的分配和排队开销;保持重复触发、Hold/Toggle、停止 epoch 语义 | -| `CompiledPlaybackPlan.cs` / executor | 批次预编译、prepared/native plan 缓存,条件计划准备 | 测热缓存命中和失效,不重复实现预编译 | -| `QpcPlaybackDelayStrategy` | 已把自旋校准放入 Prepare 和缓存,构造函数不再每次校准;高精度等待 | 不再把“每次启动校准”列作当前事实;测不同核心类别缓存和等待回退 | -| `SharedPlaybackScheduler.inl` | 两条复用工作线程;每宏只挑下一批;vector 扫描;等待及提交不持队列锁;inFlight 防同批重复认领 | 扫描成本、全局 generation/Notify、同键跨宏顺序、helper 获益 | -| 原生等待模式 | 均衡末段 300µs,极限 1000µs;helper 比 owner 延后约 2µs 参与认领 | 把固定自旋/helper 改为经测量选择的策略实验,而非增加更多线程 | -| `PlaybackWorkers.cs` / lease | 工作线程复用;空闲保留限额,ParallelWorkerLease 对特定并行工作设 32 上限 | 空闲上限不等于全系统活动线程上限;条件线程和同时宏需单独准入 | -| `ConditionMonitor.cs` | 条件独立线程、绝对轮询目标、跳过已错过轮询;then 计划缓存 | 线程总量、帧新鲜度、条件预算;不做积压轮询补跑 | -| `MacroModel.cs` | 两种条件步骤默认轮询间隔 25ms | 这是条件反应的主要量级因素;不能只优化输入末端 10µs | -| `PixelFrame.cs` / evaluation cache | 已有 BGRA 缓冲和短期复用,识别结果合并窗口默认约 1ms | 区分抓帧时间和计算完成时间,测缓存命中对应的帧年龄 | -| `AppLog.cs` | 已有有界后台日志队列 | 不能声称启动路径同步写磁盘;仍可测字符串/节点分配、队列锁 | -| `PlaybackTrace.cs` | 有 Hotkey、Dispatch、Submit、Receiver 等 stage | 缺少足够的逐批次身份/字段时补齐;声明 Receiver 枚举不等于已有接收端测量 | -| `LatencyProbe` | 明确不发送真实按键;空批次/假输入用于探针 | 它的结果不能表示真实 SendInput 或目标应用接收延迟 | - -定位入口(仓库相对路径,便于复查): - -- `src/ui/MacroStudio/GlobalKeyboardHook.cs`:KeyboardHookCallback、TriggerPressed。 -- `src/ui/MacroStudio/MainWindow.xaml.cs`:KeyboardHook_TriggerPressed。 -- `src/ui/MacroStudio/Services/ForegroundProcessService.cs`:GetForegroundProcessName。 -- `src/shared/MacroHid.Runtime/MacroPlaybackController.cs`:StartRunCore。 -- `src/shared/MacroHid.Runtime/MacroPlaybackExecutor.cs`:QpcPlaybackDelayStrategy、Prepare、TryGetPreparedPlan。 -- `src/native/MacroHid.NativePlayback/SharedPlaybackScheduler.inl`:Due、Loop、Notify、Submit。 -- `src/shared/MacroHid.Runtime/ConditionMonitor.cs`:PollCore。 -- `src/shared/MacroHid.Runtime/RecentEvaluationCache.cs`:Get。 - -### 历史原始数据的本次复核 - -两份 CSV 的 SHA-256 与既有 provenance 一致。原试验是两个并发计划、每计划 50,000 个 1ms 批次;假输入回调、预热、申请与播放会话一致的计时器设置。原环境记录为 Ryzen 9 7945HX、Windows 11 build 26200;本次没有重新验证机器状态或重跑。 - -| 档位/计划 | N | CSV 整数 µs P50 | P95 | P99 | 按原始 QPC 重算 P99.9 | 原始 QPC 最大迟到 | -|---|---:|---:|---:|---:|---:|---:| -| 均衡 0 | 50,000 | 0 | 0 | 1 | 185.6µs | 1093.9µs | -| 均衡 1 | 50,000 | 0 | 0 | 1 | 3.7µs | 876.2µs | -| 极限 0 | 50,000 | 0 | 0 | 0 | 2.1µs | 644.0µs | -| 极限 1 | 50,000 | 0 | 0 | 0 | 2.1µs | 1071.4µs | - -采用 nearest-rank 分位数。表中 0 来自整数微秒列,**不是零延迟**。全部样本合并后均衡 P99.9 为 110.1µs,但合并会掩盖计划差异,因此不能只报告合并结果。以上每档仅一轮历史数据;没有真实注入、真实物理热键或跨设备验证。 - -原数据还需要注意统计口径:调度器记录相对 nominal due 的迟到,但 `Due()` 会加暂停、按键释放依赖及组合键保持约束。今后同时输出 nominal_due、effective_due,区分“语义要求推迟”和“调度器迟到”,避免误诊。 - -## 7. 可落地算法设计 - -### 7.1 任务队列分层 - -推荐在现有框架上形成三层职责,不预设必须重写为无锁: - -1. **触发/控制入口**:固定结构 `(macroId, epoch, sourceQpc, sequence, action)`;hook 单生产者路径可用 SPSC,有 UI/条件多来源时用各自 SPSC 汇聚或真正 MPSC,不能把多生产者硬套 SPSC。容量以最大预期突发定义;满队列时明确拒绝并计数,停止信号走独立原子标志/唤醒。 -2. **时间队列**:每个 active run 保存下一批,不展开全部循环事件;候选键为 `(effectiveDue, stableSequence)`。小 N 先保留连续数组扫描,大 N 经测量再切小根堆。暂停、取消、依赖变化必须更新堆或带 generation 做失效过滤,并限制陈旧节点数量。 -3. **输入提交/仲裁**:决定同键和修饰键冲突后才提交;不可丢 key-up。输出日志/统计用有界缓冲,后台批量处理。 - -| 结构 | 成本 | 实际取舍 | -|---|---|---| -| 连续数组线性扫描 | 选取 O(N) | N 很小时局部性好、更新简单,当前实现可能已经更合适 | -| 二叉最小堆 | 入/出 O(log N) | 活跃宏较多时适用;处理暂停/取消/同刻顺序复杂 | -| 时间轮 | 平均摊销可很低,精度受桶宽/设计影响 | 大量粗粒度定时器适用;少数精密宏未必有收益 | -| FIFO 触发队列 | 常数级入出 | 只表示到达顺序,不自动满足 deadline 优先 | -| 每宏一条精密线程 | 易写局部逻辑 | 多线程自旋、切换、同键交错;不作为扩容方向 | - -先比较 1/2/8/32 活跃宏;如产品确实支持更大规模,再比较 128/256。不要只用渐进复杂度推断 2 个宏时堆更快。 - -### 7.2 绝对时间轴与迟到策略 - -周期保持 `d(k)=t_origin+k*T`,不要在每次执行后重置为 `now+T`,否则开销累积成漂移。将目标时刻和动作允许语义分开: - -\[ -d_{effective}(k)=\max(d_{nominal}(k)+pause, -t_{dependency}+hold_{min}). -\] - -赶不上时间时明确选择:短暂迟到的离散按键按序执行并维持必要 hold;过期鼠标轨迹可在声明允许时合并;识别任务只处理最新帧;过载严重时终止宏并释放持有键。**不能统一用“追赶补发”造成按下/抬起挤在一起,也不能统一丢弃过期按键。** - -### 7.3 自适应阻塞+自旋 - -令 W 为预留自旋窗口,e 为计时器实际恢复减去计划唤醒时刻的正向超时量,h 为唤醒后选取/认领的开销。工程实验模型: - -\[ -W=clip(Q_p(e+h)+margin, W_{min}, W_{max}). -\] - -在后台统计最近窗口,缓慢调整,空闲/恢复/电源状态改变后重建样本;按档位、核心类别分别学习。p、margin 和上下限均为试验参数,不是定理。离群事件需要记录,不能用无限加大 W 来掩盖 DPC 或系统停顿。 - -```text -接收控制命令,检查取消/暂停/generation -取当前最早有效批次,计算 effective_due -若 remaining > W:设置相对高精度 timer,等待 timer 或更早任务/取消事件 -否则:短自旋并检查变化;不早于 effective_due -再次校验计划与输入冲突,认领一次 -锁外提交输入,记录 submit_begin/submit_end/返回数量 -更新状态,只唤醒确实需要重新选任务的 worker -``` - -W=300µs、周期 1ms 时,单 worker 的末段自旋就可能接近一个逻辑核的 30%;W=1000µs 时可能接近持续占核。此处仅是忽略其他开销的上限量级估算,双 worker 是否同时自旋由实际负载决定,必须测 CPU time。更小调度误差若换来更差帧时间,产品价值可能下降。 - -### 7.4 控制双 worker 的收益与副作用 - -当前极限模式 helper 可在 owner 之后约 2µs 参与,`inFlight` 在锁内保护同批认领。保留这层正确性,不允许两条线程试发同一按键再择优。 - -实验比较:单提交线程;当前双 worker;延迟更大的 helper;只在有历史长尾风险时启用 helper。采集 helper win 比例、同批认领竞争、锁等待、唤醒次数、跨核心缓存流量及目标帧时间。每次提交后的全局 Notify 会使其他 worker 重新检查 generation;改成按 owner/更早 deadline 唤醒时,必须验证无丢失唤醒、暂停/取消及时生效以及新任务可以打断旧等待。 - -跨宏同键的状态不能由每宏独立字典模拟成独立物理设备。可选策略:冲突键串行租约、拒绝冲突宏、明确的共享按住引用计数。引用计数会改变脉冲/重复语义,不能静默替换。建议默认保守串行冲突,保留用户可见的冲突策略。紧急停止只释放本程序实际拥有的状态,并验证与物理按键同时按下的行为。 - -### 7.5 条件检测:优化更大的延迟项 - -默认 25ms 轮询,在事件相位均匀、计算足够快且未错过轮询的理想条件下,单轮询等待平均 12.5ms、P99 约 24.75ms。**这是模型推导,不是当前系统测量;识别和抓帧只会加入更多成本或改变分布。** - -推荐: - -- 像素/小 ROI、模板、OCR 分不同预算池;输入 deadline worker 不做识别。 -- 暂停语义允许等待识别时,应把这种等待单独计入控制流耗时,不能从用户观察延迟中删除。取消不支持中断的识别调用时,先阻止其结果启动新输入,再异步回收资源。 -- 每个检测源最多一个正在执行和一个最新待处理任务,防止旧帧排长队;不能对键盘动作应用这种覆盖规则。 -- 记录 `frameCapturedQpc`、`evaluationBegin/End`、`cacheAge`,共享抓帧和识别结果时检验来源帧的新鲜度。 -- 逐步实验像素轮询 25/10/5/2ms,模板/OCR 按实际服务时间设限,避免全量一律改 1ms。 -- 提供受控事件源时优先事件驱动;屏幕识别仍受应用帧更新、捕获和识别速度限制。 -- 图像已有 BGRA 优化和缓存,先测全链路,不重做已完成的每像素对象转换优化。 - -## 8. 分阶段实施指南与验收门槛 - -下列数字是拟议工程目标/决策门槛,不是已实现结果;具体机器不满足时报告失败并保留原始记录。 - -| 优先级 | 改动与位置 | 验证与目标 | 预期收益依据 | -|---|---|---|---| -| P0 | 扩展 PlaybackTrace、Native run trace;新增受控接收端与统一关联 ID | 每批具有 runId/batchId、nominal/effective due、submit_begin/end、接收时间/缺失原因;测量模式开关开销可量化 | 消除把 API 返回当接收、把 hold 约束当迟到等误判 | -| P0 | 热键路径分段计时;前台信息维护快照/必要复核;准备数据移出回调 | 建议 hook 自身工作 P99≤50µs;热启动按现有档位目标验收;禁止同步重 IO | 查询进程名和锁是源码确认的候选成本,收益待测 | -| P1 | SharedPlaybackScheduler 定向唤醒、减少无效 generation 变化 | 双宏 1ms、8/32 宏突发;无丢唤醒/重复提交;P99.9 不退化;以唤醒数和 CPU 下降确认 | 当前每批 Notify 两个 event,存在减少冗余机会 | -| P1 | 单/双 worker 与自适应 W 的 A/B | 均衡迟到 P99.9≤250µs,极限≤100µs;同时报告 >1ms 事件率和最大值 | 沿用现有目标;重点稳定长尾和减少资源占用 | -| P1 | 同键/修饰键仲裁、过载与迟到政策 | 交错 down/up、部分成功、取消竞态、物理按键介入;零可观察重复认领/粘键 | 正确性决定微秒优化是否有效 | -| P1 | ConditionMonitor 统一预算/帧年龄/最新任务策略 | 像素、模板、OCR 分别报告检测→提交;同场景减少轮询间隔须不破坏帧时间预算 | 25ms 默认轮询是明确的大量级成本 | -| P2 | 数组扫描 vs heap;预分配 run/计时缓冲;托管工作准入 | 用真实 N 和到达模式找交叉点;记录 hot path bytes/op、GC 停顿、队列容量 | 限制规模增长的成本,不能预设小根堆获胜 | -| P2 | CPU Sets/亲和性拓扑扩展、MMCSS 适配 | Ryzen/混合核 Intel、SMT、多处理器组、游戏负载下 A/B | 当前 32 逻辑处理器证据不覆盖所有拓扑 | -| P3 | 可选 VHF 或外部 HID 后端 | 同接收端、同逻辑动作下比较成功率、P99.9、维护/功耗成本 | 只有测出目标路径获益才进入产品默认选项 | - -实施顺序:先 P0 建基线和入口拆分,再分别做 P1 小改;每个改动独立提交和 A/B,避免同时改 timer、亲和性、队列后无法归因。P2 根据剖析触发,P3 单独验证。预计拆为 6–10 个可评审变更包;这是范围拆分,不是交付工期承诺。 - -现有策略中有历史枚举命名兼容:`PrecisionMode.Balanced` 在 policy 中映射低占用策略,`ExtremeDuringPlayback` 为界面均衡,`UltraLowJitter` 为极限。实施时以 `PlaybackPerformancePolicy.For()`、UI 标签和实际配置共同核对,不按枚举名字猜配置,也不要破坏已保存宏。 - -建议游戏体验门槛:固定场景下与不运行宏比较,P99 帧时间相对退化≤3%,并报告绝对毫秒变化、平均 FPS、CPU time 和功耗;3% 是本报告提出的初始预算,不是通用标准。系统/采样噪声接近预算时增加重复,不把不显著差异解释为提升。 - -## 9. 性能基准设计:能够复现,也能定位 - -### 9.1 四层基准 - -| 层 | 实验 | 需要的证据 | 不能声称 | -|---|---|---|---| -| A 算法 | 合成时钟验证顺序/取消/过载;实际 QPC+假 sink 测调度 | 原始 deadline、认领/提交时间、CPU/锁等待 | 真实键鼠延迟 | -| B 注入 | 前台受控窗口接收带唯一序号的 SendInput | 实际 INPUT 数量、返回数、目标消息处理 QPC、缺失和重复 | 物理设备延迟或任何游戏延迟 | -| C 应用 | 固定目标程序回放、接收或动作响应记录,加 PresentMon/ETW | 同场景重复、应用消费证据、帧时间 | 仅凭帧率 CSV 推断 input-to-photon | -| D 物理 | 设备触发+逻辑分析仪/USB 分析仪+光电传感器 | 时钟校准、触发边沿、显示光学响应 | 不经时钟同步就相减 MCU 和 QPC 时间戳 | - -受控接收端可由同机器 QPC 统一时基。`dwExtraInfo` 可作为实验关联手段,但必须在目标接收路径实测保留情况;Raw Input、窗口消息和 hook 测得的端点不同。丢失、合并、乱序事件独立计数,不能从延迟表中无声删除。PresentMon 适合帧呈现指标与联合分析,单独使用不证明每个宏事件的因果显示延迟。[PresentMon 项目](https://github.com/GameTechDev/PresentMon)。 - -### 9.2 工作负载与对照 - -- 节奏:100µs、250µs、1ms、4ms、16.667ms、100ms,加突发和随机到达;极密节奏只用于调度能力,不意味着应用可辨别同样短的按键。 -- 并发:1、2、8、32 宏;同键/不同键、长等待旁短宏、暂停/恢复、取消中提交。 -- 条件:纯输入;小 ROI 像素;模板;OCR;识别与播放混合。 -- 环境:空闲、CPU 受限、GPU 受限、磁盘/网络活动;前台/遮挡;冷启动/预热;插电/电池;固定分辨率、刷新率、应用版本、驱动、Windows build。 -- 线程策略:单/双 worker;固定/自适应 spin;默认调度/指定核心;MMCSS 开/关。一次只改变一个变量。 -- 外部工具对照:AHK、Kanata、至少一个商业录制器;相同输出逻辑、按住时长、目标窗口和成功率。公布原脚本与默认延时,既测试开箱配置也测试合理调优配置,不取消安全停止功能来美化数字。 - -### 9.3 统计规则 - -每个正式场景至少五轮交替或随机化顺序,每轮十分钟;每方案有效事件不少于十万。低频负载十分钟达不到事件量时延长采样,不能复制样本凑数。P99.9 在十万样本只有约百个尾部观察值,仍需要多轮;不同计划分别报告,不只报混合分布。 - -报告 N、P50/P95/P99/P99.9/max、有符号最小值、超 100/250/1000µs 比例、遗漏/重复/取消耗时、CPU 核秒、分配量、GC、上下文切换、温度与功耗。分位数方法固定为 nearest-rank 或明确指定算法。对重复运行给出逐轮值和区间;序列相关时使用分块方法,不能假装所有事件独立。 - -避免 coordinated omission:测试发生停顿后仍应保留所有预定 deadline;不能只在上次完成后才生成下一次请求,从而丢掉拥堵期间本应出现的延迟。条件任务的 latest-only 策略应记录被覆盖帧数,它属于产品策略,不等于测试丢样。 - -计时器/ETW/记录本身有开销:预分配二进制记录缓冲,离线计算;分别测记录关闭、仅 QPC、完整事件跟踪。事件丢失、缓冲满和追踪开销写入结果。针对最大离群事件,以 runId/batchId 对齐 CSwitch、ReadyThread、DPC/ISR、缺页、GC 和 CPU 迁移;ETW 无法解释的停顿保留“未知”,不能一概归咎于 SMI。 - -### 9.4 项目现有工具和补齐项 - -现有入口包括 `src/tools/LatencyProbe`、`tests/MacroHid.Scheduling.Benchmarks`、`tests/MacroHid.NativePlayback.Tests`、`scripts/Trace-LatencyOutliers.ps1`、`scripts/compare_frame_times.py`。调度基准目前输出项目和样本数有限,需要补齐 P99.9/max、逐事件关联和四层边界。`Scheduling.Benchmarks` 中 probe 是假输入,不能改个标题称真实接收测试。 - -每次运行建立证据包:源码工作区指纹、EXE/DLL SHA-256、命令参数、系统和电源配置、启动时间、原始事件文件、ETL、PresentMon CSV、汇总脚本版本及统计 JSON。当前工作区有未提交修改时,只记 git commit 不足以定位被测版本。 - -正式验收必须同时过三道门:①输入语义/取消/资源正确;②分位数、超限率和最大值透明;③目标应用成功率及帧时间不退化到预算外。**不能用空输入路径的 2µs 分位数,替代真实 SendInput、真实热键或真实游戏验收。** - -## 10. 决策建议 - -本项目值得继续采用“托管编排+少量原生精密执行线程”的结构。当前最有价值的工作是补齐真实接收证据,缩短 hook 同步调用链,减少调度唤醒竞争,给条件识别设预算,并明确跨宏输入状态语义。堆、时间轮、无锁、驱动和外部硬件都属于按证据选择的手段,不是精度保证。 - -将产品承诺写成可检验的形式:**在列明机器、系统、负载和宏语义的条件下,指定档位调度迟到 P99.9 达到目标,同时公布最大迟到、超限率、真实接收延迟和资源代价。** 若需要硬时限,则另立受控实时硬件目标,并明确它与 Windows 目标应用响应的边界。 diff --git a/docs/single-macro-optimization-2026-10-14.md b/docs/single-macro-optimization-2026-10-14.md deleted file mode 100644 index 3be6bdf..0000000 --- a/docs/single-macro-optimization-2026-10-14.md +++ /dev/null @@ -1,87 +0,0 @@ -# 单宏执行路径优化与验证 - -本页保留第一轮测量。后续已完成最终版本的物理热键与 Win32/WPF 真实输入接收,并进行了固定核心五轮尾部对照,见 [单宏尾部与端到端实测](single-macro-tail-and-end-to-end-2026-10-14.md)。下文的真实接收未完成状态属于第一轮历史记录。 - -日期:2026-10-14。当前阶段聚焦单个预编译键鼠宏,保留暂停、取消、按住时长与输入安全语义。 - -## 实际改动 - -- 原生执行器在单宏适用场景下认领整个连续段,一条线程连续推进,不为每个批次扫描队列、获取调度锁或通知两条线程。 -- 名义截止、实际按下后的最短按住时间、修饰键保持约束仍沿用原语义。控制事件或新任务到达时,通过 generation 变化退出连续段,交回已有调度器。 -- 修饰键边沿在创建预编译计划时计算一次,执行阶段直接读取。 -- 复用统计工作区时取消对整个提交时间数组的清零。释放依赖只引用本次已执行的前序批次,其时间戳先写后读。补充了同一计划连续复用且迟到启动的按住时长回归。 -- 单宏原生均衡按实际 timer 唤醒超时量,在每次运行内将末段自旋窗口有界提高至最多 800µs;初始为 300µs。控制事件不参与学习。 -- 极限定时事件保留辅助线程;只有相邻名义批次间隔≤20µs、当前有效截止已临近的密集段使用连续路径。 - -单宏并不要求只使用一条内部线程。首轮对照显示,极限 1ms 节奏若完全关闭辅助线程,虽然 CPU 时间减半,但 P99.9 从约 3–5µs 上升至 22–87µs,因此最终版本保留定时事件的辅助抢救。不能把这一被否决的原型结果当作最终性能。 - -## 基准口径 - -`--single-benchmark` 使用测试专用假输入回调,不向桌面发送键鼠。预热完成并创建计划后,计量一次运行;CPU 时间为测试进程的 kernel+user CPU time,wall time 包含等待完成事件。运行结束后再计算统计和写 CSV,不将导出开销计入执行时间。 - -1ms 节奏为五轮交替顺序、每轮 20,000 批次,每方案每档累计 100,000 样本。每轮约 20 秒,不是五轮各十分钟的完整场景验收。零等待密集段每次 100,000 批次,衡量调度吞吐;所有批次同一名义截止,因此其 P99.9 随累计执行时间增长,不作为周期抖动指标。 - -原始数据、基线/最终测试 EXE、日志位于工作区 `tmp/single-macro-20261014`。基线 EXE 在修改执行器前编译并保存。已有工作区修改不回退,原生产 ABI 和宏配置枚举不变。 - -## 本次结果 - -本机:AMD Ryzen 9 7945HX、16 个物理核/32 个逻辑处理器,Windows 11 build 26200,电源方案 Turbo。没有主动施加 CPU/GPU 压力,也没有证明系统完全空闲;温度和频率未锁定。极限每个新进程按原准备流程测核,不固定同一对核心,因此不能将所有最大值变化归因于某一个代码改动。 - -### 1ms 单宏:五轮,每方案每档累计十万事件 - -下表分位数为五轮各自结果的最小–最大范围,最大值及超限数量为全部十万样本。使用原始 QPC、nearest-rank 分位数,不采用原生汇总中的整数微秒截取。单位均为微秒。 - -| 档位/版本 | P50 | P95 | P99 | P99.9 | 全部样本最大迟到 | >1ms 数量 | -|---|---:|---:|---:|---:|---:|---:| -| 均衡/基线 | 72.1–78.4 | 265.9–315.5 | 373.4–420.1 | 477.3–537.5 | 1669.4 | 4 | -| 均衡/最终 | 0.0 | 0.2–0.3 | 0.6–0.7 | 12.3–46.9 | 1428.3 | 2 | -| 极限/基线 | 0.1–0.2 | 0.5–0.6 | 1.0–1.8 | 2.8–6.4 | 1989.6 | 9 | -| 极限/最终 | 0.1–0.2 | 0.2–0.6 | 0.6–1.2 | 2.7–3.6 | 287.9 | 0 | - -QPC 本次频率为 10MHz,0.0µs 表示采样端点落在同一 tick,不表示键鼠响应或执行耗时为零。所有最终定时样本均未观察到早于名义截止的提交、缺失或批次乱序。输入数量是测试回调的逻辑计数,未验证目标应用收到输入。 - -均衡的尾延迟改善有资源代价:每轮约 20 秒内,进程平均 CPU 时间从 1621.9ms 增至 9446.9ms,约从 0.08 到 0.47 个逻辑核心。极限约为 1.99 个逻辑核心,基本不变。它们是测试进程的消耗,不是全机 CPU 百分比。尚无游戏 FPS/帧时间或功耗收益结论。均衡最大迟到仍超过 1ms;极限本次零个 >1ms 事件也不是最坏时限保证。 - -### 零等待密集段:每次十万批次,三轮 - -| 档位 | 基线平均完成耗时 | 最终平均完成耗时 | 完成耗时降低 | -|---|---:|---:|---:| -| 均衡 | 148.64ms | 13.84ms | 90.7% | -| 极限 | 95.60ms | 16.69ms | 82.5% | - -这表示假输入调度和记录的吞吐提升,不表示 SendInput 能在同样时间发送并被应用处理十万次输入,也不代表 14–17ms 的周期抖动。短试验的进程 CPU 时间粒度较粗,不以其计数推导微秒级 CPU 成本。 - -详细逐轮统计及原始 CSV 的 SHA-256 见 `tmp/single-macro-20261014/summary.json`;生产 DLL、交付文件和源码指纹见 `provenance.json`。汇总可复现: - -```powershell -python scripts/summarize_single_macro.py tmp/single-macro-20261014 -``` - -### 功能与交付 - -- Release 原生构建:0 警告、0 错误;解决方案构建:0 错误、12 个现有警告(依赖兼容及测试可空类型)。 -- 托管测试:388 项通过。原生回归通过,包括单宏运行中暂停、迟到启动的同计划复用、取消、部分失败、资源保留与退出重启;原有并发回归只用于确认兼容性。 -- 本地测试版:`build/single-macro-20261014/MacroStudio/MacroStudio.exe`,配套原生 DLL 与生产构建 SHA-256 相同,DLL 加载、预热和关闭检查通过。当前进程不会自动切换为新 DLL,使用测试版需启动这个新输出。 -- 真实接收探针因未取得前台焦点在发送前中止。物理热键、真实 SendInput 接收、长时间游戏负载与条件识别混合场景仍需独立验证。 - -## 可复现入口 - -正常回归及单宏假输入: - -```powershell -.\tests\MacroHid.NativePlayback.Tests\x64\Release\MacroHid.NativePlayback.Tests.exe -.\tests\MacroHid.NativePlayback.Tests\x64\Release\MacroHid.NativePlayback.Tests.exe --single-benchmark 1 20000 1000 balanced.csv -.\tests\MacroHid.NativePlayback.Tests\x64\Release\MacroHid.NativePlayback.Tests.exe --single-benchmark 2 20000 1000 extreme.csv -``` - -参数依次为模式(1=原生均衡,2=极限)、批次数、间隔微秒、CSV 路径。快速路径回退开关须在进程启动前设为 `MACROHID_DISABLE_SINGLE_RUN_FASTPATH=1`;它用于隔离连续路径,不等于完整恢复优化前全部源码。 - -真实输入探针需要在本机交互桌面运行: - -```powershell -.\tests\MacroHid.NativePlayback.Tests\x64\Release\MacroHid.NativePlayback.Tests.exe --receiver-benchmark 1 10000 1000 receiver.csv -``` - -探针创建自己的测试窗口,只在该窗口为前台时发送带序号的 F24 按下/抬起。等待前台最多五秒,必要时点击测试窗口;切换窗口会使后续注入停止。CSV 包含计划时间、调度时间、SendInput 进入/返回、接收回调、实际发送数量和收到次数。目标处理可能早于 API 返回,分析时用 receiver−submit_begin,不能要求 receiver−submit_end 必须非负。 - -本次非交互启动没有取得测试窗口前台焦点,探针在发送前中止。因此没有真实接收延迟结果,也未完成游戏/物理热键/光学响应验收。 diff --git a/docs/single-macro-tail-and-end-to-end-2026-10-14.md b/docs/single-macro-tail-and-end-to-end-2026-10-14.md deleted file mode 100644 index 94da58a..0000000 --- a/docs/single-macro-tail-and-end-to-end-2026-10-14.md +++ /dev/null @@ -1,126 +0,0 @@ -# 单宏极限档尾部优化与真实输入接收实测 - -本轮保留原生工作线程的预配置,避免每次孤立热键触发时重新注册 MMCSS 和设置线程亲和性;热键专用线程使用 AboveNormal。两项更激进的调度改写经对比后撤回。完成最终版本的 **30 次物理 F8 → 独立 Win32 目标接收**,以及 Win32/WPF 各 2,000 次自动输入接收测试,最终版本共 4,030 个有效样本,无丢失、重复、失败或焦点拒绝。 - -固定核心的五轮调度对照中,最大迟到由 1,230.4µs 降至 893.4µs,约下降 27.4%;但这只是本轮观察值,第四轮更差,不能声称获得稳定的最大迟到上界。真实接收仍是毫秒级,未实现微秒级端到端响应。 - -## 保留与撤回的改动 - -| 改动 | 处理 | 原因与范围 | -|---|---|---| -| 原生线程初次预热时先配置 MMCSS、优先级、亲和性,再报告就绪 | 保留 | 把首次提交前的配置工作移到准备阶段 | -| 工作线程空闲时保留已配置状态,阻塞等待事件 | 保留 | 后续孤立触发不重复注册/撤销 MMCSS;模式变化和关闭时恢复。空闲时不自旋 | -| 热键专用线程 AboveNormal | 保留 | 减少钩子线程受普通任务排队的影响;未调整整个进程优先级 | -| 双工作线程以原子令牌连续推进单个计划,取消逐批队列锁与通知 | 撤回 | 固定核对照最大值 1,933.4µs,对照 1,202.3µs;不能因启动更快就接受尾部退化 | -| 单宏备用线程取消 2µs 救援余量,立即竞争 | 撤回 | 同轮合并 P99.9 为 3.4µs,预热方案为 2.7µs;最大值也更大 | - -继续保留上一轮的单宏均衡连续路径、极密集极限批次路径、预计算组合键边界及诊断工作区复用。间隔较大的极限批次仍使用原有两线程救援方式,只有一个宏,不增加并行宏功能。已就绪引擎若后来改变准备核心集合,运行线程仍在选取计划时切换配置;不能把初次预配置效果推广到所有重新配置情形。 - -## 调度器的固定核心对照 - -环境:Windows 11 10.0.26200,AMD Ryzen 9 7945HX,16 物理核、32 逻辑处理器。采集时电源方案为 Turbo。没有建立受控游戏/GPU负载,也没有改变驱动、系统注册表或进程优先级。 - -每个方案五轮,每轮 20,000 个批次,间隔 1ms,共 100,000 个事件;方案顺序轮换。限定处理器集合为 `0x50000000`,即逻辑处理器 28、30,原生准备检查通过其属于不同物理核心。测核可能交换两线程的顺序。采用相同的 0.5ms 计时器分辨率请求、假输入回调与 QPC 记录方式。 - -这里测量的是计划到期时间至输入提交标记的误差,**不是实际 SendInput 或目标接收耗时**。分位数使用原始 QPC 差值及 nearest-rank 方法,保留首批与全部异常值。 - -| 方案 | P50 | P95 | P99 | P99.9 | 最大 | >100µs | >1ms | -|---|---:|---:|---:|---:|---:|---:|---:| -| 本轮改动前 | 0.1µs | 0.4µs | 1.0µs | 2.8µs | 1,230.4µs | 17 | 1 | -| 最终保留:预配置与空闲保留 | 0.1µs | 0.5µs | 1.0µs | 2.7µs | 893.4µs | 11 | 0 | -| 撤回:立即救援竞争 | 0.2µs | 0.4µs | 0.8µs | 3.4µs | 1,189.5µs | 16 | 1 | - -各方案均无提前提交、漏批或重排。逐轮最大迟到(µs): - -| 轮次 | 改动前 | 最终保留 | 立即竞争 | -|---|---:|---:|---:| -| 1 | 859.0 | 278.8 | 1,189.5 | -| 2 | 1,230.4 | 34.6 | 66.3 | -| 3 | 224.1 | 200.4 | 888.8 | -| 4 | 316.7 | 893.4 | 122.2 | -| 5 | 977.4 | 776.3 | 259.2 | - -最终方案四轮的最大值更低,第四轮退化。启动到首次提交的原生耗时为 13–31µs,对照 49–54µs;CPU 消耗分别约 1.991 和 1.987 个逻辑核心,极限档持续 1ms 节奏仍接近占用两个核心。这不是低占用方案。 - -上一轮报告的 287.9µs 最大值来自不同时间和自动选择的核心,不能与这里的 893.4µs 拼成同条件前后对比。本轮也有自动选核探索结果及更差的异常值,全部保存在证据目录。没有通过长时间、负载矩阵和跨设备验收。 - -## 真实热键与接收程序 - -测试工具复用生产 `GlobalKeyboardHook` 源码和真实 `MacroPlaybackController → MacroPlaybackExecutor → 原生引擎 → SendInput`。目标是工具启动的另一进程,分别用普通 Win32 `GetMessage` 循环或 WPF `HwndSourceHook` 记录实际 `WM_KEYDOWN(F24)`。宏为 F24 按下、等待 5ms、抬起,固定执行一次,不执行识别或游戏效果。仅当自己的接收窗口为前台时才允许发送。 - -每次触发都等待宏结束和目标实际接收,并检查收到次数、运行成功标志及原生后端;不把 SendInput 返回当作接收。预热十次无热键播放,不计入样本。物理触发通过用户实际按下/松开 F8;自动组仅在独立探针中允许专属标记的注入 F8,生产程序继续拒绝注入热键。 - -测量起点为 **F8 低层钩子回调入口**,终点为目标窗口的 F24 消息回调。未测机械开关闭合、USB轮询、驱动上报至钩子入口的物理链路,也未测游戏逻辑、画面或显示器响应。独立探针直接调用控制器,未覆盖完整主窗口的前台进程筛选和状态刷新。同步 EventSource 与共享内存记录存在测量开销。 - -### 最终版本结果 - -| 触发与目标 | N | P50 | P95 | P99 | P99.9 | 最大 | -|---|---:|---:|---:|---:|---:|---:| -| 物理 F8 → Win32 接收 | 30 | 3.461ms | 5.366ms | — | — | 8.708ms | -| 标记注入 F8 → Win32 接收 | 2,000 | 4.336ms | 5.752ms | 6.578ms | 9.519ms | 10.455ms | -| 标记注入 F8 → WPF 接收 | 2,000 | 4.589ms | 6.682ms | 8.650ms | 12.039ms | 16.680ms | - -30 次物理样本不足以估计 P99/P99.9,不用其最大值冒充分位数结论。首个物理样本是全组最大值,钩子至分发耗时 3.663ms;其余 29 次最大端到端为 5.366ms。这提示首次热键路径仍需诊断,但未通过运行时追踪证明具体由 JIT、GC 或其他因素引起,正式统计保留首样本。 - -### 分段诊断(最终版本,µs) - -下表为 Win32 自动组 N=2,000。每段分别排序;分位数和最大值不能直接相加。 - -| 段落 | P50 | P95 | P99 | P99.9 | 最大 | -|---|---:|---:|---:|---:|---:| -| 源 F8 SendInput 开始 → F8 钩子入口 | 457.4 | 717.8 | 1,413.9 | 1,849.2 | 2,073.9 | -| F8 钩子 → 托管工作线程 | 79.4 | 110.1 | 141.7 | 306.2 | 330.3 | -| F8 钩子 → 原生首次提交标记 | 296.1 | 401.5 | 615.5 | 2,386.7 | 4,981.8 | -| 原生首次提交标记 → F24 钩子入口 | 244.9 | 439.1 | 603.7 | 988.8 | 1,649.9 | -| F24 钩子入口 → CallNextHookEx 返回 | 870.6 | 1,357.6 | 1,814.3 | 2,717.5 | 3,968.0 | -| CallNextHookEx 返回 → 目标 WM_KEYDOWN | 2,935.3 | 3,883.8 | 4,702.8 | 7,238.7 | 8,722.5 | - -WPF 自动组最后一段 P50=3,090.4µs、最大=14,840.1µs。两个目标均有明显的钩子返回后等待,因此不能只归因于 WPF。需要继续区分 Windows 输入路由、目标线程就绪/调度、消息队列和负载影响。钩子入口至转发返回还包括探针本身的少量记录工作,不能把整段直接归为其他应用钩子的耗时。 - -`native_api_total_us` 是按下与抬起两个提交调用耗时之和,不是首个 API 的独立耗时。`submit_qpc` 在原生调用前取样,包含紧随其后的处理器查询等工作。`max_scheduler_late_us` 为名义时间轴迟到;键抬起为保证实际按住时间,会顺延至按下提交结束后的最小保持时间,因此不把该字段当作有效截止时间误差。 - -### 探索阶段结果也保留 - -| 文件 | 结果与用途 | -|---|---| -| `end-to-end-final.csv` | 旧候选版 30 次物理输入全部有效,最大 9.731ms;随后自动组因最后热键仍按住而超时,整条命令没有通过。已修复等待最终物理松开再进入自动组 | -| `end-to-end-synthetic.csv` | 旧候选版 2,000 次自动输入通过,最大 133.639ms | -| `end-to-end-observed.csv` | 增加输出钩子记录及热键线程优先级后的旧候选版 2,000 次通过,最大 24.065ms | - -不同阶段的版本、记录开销、时间与负载不同。不能把 133.639ms→16.680ms 全部归因于最终算法,也不能将探索版物理样本当作最终版本验收。本轮最终 Win32 与 WPF 组也是顺序执行,并非交替多轮目标比较。 - -## 回归、证据与运行方法 - -- 最终 Release 原生 DLL 构建:0 警告、0 错误;解决方案构建:0 错误、11 个现有依赖兼容性警告;探针构建:0 错误、4 个依赖兼容性警告。 -- 托管测试 388 项通过。原生回归通过,增加了均衡与极限单宏运行中暂停验证;保留取消、输入部分失败不重放、计划/控制器引用保留、批次不重复和退出重启检查。 -- 新运行输出:`build/single-tail-20261014/MacroStudio/MacroStudio.exe`。生产、探针、交付目录中的原生 DLL 校验值一致;旧输出保持原样。现有已启动进程需重新启动该输出才能使用新版本。 -- [调度器原始统计及逐轮哈希](../tmp/single-tail-20261014/scheduler-summary.json)、[撤回双线程连续路径统计](../tmp/single-tail-20261014/rejected-dual-summary.json)、[全部真实输入统计及 CSV 哈希](../tmp/single-tail-20261014/end-to-end-summary.json)、[最终文件与环境指纹](../tmp/single-tail-20261014/provenance.json)。原始 CSV、各方案 EXE、调度器源码快照和日志在同目录,本地证据不作为随发行版附带的性能承诺。 - -重跑最终版本单宏调度(参数末尾为可选核心集合): - -```powershell -.\tests\MacroHid.NativePlayback.Tests\x64\Release\MacroHid.NativePlayback.Tests.exe --single-benchmark 2 20000 1000 single.csv 0x50000000 -``` - -重跑完整物理热键与真实接收(先构建独立探针): - -```powershell -dotnet build src/tools/EndToEndProbe/EndToEndProbe.csproj -c Release -.\src\tools\EndToEndProbe\bin\Release\net8.0-windows\EndToEndProbe.exe --physical 30 --automatic 2000 --receiver-kind win32 --timeout 300 --output physical-win32.csv -``` - -接收窗口就绪后按下/松开 F8,每次间隔约半秒;自动组期间保持窗口前台。纯自动组使用 `--physical 0`,目标可用 `--receiver-kind wpf`。失焦或缺少接收会停止并保留失败记录。不要在测量期间同时构建、跑其他基准或切换窗口。 - -重新汇总: - -```powershell -python scripts/summarize_tail_comparison.py tmp/single-tail-20261014 --log paired-eager.log --pattern '{variant}-eager-round{round}.csv' --output scheduler-summary.json -python scripts/summarize_end_to_end.py tmp/single-tail-20261014/end-to-end-release-win32.csv tmp/single-tail-20261014/end-to-end-release-wpf.csv --output tmp/single-tail-20261014/final-input-summary.json -``` - -## 下一步的具体优化顺序 - -1. 对最慢样本同步采集线程 Ready/CSwitch、DPC/ISR、GC/JIT 和输入事件,将钩子线程、原生两工作线程及目标线程放在同一条时间轴。先定位毫秒级尾部,避免只优化通常不到 3µs 的到期竞争。 -2. 分离首次热键路径与持续播放路径,检查回调内日志、事件记录及分发初始化;尝试可验证的预编译/初始化方案。需包含冷首样本的交替对照,不能靠丢弃首样本宣布完成优化。 -3. 用最终版本在固定目标负载下增加物理热键样本和至少五轮长测,同时记录最大值及超阈值次数。单次 2,000 自动样本的 P99.9 只对应很少几个尾部事件,仍需扩大样本。 -4. 最后验证真实目标程序的输入消费与游戏帧时间。输入消息到达不等于目标动作完成;驱动或硬件注入不会自动消除目标线程调度及显示延迟,不能据本轮结果承诺其收益。 diff --git a/docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md b/docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md deleted file mode 100644 index 8682313..0000000 --- a/docs/superpowers/plans/2026-10-14-scheduling-latency-phases.md +++ /dev/null @@ -1,53 +0,0 @@ -# 调度延迟优化分阶段计划 - -基线与依据:`docs/research/macro-scheduling-research-2026-10-14.md`(第 8 节优先级表)、`docs/single-macro-tail-and-end-to-end-2026-10-14.md`(实测)。本计划只覆盖缺口,不重复已实现的预编译、QPC 时间轴和共享原生调度器。 - -范围:阶段 1、2 立即实施;阶段 3 依据阶段 1 的实测决定;阶段 4 只记录,需单独设计。 - -## 阶段 1:测量先行(纯增量,默认关闭或仅影响基准输出) - -| 任务 | 位置 | 验收 | -|---|---|---| -| 1.1 基准补 P99.9/max/超阈比例 | `tests/MacroHid.Scheduling.Benchmarks/Program.cs` | 输出含 n、P99.9、max、>0.1/0.25/1ms 比例(已完成) | -| 1.2 钩子自耗时:回调入口/出口 QPC 写入预分配环形缓冲,可导出 P99 | `src/ui/MacroStudio/GlobalKeyboardHook.cs` | 回调内无分配;关闭时无可测开销;有单元测试覆盖环形缓冲 | -| 1.3 原生逐批追踪:batchId、nominalDue、effectiveDue、submitBegin、submitEnd、sent;新导出读取 | `SharedPlaybackScheduler.inl`、`NativePlayback.h/.cpp` | 默认关闭;开启后可导出逐批 CSV;`MacroHid.NativePlayback.Tests` 通过;关闭时固定核心基准不退化 | - -## 阶段 2:热键入口 - -| 任务 | 位置 | 验收 | -|---|---|---| -| 2.1 前台进程名快照:`SetWinEventHook(EVENT_SYSTEM_FOREGROUND)` 维护,热键路径只读 | `Services/ForegroundProcessService.cs`、`MainWindow.xaml.cs` | 热键路径无 `Process.GetProcessById`;前台切换后快照在下次热键前已更新;过滤语义不变 | -| 2.2 评估是否把 TriggerPressed 处理移出钩子线程(吞键判断仍在回调内) | `GlobalKeyboardHook.cs` | 依据 1.2 数据决定;钩子自耗时 P99 ≤ 50µs;EndToEndProbe「钩子→托管工作线程」不变差 | - -## 阶段 3:调度器唤醒(有风险,逐项 A/B,任一项不达标即撤回) - -| 任务 | 验收 | -|---|---| -| 3.1 `Notify()` 定向唤醒,单宏独占时跳过 generation 递增 | 固定核心五轮对照:P99.9 不退化、最大迟到不增、唤醒数与 CPU 下降;无丢唤醒/重复提交 | -| 3.2 自旋窗口增加衰减 | 同上 | - -## 阶段 4:仅记录 - -跨宏同键仲裁(P1c)、条件检测预算与帧年龄(P1d)、堆队列与 VHF 后端(P2/P3)。会改变语义或引入驱动,需单独设计。 - -## 边界 - -端到端毫秒级的主要部分(钩子返回后到目标收到消息,约 2.9ms)在调度器之外,本计划不能消除。驱动级注入与硬件中断模拟不在范围内。 - -## 实施结果(2026-10-14) - -| 任务 | 结果 | -|---|---| -| 1.1 | 完成。`Scheduling.Benchmarks` 输出 n、P99.9、max、>0.1/0.25/1ms 比例;n<1000 时 P99.9 等于 max | -| 1.2 | 完成。`HookSelfTimeRecorder`,`MACROHID_HOOK_SELFTIME=1` 开启,钩子释放时写日志 | -| 1.3 | 原生部分完成。`MACROHID_BATCH_TRACE=1` 开启,`MhpGetBatchTrace` 导出。托管互操作与 CSV 导出未接入 | -| 2.1 | 完成。按 (hwnd, pid) 缓存,每次调用仍重读前台。原路径 P50 26µs / P99 48µs,命中约 0.1µs | -| 2.2 | **不实施。** 同步调用 `TriggerPressedAsync` 在调用线程上 P50 27µs / P99 131µs(假输入接收端;首次冷调用 44ms 为 JIT)。移出需要一次线程交接,实测 P50 25µs / P99 74µs,等于把钩子停留时间换成宏启动延迟,P50 无净收益 | -| 3.1 | **撤回。** 定向唤醒(按对方等待目标判断是否唤醒)通过全部原生回归(连续 8 次),但双宏 1ms 对照中 balanced 模式 P99.9 三轮均更差(485/491/486µs 对基线 409/382/383µs)。extreme 模式噪声过大无结论 | -| 3.2 | **未实施。** 自旋窗口只在单次运行内增长且有 800/1000µs 上限;当前机器底噪(P99.9 在 6–739µs 间波动)无法分辨其收益,不对尾部关键循环做无法验证的改动 | - -### 若要继续 - -- 2.2 的数据只覆盖托管同步段。若要优化,应缩短同步段本身(日志字符串、CTS/Task 分配、`RunAsync` 同步前缀),而不是移走它。 -- 冷启动首次热键的 44ms JIT 值得单独查:确认应用启动预热是否覆盖了 `MacroPlaybackController.StartRunCore` 路径。 -- 3.1/3.2 需要在安静机器(固定核心、关闭后台负载)上做至少五轮 A/B 才能下结论。 diff --git a/scripts/summarize_end_to_end.py b/scripts/summarize_end_to_end.py deleted file mode 100644 index c135c34..0000000 --- a/scripts/summarize_end_to_end.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Validate complete samples and summarize QPC-based input probes (stdlib only).""" -import argparse -import csv -import hashlib -import json -import math -from pathlib import Path - - -def percentiles(values): - values = sorted(values) - if not values: - raise ValueError("No samples") - return {**{f"p{p * 100:g}_us": round(values[math.ceil(p * len(values)) - 1], 3) - for p in (.5, .95, .99, .999)}, "max_us": round(values[-1], 3)} - - -def summarize(path): - with path.open(encoding="utf-8-sig", newline="") as stream: - rows = list(csv.DictReader(stream)) - if not rows: - raise ValueError(f"Empty CSV: {path}") - if [int(row["sample"]) for row in rows] != list(range(len(rows))): - raise ValueError(f"Missing/reordered rows: {path}") - for row in rows: - if any(int(row[field]) != 1 for field in ("deliveries", "run_ok", "native")): - raise ValueError(f"Invalid delivery/run/backend: {path}, row={row['sample']}") - ticks = [int(row[f"{field}_qpc"]) for field in - ("hook", "dispatch", "worker", "queued", "submit")] - if ticks[0] <= 0 or ticks != sorted(ticks) or int(row["receiver_qpc"]) < ticks[-1]: - raise ValueError(f"Invalid timestamps: {path}, row={row['sample']}") - if int(row["complete_qpc"]) < ticks[-1] or int(row["frequency"]) <= 0: - raise ValueError(f"Incomplete sample: {path}, row={row['sample']}") - result = {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "groups": []} - for source in sorted({row["source"] for row in rows}): - group = [row for row in rows if row["source"] == source] - stages = [("hook", "dispatch"), ("hook", "worker"), ("hook", "submit"), - ("submit", "receiver"), ("hook", "receiver")] - if source == "synthetic_tagged": - stages.append(("source", "hook")) - if "output_hook_qpc" in group[0]: - if any(int(row["output_hook_qpc"]) <= 0 for row in group): - raise ValueError(f"Missing output hook: {path}") - stages += [("submit", "output_hook"), ("output_hook", "receiver")] - if "output_forwarded_qpc" in group[0]: - if any(int(row["output_forwarded_qpc"]) <= 0 for row in group): - raise ValueError(f"Missing hook forwarding result: {path}") - stages += [("output_hook", "output_forwarded"), ("output_forwarded", "receiver")] - metrics = {f"{a}_to_{b}": percentiles([ - (int(row[f"{b}_qpc"]) - int(row[f"{a}_qpc"])) * 1e6 / int(row["frequency"]) - for row in group]) for a, b in stages} - if "native_api_total_us" in group[0]: - metrics["native_api_total"] = percentiles([int(row["native_api_total_us"]) for row in group]) - result["groups"].append({"source": source, "n": len(group), "metrics": metrics, - "note": "N < 1000: p99.9 is an order statistic, not a tail estimate" - if len(group) < 1000 else "nearest-rank percentiles"}) - return result - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("files", type=Path, nargs="+") - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - report = [summarize(path) for path in args.files] - args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - for file in report: - for group in file["groups"]: - print(file["file"], group["source"], "n=" + str(group["n"]), group["metrics"]["hook_to_receiver"]) - - -if __name__ == "__main__": - main() diff --git a/scripts/summarize_single_macro.py b/scripts/summarize_single_macro.py deleted file mode 100644 index b480522..0000000 --- a/scripts/summarize_single_macro.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Summarize paired single-macro probes without third-party dependencies.""" -import argparse -import csv -import hashlib -import json -import math -import pathlib -import re - - -def quantiles(values): - ordered = sorted(values) - if not ordered: - raise ValueError("Empty sample series") - return { - **{f"p{p * 100:g}_us": ordered[math.ceil(p * len(ordered)) - 1] - for p in (0.5, 0.95, 0.99, 0.999)}, - "min_us": ordered[0], "max_us": ordered[-1], - } - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("directory", type=pathlib.Path) - args = parser.parse_args() - directory = args.directory.resolve() - log_metrics = {} - for name, dense in (("paired.log", False), ("paired-dense.log", True)): - key = None - for line in (directory / name).read_text(encoding="utf-8-sig").splitlines(): - label = re.search(r"variant=(\w+) mode=(\d+) round=(\d+)", line) - if label: - variant, mode, repeat = label.groups() - key = f"{'dense' if dense else 'paired'}-{variant}-mode{mode}-round{repeat}.csv" - elif line.startswith("fake input;") and key: - log_metrics[key] = {k: float(v) for k, v in re.findall(r"(\w+)=(-?[\d.]+)", line)} - records = [] - for pattern, kind in (("paired-*-mode*-round*.csv", "periodic"), - ("dense-*-mode*-round*.csv", "dense")): - for path in sorted(directory.glob(pattern)): - match = re.fullmatch(r"(?:paired|dense)-(\w+)-mode(\d+)-round(\d+)\.csv", path.name) - if not match: - continue - variant, mode, repeat = match.groups() - with path.open(newline="", encoding="utf-8-sig") as stream: - samples = list(csv.DictReader(stream)) - metrics = log_metrics[path.name] - if len(samples) != int(metrics["events"]) or [int(s["batch"]) for s in samples] != list(range(len(samples))): - raise ValueError(f"Missing or reordered samples: {path.name}") - signed = [(int(s["submit_qpc"]) - int(s["due_qpc"])) * 1e6 / int(s["frequency"]) - for s in samples] - records.append({ - "file": path.name, "kind": kind, "variant": variant, - "mode": int(mode), "round": int(repeat), "n": len(samples), - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), - "signed_error": quantiles(signed), - "early": sum(x < 0 for x in signed), - "over_100_us": sum(x > 100 for x in signed), - "over_250_us": sum(x > 250 for x in signed), - "over_1000_us": sum(x > 1000 for x in signed), - "process_cpu_ms": metrics["cpu_ms"], "wall_ms": metrics["wall_ms"], - "startup_us": metrics["startup_us"], - }) - groups = [] - for kind in ("periodic", "dense"): - for mode in (1, 2): - for variant in ("baseline", "final"): - rows = [r for r in records if (r["kind"], r["mode"], r["variant"]) == (kind, mode, variant)] - if not rows: - continue - groups.append({ - "kind": kind, "mode": mode, "variant": variant, - "runs": len(rows), "n": sum(r["n"] for r in rows), - "p999_range_us": [min(r["signed_error"]["p99.9_us"] for r in rows), max(r["signed_error"]["p99.9_us"] for r in rows)], - "maximum_us": max(r["signed_error"]["max_us"] for r in rows), - "early": sum(r["early"] for r in rows), - "over_1000_us": sum(r["over_1000_us"] for r in rows), - "mean_process_cpu_ms": sum(r["process_cpu_ms"] for r in rows) / len(rows), - "mean_wall_ms": sum(r["wall_ms"] for r in rows) / len(rows), - }) - binaries = {name: hashlib.sha256((directory / name).read_bytes()).hexdigest() - for name in ("baseline.exe", "final.exe")} - result = {"percentile": "nearest-rank from raw QPC; signed error retained", - "note": "Fake input. Dense elapsed time measures throughput, not periodic jitter.", - "binary_sha256": binaries, "groups": groups, "runs": records} - (directory / "summary.json").write_text(json.dumps(result, indent=2), encoding="utf-8") - print(json.dumps(groups, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/summarize_tail_comparison.py b/scripts/summarize_tail_comparison.py deleted file mode 100644 index 83679e7..0000000 --- a/scripts/summarize_tail_comparison.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Summarize repeated native deadline probes, retaining maximums and early events.""" -import argparse -import csv -import hashlib -import json -import re -from pathlib import Path -from summarize_end_to_end import percentiles - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - parser.add_argument("--log", required=True) - parser.add_argument("--pattern", required=True, help="CSV filename with {variant} and {round}") - parser.add_argument("--output", required=True) - args = parser.parse_args() - reports, groups = [], {} - key = None - for line in (args.directory / args.log).read_text(encoding="utf-8-sig").splitlines(): - label = re.fullmatch(r"variant=(\w+) round=(\d+) mask=(\S+)", line) - if label: - key = label.groups() - elif line.startswith("fake input;") and key: - variant, repeat, mask = key - metrics = {k: float(v) for k, v in re.findall(r"(\w+)=(-?[\d.]+)", line)} - path = args.directory / args.pattern.format(variant=variant, round=repeat) - with path.open(encoding="utf-8-sig", newline="") as stream: - samples = list(csv.DictReader(stream)) - if len(samples) != int(metrics["events"]) or [int(s["batch"]) for s in samples] != list(range(len(samples))): - raise ValueError(f"Missing/reordered events: {path}") - errors = [(int(s["submit_qpc"]) - int(s["due_qpc"])) * 1e6 / int(s["frequency"]) - for s in samples] - report = {"file": path.name, "variant": variant, "round": int(repeat), "mask": mask, - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "n": len(errors), - "metrics": percentiles(errors), "early": sum(e < 0 for e in errors), - "over_100_us": sum(e > 100 for e in errors), - "over_1000_us": sum(e > 1000 for e in errors), "process": metrics} - reports.append(report) - groups.setdefault(variant, []).extend(errors) - key = None - if not reports or key: - raise ValueError("No complete benchmark results or incomplete final round") - pooled = [] - for variant, errors in groups.items(): - runs = [r for r in reports if r["variant"] == variant] - pooled.append({"variant": variant, "n": len(errors), "rounds": len(runs), - "metrics": percentiles(errors), "early": sum(e < 0 for e in errors), - "over_100_us": sum(e > 100 for e in errors), - "over_1000_us": sum(e > 1000 for e in errors), - "round_max_us": [r["metrics"]["max_us"] for r in runs], - "startup_us": [r["process"]["startup_us"] for r in runs], - "logical_cores_cpu": sum(r["process"]["cpu_ms"] for r in runs) / - sum(r["process"]["wall_ms"] for r in runs)}) - output = {"measurement": "QPC immediately before fake input; not target receive latency", - "percentiles": "nearest-rank; maxima are observations, not bounds", "rounds": reports, "pooled": pooled} - (args.directory / args.output).write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8") - for group in pooled: - print(group["variant"], "n=" + str(group["n"]), group["metrics"], "round_max", group["round_max_us"]) - - -if __name__ == "__main__": - main() diff --git a/src/tools/EndToEndProbe/EndToEndProbe.csproj b/src/tools/EndToEndProbe/EndToEndProbe.csproj deleted file mode 100644 index 8760836..0000000 --- a/src/tools/EndToEndProbe/EndToEndProbe.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - Exe - net8.0-windows - true - enable - enable - $(DefineConstants);MACROHID_HOTKEY_PROBE - - - - - - - diff --git a/src/tools/EndToEndProbe/NativeReceiver.cs b/src/tools/EndToEndProbe/NativeReceiver.cs deleted file mode 100644 index 4f219ae..0000000 --- a/src/tools/EndToEndProbe/NativeReceiver.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Diagnostics; -using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; - -// A separate process with a plain GetMessage loop. This removes the WPF -// dispatcher's workload from the receiver comparison, not from the hotkey hook. -internal static class NativeReceiver -{ - internal static void Run(string name) - { - using var memory = MemoryMappedFile.OpenExisting(name); - using var view = memory.CreateViewAccessor(); - IntPtr label = IntPtr.Zero; - int doneTicks = 0; - string previous = ""; - WindowProc callback = (hwnd, message, w, l) => - { - if (message == 0x100 && w.ToInt64() == 0x87) - { - long tick = Stopwatch.GetTimestamp(); - int row = view.ReadInt32(0); - if (row >= 0 && row < view.ReadInt32(44)) - { - long at = 128 + (long)row * 128; - int count = view.ReadInt32(at + 72); - view.Write(at + 72, count + 1); - if (count == 0) { view.Write(at + 48, tick); view.Write(4, view.ReadInt32(4) + 1); } - } - } - if (message == 0x113) // WM_TIMER; no per-input UI work - { - int phase = view.ReadInt32(48), received = view.ReadInt32(4); - string text = phase switch - { - 0 => "请点击此窗口,将它置于前台。正在预热,请先不要按 F8。", - 1 => $"请按下并松开 F8 共 {view.ReadInt32(52)} 次,每次间隔约半秒。\r\n已接收 {received} 次。请保持此窗口为前台。", - 2 => $"正在自动测试,请不要切换窗口。\r\n已接收 {received} 次。", - 3 => "测试完成,结果已保存。窗口即将关闭。", - _ => "测试未完成,请查看错误记录。窗口即将关闭。" - }; - if (text != previous) { SetWindowText(label, text); previous = text; } - if (view.ReadInt32(8) != 0 && ++doneTicks >= 30) DestroyWindow(hwnd); - return IntPtr.Zero; - } - if (message == 0x2) { view.Write(24, 1); PostQuitMessage(0); return IntPtr.Zero; } - return DefWindowProc(hwnd, message, w, l); - }; - string className = "MacroHID.Receiver." + Guid.NewGuid().ToString("N"); - var cls = new WindowClass { Size = (uint)Marshal.SizeOf(), - Proc = Marshal.GetFunctionPointerForDelegate(callback), Instance = GetModuleHandle(null), - Cursor = LoadCursor(IntPtr.Zero, new IntPtr(32512)), Background = new IntPtr(6), Name = className }; - if (RegisterClassEx(ref cls) == 0) throw new InvalidOperationException("Cannot register receiver window"); - IntPtr window = IntPtr.Zero; - try - { - window = CreateWindowEx(8, className, "MacroHID 真实热键接收测试(Win32)", 0x10CF0000, - 240, 240, 800, 300, IntPtr.Zero, IntPtr.Zero, cls.Instance, IntPtr.Zero); - if (window == IntPtr.Zero) throw new InvalidOperationException("Cannot create receiver window"); - label = CreateWindowEx(0, "STATIC", "正在准备测试……", 0x50000000, - 24, 40, 720, 180, window, IntPtr.Zero, cls.Instance, IntPtr.Zero); - if (label == IntPtr.Zero || SetTimer(window, new UIntPtr(1), 100, IntPtr.Zero) == UIntPtr.Zero) - throw new InvalidOperationException("Cannot initialize receiver UI"); - view.Write(16, window.ToInt64()); view.Write(12, 1); - SetForegroundWindow(window); - int result; - while ((result = GetMessage(out var message, IntPtr.Zero, 0, 0)) > 0) - { TranslateMessage(ref message); DispatchMessage(ref message); } - if (result < 0) throw new InvalidOperationException("Receiver message loop failed"); - } - finally - { - if (window != IntPtr.Zero) DestroyWindow(window); - UnregisterClass(className, cls.Instance); - GC.KeepAlive(callback); - } - } - - [UnmanagedFunctionPointer(CallingConvention.Winapi)] - private delegate IntPtr WindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WindowClass - { - public uint Size, Style; - public IntPtr Proc; - public int ClassExtra, WindowExtra; - public IntPtr Instance, Icon, Cursor, Background; - public string? Menu, Name; - public IntPtr SmallIcon; - } - [StructLayout(LayoutKind.Sequential)] - private struct Message - { - public IntPtr Hwnd; - public uint Id; - public UIntPtr W; - public IntPtr L; - public uint Time; - public int X, Y; - public uint Private; - } - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr GetModuleHandle(string? name); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern ushort RegisterClassEx(ref WindowClass cls); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool UnregisterClass(string name, IntPtr instance); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr CreateWindowEx(uint ex, string cls, string title, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr DefWindowProc(IntPtr hwnd, uint message, IntPtr w, IntPtr l); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr LoadCursor(IntPtr instance, IntPtr name); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool SetWindowText(IntPtr hwnd, string text); - [DllImport("user32.dll")] private static extern UIntPtr SetTimer(IntPtr hwnd, UIntPtr id, uint milliseconds, IntPtr callback); - [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); - [DllImport("user32.dll")] private static extern bool DestroyWindow(IntPtr hwnd); - [DllImport("user32.dll")] private static extern void PostQuitMessage(int code); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetMessage(out Message message, IntPtr hwnd, uint minimum, uint maximum); - [DllImport("user32.dll")] private static extern bool TranslateMessage(ref Message message); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr DispatchMessage(ref Message message); -} diff --git a/src/tools/EndToEndProbe/Program.cs b/src/tools/EndToEndProbe/Program.cs deleted file mode 100644 index 1cda990..0000000 --- a/src/tools/EndToEndProbe/Program.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Diagnostics.Tracing; -using System.IO; -using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; -using System.Text; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Interop; -using System.Windows.Threading; -using MacroHid.Core; -using MacroHid.Runtime; -using MacroStudio; - -internal static class Program -{ - private const int Header = 128, Stride = 128; - private const ulong Tag = 0x4D485052; - private static long Pos(int row, int field) => Header + (long)row * Stride + field; - - [STAThread] - private static int Main(string[] args) - { - try - { - if (args.Length > 0 && args[0] == "--receiver") - { - if (args.Length > 2 && args[2] == "win32") NativeReceiver.Run(args[1]); - else RunReceiver(args[1]); - return 0; - } - return RunProbe(args).GetAwaiter().GetResult(); - } - catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } - finally { NativePlaybackWarmup.Shutdown(); } - } - - private static void RunReceiver(string name) - { - using var memory = MemoryMappedFile.OpenExisting(name); - using var view = memory.CreateViewAccessor(); - var text = new TextBlock { FontSize = 22, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(24) }; - var window = new Window { Title = "MacroHID 真实热键接收测试", Width = 800, Height = 300, - Topmost = true, WindowStartupLocation = WindowStartupLocation.CenterScreen, Content = text }; - window.SourceInitialized += (_, _) => - { - var handle = new WindowInteropHelper(window).Handle; - HwndSource.FromHwnd(handle).AddHook((IntPtr hwnd, int message, IntPtr w, IntPtr l, ref bool handled) => - { - if (message == 0x100 && w.ToInt64() == 0x87) // WM_KEYDOWN / F24 - { - long received = Stopwatch.GetTimestamp(); - int row = view.ReadInt32(0); - if (row >= 0 && row < view.ReadInt32(44)) - { - long at = Pos(row, 72); - int previous = view.ReadInt32(at); - view.Write(at, previous + 1); - if (previous == 0) - { - view.Write(Pos(row, 48), received); - view.Write(4, view.ReadInt32(4) + 1); - } - } - } - return IntPtr.Zero; - }); - view.Write(16, handle.ToInt64()); - view.Write(12, 1); - Native.SetForegroundWindow(handle); - }; - window.Closing += (_, _) => view.Write(24, 1); - var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; - int doneTicks = 0; - timer.Tick += (_, _) => - { - int phase = view.ReadInt32(48), manual = view.ReadInt32(52), auto = view.ReadInt32(56); - text.Text = phase switch - { - 0 => "请点击此窗口,把它置于前台。正在预热测试,请先不要按 F8。", - 1 => $"请按下并松开 F8 共 {manual} 次,每次间隔约半秒。\n已接收 {view.ReadInt32(4)} 次。请保持此窗口为前台。", - 2 => $"物理热键测试已完成,正在自动测试 {auto} 次。\n已接收 {view.ReadInt32(4)} 次。请不要按 F8 或切换窗口。", - 3 => "测试已完成,结果已保存。此窗口即将自动关闭。", - _ => "测试未完成,请查看记录的错误。此窗口即将关闭。" - }; - if (view.ReadInt32(8) != 0 && ++doneTicks >= 30) window.Close(); - }; - timer.Start(); - new Application().Run(window); - } - - private static async Task RunProbe(string[] args) - { - string Option(string key, string fallback) - { int at = Array.IndexOf(args, key); return at >= 0 && at + 1 < args.Length ? args[at + 1] : fallback; } - int manual = int.Parse(Option("--physical", "30")), automatic = int.Parse(Option("--automatic", "2000")); - int timeout = int.Parse(Option("--timeout", "180")); - string output = Path.GetFullPath(Option("--output", "end-to-end.csv")); - string receiverKind = Option("--receiver-kind", "wpf"); - if (receiverKind is not ("wpf" or "win32")) throw new ArgumentException("Unknown receiver kind"); - if (manual < 0 || automatic < 0 || manual + automatic < 1 || manual + automatic > 100000 || timeout < 1) - throw new ArgumentException("Invalid sample counts or timeout."); - Directory.CreateDirectory(Path.GetDirectoryName(output)!); - string name = "Local\\MacroHID-E2E-" + Guid.NewGuid().ToString("N"); - using var memory = MemoryMappedFile.CreateNew(name, Header + (long)(manual + automatic) * Stride); - using var view = memory.CreateViewAccessor(); - view.Write(0, -1); view.Write(44, manual + automatic); view.Write(52, manual); view.Write(56, automatic); - var info = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true }; - info.ArgumentList.Add("--receiver"); info.ArgumentList.Add(name); info.ArgumentList.Add(receiverKind); - using var receiver = Process.Start(info) ?? throw new InvalidOperationException("Cannot start receiver"); - var document = McrxParser.Parse("{\"steps\":[{\"type\":\"key.down\",\"key\":\"F24\"},{\"type\":\"wait\",\"ms\":5},{\"type\":\"key.up\",\"key\":\"F24\"}]}"); - var options = new PlaybackExecutionOptions(PlaybackMode.FixedCount, 1, PixelEvaluationMode.Skip, false, PrecisionMode.UltraLowJitter); - var sink = new SendInputMacroSink(); - var executor = new MacroPlaybackExecutor(sink); - await using var controller = new MacroPlaybackController(document, executor, options); - using var trace = new TraceRecorder(view); - using var hook = new GlobalKeyboardHook(); - var completions = new TaskCompletionSource?[manual + automatic]; - var routeGate = new object(); - int accepted = 0, automaticPhase = 0, physicalReleases = 0; - long sourceTick = 0; - TaskCompletionSource? pending = null; - var deadline = Stopwatch.StartNew(); - IntPtr target = IntPtr.Zero; - void RequireTarget() - { - if (view.ReadInt32(24) != 0 || receiver.HasExited || Native.GetForegroundWindow() != target) - throw new InvalidOperationException("Receiver closed or lost foreground focus; stopped before further injection."); - } - async Task Complete(int row, Task task) - { - try - { - var result = await task.ConfigureAwait(false); - view.Write(Pos(row, 80), result.Status == PlaybackRunStatus.Completed && !result.Cancelled ? 1 : 0); - view.Write(Pos(row, 84), result.InputStats?.BackendName.StartsWith("native", StringComparison.Ordinal) == true ? 1 : 0); - view.Write(Pos(row, 96), result.InputStats?.NativeSubmitDurationMicroseconds ?? 0); - view.Write(Pos(row, 104), result.InputStats?.EngineWakeCostMicroseconds ?? 0); - view.Write(Pos(row, 112), result.InputStats?.MaxLateMicroseconds ?? 0); - completions[row]!.TrySetResult(result); - } - catch (Exception ex) { completions[row]!.TrySetException(ex); } - } - hook.TriggerPressed += (_, e) => - { - lock (routeGate) - { - bool isAutomatic = Volatile.Read(ref automaticPhase) != 0; - if (e.ProbeInjected != isAutomatic) return; - if (target == IntPtr.Zero || Native.GetForegroundWindow() != target) - { view.Write(40, view.ReadInt32(40) + 1); return; } - if (accepted >= completions.Length || (!isAutomatic && accepted >= manual)) return; - if (controller.Status is PlaybackStatus.Running or PlaybackStatus.Stopping - || (accepted > 0 && view.ReadInt64(Pos(accepted - 1, 48)) == 0)) - { view.Write(36, view.ReadInt32(36) + 1); return; } - int row = accepted++; - completions[row] = new(TaskCreationOptions.RunContinuationsAsynchronously); - view.Write(0, row); view.Write(32, accepted); - view.Write(Pos(row, 0), isAutomatic ? Volatile.Read(ref sourceTick) : 0); - view.Write(Pos(row, 8), e.CallbackQpc); view.Write(Pos(row, 76), e.ProbeInjected ? 1 : 0); - _ = controller.TriggerPressedAsync(e.CallbackQpc); - _ = Complete(row, controller.WhenIdleAsync()); - pending?.TrySetResult(row); - } - }; - hook.TriggerReleased += (_, _) => - { - if (Volatile.Read(ref automaticPhase) == 0) Interlocked.Increment(ref physicalReleases); - }; - hook.ProbeInputObserved += (key, down, tick) => - { - int row = view.ReadInt32(0); - if (key == 0x87 && down && row >= 0) view.Write(Pos(row, 88), tick); - }; - hook.ProbeOutputForwarded += tick => - { - int row = view.ReadInt32(0); - if (row >= 0) view.Write(Pos(row, 120), tick); - }; - async Task WaitForRow(int row) - { - var task = completions[row]?.Task ?? throw new InvalidOperationException("Missing triggered run"); - await task.WaitAsync(TimeSpan.FromSeconds(3)); - var wait = Stopwatch.StartNew(); - while (view.ReadInt64(Pos(row, 48)) == 0 && wait.Elapsed.TotalSeconds < 3) await Task.Delay(1); - if (view.ReadInt64(Pos(row, 48)) == 0 || view.ReadInt32(Pos(row, 72)) != 1 - || view.ReadInt32(Pos(row, 80)) != 1 || view.ReadInt32(Pos(row, 84)) != 1) - throw new InvalidOperationException($"Missing, duplicate, failed or non-native sample {row}"); - } - try - { - while (view.ReadInt32(12) == 0 && deadline.Elapsed.TotalSeconds < timeout) await Task.Delay(20); - target = new IntPtr(view.ReadInt64(16)); - if (target == IntPtr.Zero) throw new InvalidOperationException("Receiver did not initialize"); - executor.Prepare(document, options); - Console.WriteLine("WINDOW_READY: click the MacroHID receiver window; waiting for foreground."); - while (Native.GetForegroundWindow() != target && deadline.Elapsed.TotalSeconds < timeout) - { if (receiver.HasExited) throw new InvalidOperationException("Receiver closed"); await Task.Delay(50); } - RequireTarget(); - // Warm the actual playback path against our foreground receiver. - // These untriggered runs are excluded from both measured groups. - for (int i = 0; i < 10; i++) - { RequireTarget(); _ = controller.TriggerPressedAsync(); await controller.WhenIdleAsync(); } - hook.Start(new HotkeyGesture(HidModifier.None, HidKey.F8)); - view.Write(48, 1); - Console.WriteLine($"PHYSICAL_READY: press/release F8 {manual} times, about 0.5s apart; target pid={receiver.Id}, kind={receiverKind}."); - while (Volatile.Read(ref accepted) < manual && deadline.Elapsed.TotalSeconds < timeout) - { if (receiver.HasExited) throw new InvalidOperationException("Receiver closed"); await Task.Delay(20); } - if (Volatile.Read(ref accepted) < manual) throw new TimeoutException("Physical sample collection timed out"); - for (int i = 0; i < manual; i++) await WaitForRow(i); - while (Volatile.Read(ref physicalReleases) < manual && deadline.Elapsed.TotalSeconds < timeout) await Task.Delay(10); - if (Volatile.Read(ref physicalReleases) < manual) throw new TimeoutException("Final physical hotkey is still held"); - Volatile.Write(ref automaticPhase, 1); - hook.ProbeInjectedInputTag = new IntPtr((long)Tag); - view.Write(48, 2); - for (int i = 0; i < automatic; i++) - { - RequireTarget(); - pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - Volatile.Write(ref sourceTick, Stopwatch.GetTimestamp()); - Native.SendProbeHotkey(Tag); - int row = await pending.Task.WaitAsync(TimeSpan.FromSeconds(3)); - await WaitForRow(row); - await Task.Delay(10); - } - view.Write(48, 3); - Console.WriteLine($"PASS: physical={manual}, synthetic={automatic}, receiver_pid={receiver.Id}, CSV={output}"); - return 0; - } - catch { view.Write(48, 4); throw; } - finally - { - hook.Stop(); - await controller.StopAsync(); - using var file = new StreamWriter(output, false, new UTF8Encoding(false)); - file.WriteLine("sample,source,source_qpc,hook_qpc,dispatch_qpc,worker_qpc,queued_qpc,submit_qpc,receiver_qpc,complete_qpc,frequency,run_id,deliveries,run_ok,native,output_hook_qpc,native_api_total_us,engine_wake_us,max_scheduler_late_us,output_forwarded_qpc"); - for (int i = 0; i < Volatile.Read(ref accepted); i++) - { - var ticks = Enumerable.Range(0, 8).Select(n => view.ReadInt64(Pos(i, n * 8))).ToArray(); - file.WriteLine($"{i},{(view.ReadInt32(Pos(i, 76)) == 0 ? "physical_manual" : "synthetic_tagged")},{string.Join(',', ticks)},{Stopwatch.Frequency},{view.ReadInt64(Pos(i, 64))},{view.ReadInt32(Pos(i, 72))},{view.ReadInt32(Pos(i, 80))},{view.ReadInt32(Pos(i, 84))},{view.ReadInt64(Pos(i, 88))},{view.ReadInt64(Pos(i, 96))},{view.ReadInt64(Pos(i, 104))},{view.ReadInt64(Pos(i, 112))},{view.ReadInt64(Pos(i, 120))}"); - } - view.Write(8, 1); - if (!await Task.Run(() => receiver.WaitForExit(5000))) receiver.Kill(); - Console.WriteLine($"accepted={accepted}, rejected_busy={view.ReadInt32(36)}, rejected_focus={view.ReadInt32(40)}"); - } - } - - private sealed class TraceRecorder(MemoryMappedViewAccessor view) : EventListener - { - private readonly ConcurrentDictionary rows = new(); - protected override void OnEventSourceCreated(EventSource source) - { if (source.Name == "MacroHid-Playback") EnableEvents(source, EventLevel.Informational); } - protected override void OnEventWritten(EventWrittenEventArgs args) - { - if (args.EventId != 1 || args.Payload is null) return; - long run = (long)args.Payload[0]!, tick = (long)args.Payload[2]!; - var stage = (PlaybackTraceStage)(int)args.Payload[1]!; - if (stage == PlaybackTraceStage.Hotkey) - { - int row = view.ReadInt32(0); - if (row < 0) return; - rows[run] = row; view.Write(Pos(row, 64), run); - } - if (!rows.TryGetValue(run, out int index)) return; - int field = stage switch - { - PlaybackTraceStage.Dispatch => 16, PlaybackTraceStage.WorkerStart => 24, - PlaybackTraceStage.NativeQueued => 32, PlaybackTraceStage.NativeFirstSubmit => 40, - PlaybackTraceStage.CleanupComplete => 56, _ => -1 - }; - if (field >= 0) view.Write(Pos(index, field), tick); - } - } - - private static class Native - { - [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] internal static extern bool SetForegroundWindow(IntPtr window); - [StructLayout(LayoutKind.Sequential)] private struct Keyboard - { public ushort Key, Scan; public uint Flags, Time; public UIntPtr Extra; } - [StructLayout(LayoutKind.Explicit, Size = 32)] private struct Union - { [FieldOffset(0)] public Keyboard Keyboard; } - [StructLayout(LayoutKind.Sequential)] private struct Input - { public uint Type; public Union Union; } - [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint count, Input[] inputs, int size); - internal static void SendProbeHotkey(ulong tag) - { - Input[] inputs = [new() { Type = 1, Union = new() { Keyboard = new() { Key = 0x77, Extra = new UIntPtr(tag) } } }, - new() { Type = 1, Union = new() { Keyboard = new() { Key = 0x77, Flags = 2, Extra = new UIntPtr(tag) } } }]; - if (SendInput(2, inputs, Marshal.SizeOf()) != 2) throw new InvalidOperationException("Hotkey SendInput failed"); - } - } -} diff --git a/src/tools/FufuTargetProbe/FufuTargetProbe.csproj b/src/tools/FufuTargetProbe/FufuTargetProbe.csproj deleted file mode 100644 index af834d2..0000000 --- a/src/tools/FufuTargetProbe/FufuTargetProbe.csproj +++ /dev/null @@ -1,3 +0,0 @@ - -Exenet8.0-windowsenableenableapp.manifest - diff --git a/src/tools/FufuTargetProbe/Program.cs b/src/tools/FufuTargetProbe/Program.cs deleted file mode 100644 index f570f99..0000000 --- a/src/tools/FufuTargetProbe/Program.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text.Json; - -Console.OutputEncoding = System.Text.Encoding.UTF8; -Console.WriteLine("Fufu 帧率地址对照工具:只读内存,不修改游戏或 DLL。\n请保持同一游戏进程运行,关闭 Macro 的锁帧操作。"); -string output = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MacroStudio", "fufu-target-probe"); -Directory.CreateDirectory(output); -nint handle = 0; -try -{ - using var game = Process.GetProcessesByName("YuanShen").Concat(Process.GetProcessesByName("GenshinImpact")).Single(); - var modules = game.Modules.Cast().Select(m => new Module(m.ModuleName, m.FileName, m.BaseAddress.ToInt64(), m.ModuleMemorySize)).ToArray(); - handle = Native.OpenProcess(0x410, false, game.Id); // Query + read only; no write rights. - if (handle == 0) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); - Console.Write("确认游戏当前稳定为 178 FPS 后,按回车记录:"); Console.ReadLine(); - var found = new List(); long scanned = 0, failed = 0; - var buffer = new byte[1024 * 1024]; - for (long cursor = 0x10000; ;) - { - if (Native.VirtualQueryEx(handle, (nint)cursor, out var info, (nuint)Marshal.SizeOf()) == 0) break; - long start = info.BaseAddress.ToInt64(); - if (info.RegionSize > long.MaxValue || start > long.MaxValue - (long)info.RegionSize) break; - long end = start + (long)info.RegionSize; - if (end <= cursor) break; - bool writable = info.State == 0x1000 && (info.Protect & 0x101) == 0 && (info.Protect & 0xCC) != 0; - if (writable) - { - for (long address = start; address < end; address += buffer.Length) - { - int size = (int)Math.Min(buffer.Length, end - address); - if (!Native.ReadProcessMemory(handle, (nint)address, buffer, (nuint)size, out var count)) { failed += size; continue; } - int available = checked((int)count); scanned += available; - for (int offset = (int)((4 - address % 4) % 4); offset + 4 <= available; offset += 4) - if (BitConverter.ToInt32(buffer, offset) == 178) found.Add(address + offset); - } - } - cursor = end; - } - string identity = $"{DateTime.Now:yyyyMMdd-HHmmss}-pid{game.Id}"; - string baselinePath = Path.Combine(output, identity + "-178.json"); - File.WriteAllText(baselinePath, JsonSerializer.Serialize(new { pid = game.Id, time = DateTime.Now, scannedBytes = scanned, unreadableBytes = failed, modules, addresses = found.Select(Describe).ToArray() }, new JsonSerializerOptions { WriteIndented = true })); - Console.WriteLine($"已保存 178 快照:{found.Count} 个候选;已读 {scanned / 1024 / 1024} MB。\n{baselinePath}\n不要退出游戏。现在去芙芙工具箱改为 143,并确认实际帧率随之变化。"); - Console.Write("实际已稳定为 143 后,回到这里按回车:"); Console.ReadLine(); - if (game.HasExited) throw new InvalidOperationException("游戏进程已退出,快照不能继续比较。"); - var matches = Filter(found, 143); - string firstPath = Path.Combine(output, identity + "-143.json"); - File.WriteAllText(firstPath, JsonSerializer.Serialize(matches.Select(Describe).ToArray(), new JsonSerializerOptions { WriteIndented = true })); - Console.WriteLine($"178 → 143 同步变化的地址:{matches.Count} 个。\n现在再用芙芙工具箱改为 167,并确认实际帧率变化。"); - Console.Write("实际已稳定为 167 后,按回车:"); Console.ReadLine(); - if (game.HasExited) throw new InvalidOperationException("游戏进程已退出。"); - matches = Filter(matches, 167); - string result = Path.Combine(output, identity + "-confirmed.json"); - File.WriteAllText(result, JsonSerializer.Serialize(matches.Select(Describe).ToArray(), new JsonSerializerOptions { WriteIndented = true })); - Console.WriteLine($"两次同步变化的候选:{matches.Count} 个。结果:{result}\n结果仍需核对代码用途,不能直接认定为控制字段。可以在芙芙工具箱恢复原目标 178。"); - List Filter(List candidates, int target) - { - var bytes = new byte[4]; var result = new List(); - foreach (var candidate in candidates) - if (Native.ReadProcessMemory(handle, (nint)candidate, bytes, 4, out var read) && read == 4 && BitConverter.ToInt32(bytes) == target) result.Add(candidate); - return result; - } - object Describe(long address) - { - var module = modules.FirstOrDefault(m => address >= m.Base && address - m.Base < m.Size); - Native.VirtualQueryEx(handle, (nint)address, out var region, (nuint)Marshal.SizeOf()); - return new { address = $"0x{address:X}", module = module?.Name, modulePath = module?.Path, rva = module == null ? null : $"0x{address - module.Base:X}", allocation = $"0x{region.AllocationBase:X}", regionType = $"0x{region.Type:X}" }; - } -} -catch (Exception ex) { Console.WriteLine("未完成:" + ex.Message); } -finally { if (handle != 0) Native.CloseHandle(handle); } -Console.WriteLine("按回车退出。"); Console.ReadLine(); -record Module(string Name, string Path, long Base, int Size); -[StructLayout(LayoutKind.Sequential)] -struct MemoryInfo { public nint BaseAddress, AllocationBase; public uint AllocationProtect; public nuint RegionSize; public uint State, Protect, Type; } -static class Native -{ - [DllImport("kernel32.dll", SetLastError = true)] public static extern nint OpenProcess(uint access, bool inherit, int pid); - [DllImport("kernel32.dll", SetLastError = true)] public static extern bool ReadProcessMemory(nint process, nint address, byte[] bytes, nuint size, out nuint read); - [DllImport("kernel32.dll")] public static extern nuint VirtualQueryEx(nint process, nint address, out MemoryInfo info, nuint size); - [DllImport("kernel32.dll")] public static extern bool CloseHandle(nint handle); -} diff --git a/src/tools/FufuTargetProbe/app.manifest b/src/tools/FufuTargetProbe/app.manifest deleted file mode 100644 index 6fc600e..0000000 --- a/src/tools/FufuTargetProbe/app.manifest +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/tests/MacroHid.Scheduling.Benchmarks/MacroHid.Scheduling.Benchmarks.csproj b/tests/MacroHid.Scheduling.Benchmarks/MacroHid.Scheduling.Benchmarks.csproj deleted file mode 100644 index 678e43b..0000000 --- a/tests/MacroHid.Scheduling.Benchmarks/MacroHid.Scheduling.Benchmarks.csproj +++ /dev/null @@ -1 +0,0 @@ -Exenet8.0-windowsenableenable$(RuntimeRoot)/MacroHid.Core.dll$(RuntimeRoot)/MacroHid.Runtime.dll diff --git a/tests/MacroHid.Scheduling.Benchmarks/Program.cs b/tests/MacroHid.Scheduling.Benchmarks/Program.cs deleted file mode 100644 index c1c2b65..0000000 --- a/tests/MacroHid.Scheduling.Benchmarks/Program.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Diagnostics; -using MacroHid.Core; -using MacroHid.Runtime; -var options = new PlaybackExecutionOptions(PlaybackMode.FixedCount,1,PixelEvaluationMode.Skip,false,PrecisionMode.Balanced); -var simple = McrxParser.Parse("{\"steps\":[{\"type\":\"key.down\",\"key\":\"A\"},{\"type\":\"wait\",\"ms\":3},{\"type\":\"key.up\",\"key\":\"A\"}]}"); -var parallel = new MacroDocument(1,"parallel",[new ParallelStep(new ParallelBranchStep("left",simple.Steps),new ParallelBranchStep("right",simple.Steps))]); -var longWait = McrxParser.Parse("{\"steps\":[{\"type\":\"key.down\",\"key\":\"A\"},{\"type\":\"wait\",\"ms\":1000},{\"type\":\"key.up\",\"key\":\"A\"}]}"); -var startup = new List(); var skew = new List(); var stops = new List(); var jitter = new List(); -var process=Process.GetCurrentProcess(); int handlesBefore=process.HandleCount; -int rounds = args.Length > 0 ? int.Parse(args[0]) : 300; -for(int i=0;i=10) {startup.Add(Ms(times[0]-start));jitter.Add(Math.Abs(Ms(times[1]-times[0])-3));} - } - sink=new Probe(); - using(var executor=new MacroPlaybackExecutor(sink)) { - await executor.RunAsync(parallel,options,CancellationToken.None); - var times=sink.Times.ToArray(); - if(i>=10) skew.Add(Ms(times[1]-times[0])); - } - sink=new Probe(); - using(var executor=new MacroPlaybackExecutor(sink)) { - using var cts=new CancellationTokenSource(); - var task=executor.RunAsync(longWait,options,cts.Token); - if(!sink.Started.Wait(2000)) throw new Exception("start timeout"); - long start=Stopwatch.GetTimestamp(); cts.Cancel(); - try {await task;}catch(OperationCanceledException){} - if(i>=10) stops.Add(Ms(Stopwatch.GetTimestamp()-start)); - } - if ((i + 1) % (rounds > 300 ? 500 : 100) == 0) { - GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); process.Refresh(); - Console.WriteLine($"checkpoint runs={i+1}: handles={process.HandleCount}; threads={process.Threads.Count}; privateMiB={process.PrivateMemorySize64/1048576.0:F2}"); - } -} -process.Refresh(); -foreach(var pair in new[]{("startup",startup),("parallelSkew",skew),("stop",stops),("3msIntervalAbsoluteError",jitter)}) { - var values=pair.Item2.Order().ToArray(); - Console.WriteLine($"{pair.Item1}: n={values.Length} p50={Rank(values,.5):F4}ms p95={Rank(values,.95):F4}ms p99={Rank(values,.99):F4}ms p99.9={Rank(values,.999):F4}ms max={values[^1]:F4}ms >0.1ms={Over(values,.1):P2} >0.25ms={Over(values,.25):P2} >1ms={Over(values,1):P2}"); -} -GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); process.Refresh(); -Console.WriteLine($"handles before={handlesBefore} after={process.HandleCount}; threads={process.Threads.Count}; privateMiB={process.PrivateMemorySize64/1048576.0:F2}"); -static double Ms(long ticks)=>ticks*1000.0/Stopwatch.Frequency; -// nearest-rank on an ascending array; p99.9 equals max until n >= 1000. -static double Rank(double[] sorted,double p)=>sorted[Math.Max(1,(int)Math.Ceiling(sorted.Length*p))-1]; -static double Over(double[] sorted,double thresholdMs)=>sorted.Count(v=>v>thresholdMs)/(double)sorted.Length; -sealed class Probe : IMacroInputSink { - public readonly System.Collections.Concurrent.ConcurrentQueue Times=new(); - public readonly ManualResetEventSlim Started=new(); - public bool IsAvailable=>true; - public void Submit(uint sequence,InputAction action) {Times.Enqueue(Stopwatch.GetTimestamp());Started.Set();} - public InputSubmissionStats? GetStats()=>null; -}