From 32ac795fc4abd04006673bf644f87a711329e78f Mon Sep 17 00:00:00 2001 From: tei Date: Sun, 11 Oct 2026 18:02:33 +0900 Subject: [PATCH] test: benchmark particle-only C FastPath CPU across R layouts --- .../STEP-119_Symbol缓存实际实现_v1.md | 10 + .../60_测试/STEP-119_RCRR多配置验收矩阵_v2.md | 93 ++++++++ .../STEP-119_粒子独占C与CPU效率_阶段验收_v1.md | 51 +++++ .../ParticleCoreLayerValidationActivity.kt | 14 +- .../activity/particle/Step113PbfGateScene.kt | 60 +++-- .../benchmark_step119_cpu.py | 192 ++++++++++++++++ .../compare_step119_cpu.py | 124 +++++++++++ .../test_step119_rcrr_scenarios.py | 205 +++++++++++++----- .../verify_step113_fast_path.py | 46 ++-- 粒子开发记录.md | 2 + 10 files changed, 710 insertions(+), 87 deletions(-) create mode 100644 docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md create mode 100644 docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md create mode 100644 tools/particle_android_validation/benchmark_step119_cpu.py create mode 100644 tools/particle_android_validation/compare_step119_cpu.py diff --git a/docs/50_Step记录/STEP-119_Symbol缓存实际实现_v1.md b/docs/50_Step记录/STEP-119_Symbol缓存实际实现_v1.md index 36914d892..e8ba87d99 100644 --- a/docs/50_Step记录/STEP-119_Symbol缓存实际实现_v1.md +++ b/docs/50_Step记录/STEP-119_Symbol缓存实际实现_v1.md @@ -64,3 +64,13 @@ Mac Metal原生CMake配置首次因PATH找不到cmake而失败,改用既有/us - Test App 增加三种 `RCRR_*_NO_TIDE` 场景,分别测试窄 C、宽 C 与错误动态 R;基于上述真实 Manifest 校准成 Native64 精确 R/C 区间,未修改正式 PBF/原场景/24 Gate。验证器精确验三段 R 边界;4项 Python unittest PASS、Style Manifest 静态索引核对 PASS、git diff --check PASS。 - **尚未完成**:新 Kotlin Test App 的用户手工 APK 编译及三场景真机 90秒回归、图像目视、Camera/Source/Style/Tile/Annotation/Context 真机失效和恢复、STEP119 13项 GTest 真跑、Metal/iOS。Mac 原有 libuv 缺失限制保持,不擅自安装或升级工具链。STEP-119 **PARTIAL,不 CLOSED**。 - 文档:`docs/60_测试/STEP-119_RCRR多配置验收矩阵_v1.md`、`docs/报告/STEP-119_RCRR多场景局限性_阶段验收_v1.md`。原始真机记录位于 ignored `artifacts/maplibre-particle/android-validation/step119-rcrr-observe-20261011_172720/partial_rcrr/`。 + +## RDC STEP-119 粒子独占 C 与 CPU 效率收敛(2026-10-11 JST) + +- 用户确认:RCRR 正常运行的 `CONTINUOUS` 原则上**只含 `RasterParticleLayer`**,`WeatherColorLayer` 与其他非逐帧图层在 R;下一轮验收必须结合 CPU/帧、CPU/秒和实际 FPS,不只观察 FastDelta。 +- RDC 已同步 Integration `main`=`80667d6c343411d03a760425438e3d292f452118`;Particle 以 `1072816740fa69b7b23cd70602a402be19e5be2a` 为起点,现有 `particle/main` 与 STEP110/111 未跟踪文件不变。 +- Native 源码检查确认 `singleContinuousSegment(plan)` 是 Fast 准入条件,Fast 上传/绘制仅访问 C 范围,所有 R 需满足原24 Gate 与真实 Render/Symbol 缓存证书;WeatherColor 放 R 后是否能安全 Build/Reuse 尚待真机验证,不能提前 PASS。 +- 将 `RCRR_C_NARROW_NO_TIDE` 改为粒子独占 C[32,33)(WeatherColor 在 R[0,32)),新增 RCR 两 R、RCRRR 四 R,以及同 Style/Plan 关闭缓存的 Full 对照;保留旧 C 宽、粒子误入 R 两种负例和原63层 `PARTIAL_RCRR`。菜单 ID 改为100~102避免新增enum与操作冲突。 +- 新增严格 SHA/HEAD/设备锁的 CPU 采样器(App进程 utime+stime、6秒采样、35秒warmup、90秒观测)、A-B-A 对照比较器;对照同时列 CPU核秒/秒、CPU估算ms/完成帧、Native FPS、FastFraction≥95%、Build/Reuse/Invalidate。ADB实机只读确认 `CLK_TCK=100`,未以旧APK冒充新测试;未进行Android编译。 +- Python 非Jest结构+CPU测试 12项 PASS。正式测试矩阵 `docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md`,本轮阶段报告 `docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md`。 +- **待验**:用户手工编译、安装新APK及Full-Fast-Full CPU实测,三种粒子独占C布局的Build/Reuse/Fast、视觉等价、变更后失效与恢复,13项GTest/Metal/iOS。STEP-119继续PARTIAL、不宣称CPU性能提升。 diff --git a/docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md b/docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md new file mode 100644 index 000000000..e383a7dc9 --- /dev/null +++ b/docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md @@ -0,0 +1,93 @@ +# STEP-119 RCRR 粒子独占 C 与 CPU 效率测试矩阵 v2 + +日期:2026-10-11 JST。状态:**TEST SOURCE PREPARED / ANDROID NOT RUN / STEP-119 PARTIAL**。本 v2 取代 v1 对“窄 C 包含 WeatherColor+Particle”的测试建议;v1 保留为历史证据,不改写当时实机事实。 + +## 一、目标与核心原则 + +原则:**一个 Continuous 段只放 RasterParticleLayer**。WeatherColorLayer 不是逐帧运动图层,正常预测/颜色不变化期间可作为 R 候选,但数据提交、forecast time、色阶、Opacity、Style、Camera、Source/Tile/Atlas 等发生实际结果变化时必须安全撤销旧缓存并 Full,再重 Build/Reuse。这些具体动态失效条件仍需真机证明,不能因为分段配置正确而提前视为 PASS。 + +Native 当前代码以 `singleContinuousSegment(plan)` 选择唯一 C;快路只在这个 C 范围执行 tweaker/upload/postRender,全部 R 需具备有效缓存、Revision 与 Symbol 结果证书;现有 24 Gate 不能放宽。因而“一个 C + 多个 R”符合技术实现边界,但不是任意 R 划分都必然适合 Fast。 + +## 二、可比 Style 与真实图层顺序 + +正式原 PBF Style 63 层、测试副本只排除无源 `tide-spots`,其余 62 层定义和次序完全保留,WeatherColor / Particle **按原插入点顺序**位于 `clip-outline` 之后,Native 预计总 64 层。基于设备导出与原正向成功相同指纹的 Style Manifest: + +- R 下部 [0,32):含基础海图 [0,31),并在索引31绘制 `rn-weather-color`;WeatherColor 留在 R 中。 +- Particle C 唯一图层 [32,33):`rn-weather-particles`。 +- 上部 R [33,64):各类静态海图、Symbol 和文字。按测试目的拆成 1/2/3 个 R 片段,不改实际图层顺序。 + +**不要在 NavSea 产品中按本测试索引硬编码**;生产实际 Style 上应按图层 ID 验证真实边界。 + +## 三、新一轮候选与负对照 + +| 场景 | 预期分段 | 目的 | +|---|---|---| +| `RCRR_FULL_CONTROL_NO_TIDE` | R[0,32) C[32,33) R[33,63) R[63,64) | 与主 RCRR 相同 Style/Plan/Field,但关闭 Retained 缓存,取得 Full CPU 基线 | +| `RCRR_C_NARROW_NO_TIDE` | R[0,32) C[32,33) R[33,63) R[63,64) | 主 RCRR 正向,仅粒子进入 C;3 个 R | +| `RCR_PARTICLE_ONLY_NO_TIDE` | R[0,32) C[32,33) R[33,64) | 仅 2 个 R,测缓存片段数量收益/开销 | +| `RCRRR_PARTICLE_ONLY_NO_TIDE` | R[0,32) C[32,33) R[33,38) R[38,63) R[63,64) | 4 个 R,测额外 RenderTarget/Composite/缓存生命周期成本 | +| `RCRR_C_WIDE_NO_TIDE` | R[0,2) C[2,38) R[38,63) R[63,64) | 历史宽 C 错误配置,继续作为负对照,不作为性能候选 | +| `RCRR_R_DYNAMIC_NO_TIDE` | R[0,33) C[33,38) R[38,63) R[63,64) | Particle 在 R 内的错误配置,必须 Fail-Closed | +| 原 `PARTIAL_RCRR` | R[0,3) C[3,39) R[39,64) R[64,66) | **已经实测**:R0 缺 tide、R2/R3 各 Build1/Reuse536、FastDelta0/FullDelta596、decisionMismatch0 | + +旧正向 `STATIC_RC_NO_TIDE` 用于证明原有 FastPath(FastDelta1819),但天气层位置与粒子独占 C 的四个 A/B 场景不相同,**不能直接拿其 CPU 数据得出 RCRR 快慢结论**。 + +## 四、CPU 指标及可比性 + +使用 Android `/proc//stat` 同一进程的 `utime+stime`(含 App 主线程与原生工作线程),以设备实际 `getconf CLK_TCK` 转换为 CPU 秒。记录该进程在观察期内消耗的累计 CPU 核秒。设备实测此接口可访问且 `CLK_TCK=100`,此预检不是新 APK 实测。 + +每场景冷启动不同 PID,固定设备、x86_64 OpenGL APK、Style URL/PBF fingerprint、中心/zoom/rotation、1920×1200 设备条件、Weather 数据、forecast pause、1024 粒子和目标 FPS30。先 Warmup35 秒(等待载入/Placement/Cache settle),再观察至少 90 秒;每 6 秒读取一次 CPU 累计值,场景 Native summary 同样按 6 秒采样,最多允许 CPU/Native 边界时间差 9 秒,否则记测量不完整。 + +同时必须记录: + +- `CPU 核秒/秒 = Δ(utime+stime)/CLK_TCK/观察秒数`;乘100为**相对单核的进程 CPU%**,多线程时可超过100%。 +- `Native FPS = (ΔFast+ΔFull)/Native摘要实际区间秒数`。 +- `估算 CPU ms/完成帧 = (CPU核秒/秒) × 1000 / Native FPS`;这是进程级近似值,不是 C++ 单次 render 函数的精确 CPU 耗时。 +- FastFraction、Full/Fast 增量、R Build/Reuse/Invalidate、24 Gate mismatch、源指纹、生命周期、帧率、帧率波动及进程 PID。 +- Full→Fast→Full(A-B-A)控制热机/网络缓存时序偏差;Full 前后的 CPU 核秒/秒漂移超过15%则需要复测,不凭一次高速帧数下性能结论。 +- 如果 Fast FPS 比 Full 高而 CPU/秒也提高,必须同时呈报“每帧成本下降”和“整机持续负载未必下降”。`CPUms/frame`改善不等于省电或 GPU time 已改善。 + +## 五、顺序执行(用户手工编译一次,其余通过 RDC) + +必须先将本轮 Test App 修正提交/Push,**由用户手工使用当前 HEAD** 进行 Android APK 构建/安装;禁止用旧 `3d989a...` APK 冒充新场景。完成后 RDC 检查 build-info commit、SHA-256、设备 base.apk SHA 和目标 ABI。 + +```bash +cd /Users/tei/dev/MapLibreParticleLab +./tools/particle_android_validation/build_validation_apk.sh +adb -s 192.168.200.139:5555 install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk +python3 -B tools/particle_android_validation/verify_step113_fast_path.py --serial 192.168.200.139:5555 --preflight-only + +# 每条命令执行完读取状态/result.json,再进入下一条;A-B-A: +python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_FULL_CONTROL_NO_TIDE --seconds 90 --warmup 35 +python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_C_NARROW_NO_TIDE --seconds 90 --warmup 35 +python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRR_FULL_CONTROL_NO_TIDE --seconds 90 --warmup 35 +python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCR_PARTICLE_ONLY_NO_TIDE --seconds 90 --warmup 35 +python3 -B tools/particle_android_validation/benchmark_step119_cpu.py --scenario RCRRR_PARTICLE_ONLY_NO_TIDE --seconds 90 --warmup 35 +``` + +每次自动写入 Git 忽略目录: +`artifacts/maplibre-particle/android-validation/step119-rcrr-cpu/<时间>-<场景>/result.json`;同目录保存日志和启动记录。脚本全程真实读取现有 Native 摘要,不改 Gate、不执行 Gradle。验证器 Fast 场景须稳定 Fast 比例≥95%,Full 对照须 FastDelta=0、FullDelta>0。未满足时标记 `MIXED_OR_NO_FAST` 或 `NO_EXPECTED_FRAMES`,**不把回退帧混入 Fast 成本声称速度提升**。 + +在取得相应结果路径后使用: + +```bash +python3 -B tools/particle_android_validation/compare_step119_cpu.py \ + --full-before '<第1次Full/result.json>' \ + --fast '' \ + --full-after '<第2次Full/result.json>' \ + --rcr '' \ + --rcrrr '' +``` + +结果自动核验同 SHA/设备/Style 指纹、Native窗口、失配、Full控制与顺序漂移,输出 CPU per frame 节省比例、CPU 核秒/秒和 FPS 对照。若主 RCRR 尚不能稳定 Fast,不执行“有性能提升”的结论,只报告准确的 Gate 和成本。 + +## 六、其他 STEP-119 待验 + +相同视角的 Full/Fast 截图与 Symbol/文字/航标图层层序人工对比;Camera pan/zoom/rotate、Source/Style/Tile/Annotation/context/forecast 变更导致的证书失效和 Full → Build → Reuse 恢复;13 项新增 C++ GTest 真实运行和 macOS Metal/iOS 独立验证,均保持 NOT VERIFIED。Mac 现存 libuv CMake 配置缺口不授权升级第三方系统工具。全任务 STEP119 仍是 `PARTIAL`,本批仅准备测试能力,没有 Android 编译或新数据。 + +## 七、保护 + +- 不改变 Shared Core 公开 API、24 Gate、Renderer 行为、正式远端 PBF Style、发行 Tag/SOURCE_LOCK。 +- 原 `STATIC_RC` 63 层与历史 RCRR/负对照保留。 +- 不清理两份 STEP110/111 未跟踪文档。 +- 本阶段只把 Test App/CPU 工具/中文报告/Step/开发记录按 Git 任务范围提交到已有 `particle/main`,Push后回读远端 HEAD。 diff --git a/docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md b/docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md new file mode 100644 index 000000000..01af96742 --- /dev/null +++ b/docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md @@ -0,0 +1,51 @@ +# STEP-119 粒子独占 C 与 CPU 效率阶段验收报告 v1 + +日期:2026-10-11(JST)。阶段结果:**SOURCE_READY / CPU_BENCH_READY / ANDROID_NEW_APK_NOT_RUN / STEP119_PARTIAL**。 + +## 一、可行性结论 + +用户确认 RCRR 的正常使用原则是 C 只放粒子,后续必须以实际 CPU 效率衡量 FastPath 而非单看 Fast 计数。源码检查确认:Native `tryRenderParticleFastFrame()` 使用 `singleContinuousSegment(plan)`;Fast 执行仅访问当前唯一 C 段的 LayerGroup,R 需全部缓存有效、无版本/内容/Placement/Annotation 等 24 Gate 安全问题。原则**有条件可行**,不等于 WeatherColor 放入 R 后已经获得 Fast 资格。 + +**没有取得新 APK 的 Full/Fast CPU 性能对比数值**;本报告仅完成测试场景、测量器与验收准备。不能宣称 CPU 性能提升、GTest 或 iOS 完成。 + +## 二、实际基线和执行状态 + +- 设备 Mac.lan,通过 RDC 操作 `/Users/tei/dev/MapLibreParticleLab`;原有 `particle/main` 分支,起点 `1072816740fa69b7b23cd70602a402be19e5be2a`。 +- Integration `/Users/tei/dev/navsea-particle-integration` 已 `fetch main`、`merge --ff-only origin/main`,本地/远端一致 `80667d6c343411d03a760425438e3d292f452118`。已依据当前规则确认 Android/iOS/Shared Core 边界及项目 AGENTS。 +- 既有设备 APK 源 commit `3d989a00a822989339a6d7cb4d23acc04396670e`、SHA-256 `57d68800770d431a2ce9247406f636e7a01cc19fccaf4c59f5eb3a7c99128135`;**未重新编译或安装,不得对新场景使用旧 APK 验收**。 +- 之前的原 `PARTIAL_RCRR` 80秒真机负例保留:R0 因缺 tide Build=0/Reuse=0、R2/R3 各 Build=1/Reuse=536、FastDelta=0/FullDelta=596、decisionMismatch=0。旧 `STATIC_RC_NO_TIDE` 正向 Fast=1819,数据/图层顺序不同,不当作新 CPU 对照数据。 + +## 三、新 Test App 结构 + +不改生产 PBF、原 63 层场景或 Shared Core 的 24 Gate。Test App 在唯一无 tide 测试副本中得到 62 个基础层,插入的 WeatherColor 索引31、Particle 索引32。正常性能候选只允许 Particle C[32,33): + +- `RCRR_C_NARROW_NO_TIDE`:R[0,32)、C[32,33)、R[33,63)、R[63,64)。 +- `RCR_PARTICLE_ONLY_NO_TIDE`:R[0,32)、C[32,33)、R[33,64)。 +- `RCRRR_PARTICLE_ONLY_NO_TIDE`:R[0,32)、C[32,33)、R[33,38)、R[38,63)、R[63,64)。 +- `RCRR_FULL_CONTROL_NO_TIDE`:与第一组同源、同图层、同 RCRR Plan,但调用 `setRetainedUnderlayEnabled(false)`,目的是同屏 Full CPU 基线;预期原生 Fast=0,实际须设备证明。 + +老 `RCRR_C_WIDE_NO_TIDE`、`RCRR_R_DYNAMIC_NO_TIDE` 仍保留为错误划分安全负对照,不列为正常性能候选。原 `PARTIAL_RCRR` 和 `STATIC_RC` 及 v1 报告均保留历史事实。 + +Test App 菜单的操作 ID 从10/11/12迁移到100/101/102,避免新增 enum 场景 10/11 与 Snapshot、Mutate 冲突;Full 对照按钮显示 CACHE OFF。Native 摘要新增 `sampleElapsedMs` 与 benchmark 档 6 秒报告周期,仅用于关联 CPU 采样,不修改 Gate 或 Render 路径。 + +## 四、CPU 真实测量方案 + +新增 `tools/particle_android_validation/benchmark_step119_cpu.py`:在用户提供相同源码构建并正确安装的 APK 上,调用既有严格 `inspect()` 锁 commit、SHA、ABI/安装设备;启动目标 Activity,Warmup35秒,稳定观察90秒,按6秒读取 Android `/proc/PID/stat` 的 utime+stime 和 `/proc/uptime`。设备访问能力已只读检查:真实 `CLK_TCK=100`,`/proc//stat` 可读取,但**CPU Benchmark 本身尚未对新 APK 运行**。 + +输出 `CPU Core Seconds`、`CPU Core Equivalent`(CPU%相对单核)、`Native FPS`、Fast/Full增量、FastFraction、估算 `CPU ms/完成帧` 和分段 Build/Reuse/Invalidation。CPU 为**整个 App 进程**的时间,并非 C++ renderer 独占的精准逐帧计时。Fast 稳定区间要求 FastFraction≥95%,Full 对照要求 Full>0、Fast=0,且 24 Gate mismatch=0;失败按原始事实保存 JSON 并返回非零。 + +新增 `tools/particle_android_validation/compare_step119_cpu.py`:读取 Full→Fast→Full A-B-A 实际结果与 RCR/RCRRR 结果,校验同设备/APK SHA/Style 指纹、场景生命周期和 CPU/Native 边界,将 Full 两轮作为 CPU/帧基线,计算各 Fast 组 CPU/帧变化比例及 CPU/秒和 FPS。若 Full 两端 CPU/秒漂移大于15%,结果标记需复测。此比较用于检验 CPU FastPath 收益,**不代表 GPU 时间、能耗或省电已经证明**;FPS 不同时必须同时呈报 CPU每秒与CPU每帧。 + +## 五、当前已执行验证与未执行项 + +- Python 非 Jest 结构/CPU 算法测试:12 项 PASS,运行退出码 0;涵盖4类有效性能结构与2类保守负例、错误分段/缺源/Annotation失败、原 STATIC_RC / 原 PBF63 边界、proc/stat解析与 CPU/fps 归一计算。 +- CPU 工具 CLI 参数校验/AST:PASS;Android ADB 读设备时钟率:`100`(只读)。 +- `git diff --check`:本阶段代码检查通过,最终 commit/push 信息以后续 RDC 实际状态补充。 +- 新 Kotlin Test App 尚未经用户手工 Gradle 编译/打包;四种 CPU 场景 Native 真实 Full/Fast、CPU时间、帧率与图像仍为 **NOT VERIFIED**。 +- Source/Style/Tile/Camera/Annotation 等失效及恢复、13项 STEP119 Native GTest、Metal/iOS 独立验证均未完成。Mac libuv 既有缺失不擅自装依赖/升级系统工具。 + +## 六、工程风险与下一步 + +只有在 C[32,33) 实际得到 Fast、视觉匹配及 CPU/帧或 CPU/秒的受控改善后,才考虑产品采用。多 R 可能提高 FBO/Composite 成本;WeatherColor 放 R 可能因当前 Core 类型/动态数据结果证书被保守拒绝;如果 Main CPU 指标改善但持续功耗/热度上升,也不等于总体有益。 + +详见 `docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md`。本报告是阶段验收,不进入发行、不创建 Tag/SOURCE_LOCK,也不覆盖上轮或未跟踪的 STEP110/111。Git 以 RDC 实际新 commit/push/远端 SHA 返回为准。 diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt index 1822e3d99..0c8f6093e 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt @@ -792,16 +792,16 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { Step113PbfGateScene.Scenario.values().forEachIndexed { index, scenario -> menu.add(0, index, index, "STEP-113 正式 PBF · ${scenario.name}") } - menu.add(0, 10, 10, "EXPORT LAST SNAPSHOT") - menu.add(0, 11, 11, "MUTATE PROBE SOURCE ONCE") - menu.add(0, 12, 12, "EXIT STEP-113") + menu.add(0, 100, 100, "EXPORT LAST SNAPSHOT") + menu.add(0, 101, 101, "MUTATE PROBE SOURCE ONCE") + menu.add(0, 102, 102, "EXIT STEP-113") setOnMenuItemClickListener { item -> when (item.itemId) { in Step113PbfGateScene.Scenario.values().indices -> startStep113Scene(Step113PbfGateScene.Scenario.values()[item.itemId]) - 10 -> step113Scene?.export() - 11 -> step113Scene?.mutateSource() - 12 -> { stopStep113Scene(); loadValidationStyle(active, enabled, "STEP113_EXIT") } + 100 -> step113Scene?.export() + 101 -> step113Scene?.mutateSource() + 102 -> { stopStep113Scene(); loadValidationStyle(active, enabled, "STEP113_EXIT") } } true } @@ -827,7 +827,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() { styleReady = false ++styleLoadEpoch // Cancel previous asynchronous style callbacks. step113Scenario = scenario - retainedCacheEnabled = true + retainedCacheEnabled = scenario != Step113PbfGateScene.Scenario.RCRR_FULL_CONTROL_NO_TIDE updateRetainedCacheButtonState() step113DiagnosticsHeight = diagnostics.layoutParams.height step113DiagnosticsMaxLines = diagnostics.maxLines diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step113PbfGateScene.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step113PbfGateScene.kt index aae912f2e..3a3107e9b 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step113PbfGateScene.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step113PbfGateScene.kt @@ -29,7 +29,8 @@ internal class Step113PbfGateScene( private val display: (String) -> Unit ) { enum class Scenario { PBF63, STATIC_RC, PARTICLE_OFF, PARTIAL_RCRR, SOURCE_CHANGE, STATIC_RC_NO_TIDE, - RCRR_C_NARROW_NO_TIDE, RCRR_C_WIDE_NO_TIDE, RCRR_R_DYNAMIC_NO_TIDE } + RCRR_C_NARROW_NO_TIDE, RCRR_C_WIDE_NO_TIDE, RCRR_R_DYNAMIC_NO_TIDE, + RCR_PARTICLE_ONLY_NO_TIDE, RCRRR_PARTICLE_ONLY_NO_TIDE, RCRR_FULL_CONTROL_NO_TIDE } private val handler = Handler(Looper.getMainLooper()) private var generation = 0 @@ -80,7 +81,8 @@ internal class Step113PbfGateScene( } }) val now = SystemClock.elapsedRealtime() - if (now - lastLogMs >= 30000L) { + val summaryIntervalMs = if (isCpuBenchmark(scenario)) 6000L else 30000L + if (now - lastLogMs >= summaryIntervalMs) { lastLogMs = now logRecord("summary", summary(native)) } @@ -112,7 +114,11 @@ internal class Step113PbfGateScene( ui.isDoubleTapGesturesEnabled(), ui.isQuickZoomGesturesEnabled()) ui.setAllGesturesEnabled(false) map.cancelTransitions() - map.setRetainedUnderlayEnabled(true) + // CPU A/B control: exactly the same style, camera, weather and RCRR plan. + // Only the retained-cache enable bit differs. + val fullControl = scenario == Scenario.RCRR_FULL_CONTROL_NO_TIDE + map.setRetainedUnderlayEnabled(!fullControl) + commandResults.put("forceFullControl", fullControl) if (!paused) handler.post(poll) map.setStyle(Style.Builder().fromUri(STYLE_URL)) production@ { loaded -> if (!running || epoch != generation) return@production @@ -190,7 +196,7 @@ internal class Step113PbfGateScene( check(!sources.has(SOURCE)) { "天气 Source ID 冲突" } sources.put(SOURCE, JSONObject().put("type", "raster-array").put("host-field-submission", true)) val layers = JSONArray() - val prefix = isRcrrVariant(scenario) + val prefix = isSegmentedScenario(scenario) for (i in 0 until effectiveBase.length()) { layers.put(effectiveBase.getJSONObject(i)) if (prefix && effectiveBase.getJSONObject(i).getString("id") == "clip-outline") addFields(layers) @@ -230,9 +236,12 @@ internal class Step113PbfGateScene( val plan = planJson(scenario, effectiveIds.last(), effectiveIds[clipIndex + 1]) parity.put("testPlanSelector", when (scenario) { Scenario.PARTIAL_RCRR -> "旧RCRR缺源负对照:R0包含缺源tide,C1混合天气与大量静态PBF" - Scenario.RCRR_C_NARROW_NO_TIDE -> "RCRR窄C:仅天气;两段后续静态R" + Scenario.RCRR_C_NARROW_NO_TIDE -> "RCRR粒子独占C:WeatherColor及海图均在R" Scenario.RCRR_C_WIDE_NO_TIDE -> "RCRR宽C:天气及部分PBF;后续静态R" - Scenario.RCRR_R_DYNAMIC_NO_TIDE -> "RCRR错误动态R:天气落R0;验证安全拒绝" + Scenario.RCRR_R_DYNAMIC_NO_TIDE -> "RCRR负例:粒子进入R,C含普通海图" + Scenario.RCR_PARTICLE_ONLY_NO_TIDE -> "RCR粒子独占C:与RCRR同Style测试缓存分段开销" + Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE -> "RCRRR粒子独占C:增加R段测试分段开销" + Scenario.RCRR_FULL_CONTROL_NO_TIDE -> "与粒子独占C的RCRR同Style/Plan;缓存禁用Full CPU基线" else -> if (hasField) "当前NavSea源码RC:rn-weather-color" else "基础63层全RETAINED" }) .put("requestedPlan", JSONObject(plan)) @@ -382,9 +391,12 @@ internal class Step113PbfGateScene( counts.put(JSONArray().put(g.optString("reason")).put(g.optLong("failures")).put(g.optLong("firstFailures"))) } val rs = JSONArray() + val segmentRanges = JSONArray() val segments = native.optJSONArray("segments") ?: JSONArray() for (i in 0 until segments.length()) { val s = segments.getJSONObject(i) + segmentRanges.put(JSONObject().put("strategy", s.optString("strategy")) + .put("begin", s.optInt("beginIndex")).put("end", s.optInt("endExclusive"))) val c = s.optJSONObject("cache") ?: continue rs.put(JSONObject().put("segment", s.optInt("segmentIndex")).put("begin", s.optInt("beginIndex")) .put("end", s.optInt("endExclusive")).put("state", c.optString("cacheState")) @@ -397,6 +409,7 @@ internal class Step113PbfGateScene( .put("placementGatePassed", s.optBoolean("placementGatePassed")) } return JSONObject().put("schema", "step113-summary").put("schemaVersion", 1).put("scenario", scenario.name) + .put("sampleElapsedMs", SystemClock.elapsedRealtime()) .put("sceneReady", ready).put("blockedReason", failureReason ?: JSONObject.NULL) .put("sceneGeneration", generation).put("full113Equivalent", false).put("basePreserved", parity.optBoolean("baseLayersPreserved")) .put("originalBaseLayerCount", parity.optInt("baseLayerCount")) @@ -415,7 +428,7 @@ internal class Step113PbfGateScene( .put("processSimulationAdvance", lastPost(native)?.opt("processSimulationAdvance") ?: JSONObject.NULL) .put("overwritten", t.optLong("overwritten")).put("decisionMismatch", t.optLong("decisionMismatch")) .put("fullRendered", t.optLong("fullRendered")).put("fastRendered", t.optLong("fastRendered")) - .put("gateFailureCounts", counts).put("retained", rs) + .put("gateFailureCounts", counts).put("segmentRanges", segmentRanges).put("retained", rs) .put("commands", JSONObject(commandResults.toString())) .put("sources", native.optJSONArray("sources") ?: JSONArray()) .put("placementChanged", native.optBoolean("placementChanged")) @@ -503,13 +516,19 @@ internal class Step113PbfGateScene( private const val ANNOTATION_LAYER = "org.maplibre.annotations.points" private const val FORECAST_MS = 1786060800000L - private fun isRcrrVariant(s: Scenario): Boolean = s == Scenario.PARTIAL_RCRR || + private fun isSegmentedScenario(s: Scenario): Boolean = s == Scenario.PARTIAL_RCRR || s == Scenario.RCRR_C_NARROW_NO_TIDE || s == Scenario.RCRR_C_WIDE_NO_TIDE || - s == Scenario.RCRR_R_DYNAMIC_NO_TIDE + s == Scenario.RCRR_R_DYNAMIC_NO_TIDE || s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRR_FULL_CONTROL_NO_TIDE + + private fun isCpuBenchmark(s: Scenario): Boolean = s == Scenario.RCRR_C_NARROW_NO_TIDE || + s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRR_FULL_CONTROL_NO_TIDE private fun isNoTideVariant(s: Scenario): Boolean = s == Scenario.STATIC_RC_NO_TIDE || s == Scenario.RCRR_C_NARROW_NO_TIDE || s == Scenario.RCRR_C_WIDE_NO_TIDE || - s == Scenario.RCRR_R_DYNAMIC_NO_TIDE + s == Scenario.RCRR_R_DYNAMIC_NO_TIDE || s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || s == Scenario.RCRR_FULL_CONTROL_NO_TIDE private fun planJson(s: Scenario, finalBaseId: String, firstAfterClipId: String): String { val segments = JSONArray().put(JSONObject().put("strategy", "RETAINED")) @@ -517,14 +536,27 @@ internal class Step113PbfGateScene( val continuousStart = when (s) { Scenario.PARTIAL_RCRR, Scenario.RCRR_C_WIDE_NO_TIDE -> "sea-area-fill" Scenario.RCRR_R_DYNAMIC_NO_TIDE -> firstAfterClipId - else -> COLOR + else -> if (s == Scenario.RCRR_C_NARROW_NO_TIDE || + s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRR_FULL_CONTROL_NO_TIDE) PARTICLE else COLOR } segments.put(JSONObject().put("strategy", "CONTINUOUS").put("startLayerId", continuousStart)) - if (isRcrrVariant(s)) { - val upperRetainedStart = if (s == Scenario.RCRR_C_NARROW_NO_TIDE) + if (isSegmentedScenario(s)) { + val particleOnlyC = s == Scenario.RCRR_C_NARROW_NO_TIDE || + s == Scenario.RCR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE || + s == Scenario.RCRR_FULL_CONTROL_NO_TIDE + val upperRetainedStart = if (particleOnlyC) firstAfterClipId else "special-pattern-line-754" segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", upperRetainedStart)) - segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", finalBaseId)) + if (s == Scenario.RCRRR_PARTICLE_ONLY_NO_TIDE) { + segments.put(JSONObject().put("strategy", "RETAINED") + .put("startLayerId", "special-pattern-line-754")) + } + if (s != Scenario.RCR_PARTICLE_ONLY_NO_TIDE) { + segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", finalBaseId)) + } } } return JSONObject().put("schemaVersion", 1).put("segments", segments).toString() diff --git a/tools/particle_android_validation/benchmark_step119_cpu.py b/tools/particle_android_validation/benchmark_step119_cpu.py new file mode 100644 index 000000000..28a425e3e --- /dev/null +++ b/tools/particle_android_validation/benchmark_step119_cpu.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""STEP-119 / RCRR CPU 对照。只启动已安装测试场景并读取真机CPU/Native计数,不编译/修改24 Gate。""" +import argparse +import json +import statistics +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +import verify_step113_fast_path as verifier + +BENCH_SCENARIOS = ( + "RCRR_FULL_CONTROL_NO_TIDE", + "RCRR_C_NARROW_NO_TIDE", + "RCR_PARTICLE_ONLY_NO_TIDE", + "RCRRR_PARTICLE_ONLY_NO_TIDE", +) + + +def parse_proc_stat(raw: str): + """Linux /proc/PID/stat: comm may contain whitespace or ')'; indices are post-comm.""" + close = raw.rfind(")") + if close <= 0 or not raw.startswith(raw.split(" ", 1)[0] + " ("): + raise ValueError("invalid /proc/PID/stat header") + rest = raw[close + 2:].split() + if len(rest) < 20: + raise ValueError("truncated /proc/PID/stat") + # Fields 14/15 utime/stime and field 22 starttime; rest starts at field 3. + return int(rest[11]) + int(rest[12]), int(rest[19]) + + +def collect_result(samples, cpu_start, cpu_end, clock_ticks, elapsed): + native_start, native_end = samples + fast_delta = native_end["fastRendered"] - native_start["fastRendered"] + full_delta = native_end["fullRendered"] - native_start["fullRendered"] + interval = (native_end["sampleElapsedMs"] - native_start["sampleElapsedMs"]) / 1000 + if interval <= 0 or fast_delta < 0 or full_delta < 0: + raise ValueError("invalid Native window/counters") + cpu_sec = (cpu_end["ticks"] - cpu_start["ticks"]) / clock_ticks + if cpu_sec < 0: + raise ValueError("CPU counter reset") + fps = (fast_delta + full_delta) / interval + cpu_cores = cpu_sec / elapsed + return { + "cpuSeconds": cpu_sec, + "cpuCoreEquivalent": cpu_cores, + "cpuPercentOfSingleCore": 100 * cpu_cores, + "nativeWindowSeconds": interval, + "nativeWindowFullFrames": full_delta, + "nativeWindowFastFrames": fast_delta, + "nativeObservedFps": fps, + "fastFraction": fast_delta / (fast_delta + full_delta) if fast_delta + full_delta else None, + "estimatedCpuMsPerNativeFrame": 1000 * cpu_cores / fps if fps else None, + "caveat": ("CPU时间为整个App进程,含UI、日志和其他线程;" + "帧数由邻近原生摘要窗口得出;CPU毫秒/帧为近似值,不是原生逐帧计时。"), + } + + +def run(args): + adb = args.adb or str(Path.home() / "Library/Android/sdk/platform-tools/adb") + info, sha, head, _ = verifier.inspect(verifier.APK, adb, args.serial) + def device(*argv): + return verifier.call([adb, "-s", args.serial, *argv]) + expected_full = args.scenario == "RCRR_FULL_CONTROL_NO_TIDE" + clock_ticks = int(device("shell", "getconf", "CLK_TCK")) + if clock_ticks <= 0: + raise RuntimeError("invalid CLOCK_TICKS") + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out = verifier.ARTIFACTS / "step119-rcrr-cpu" / f"{stamp}-{args.scenario.lower()}" + out.mkdir(parents=True, exist_ok=False) + launch = device("shell", "am", "start", "-S", "-n", verifier.ACTIVITY, + "--es", "step113_scenario", args.scenario) + (out / "launch.txt").write_text(launch + "\n", encoding="utf-8") + pid = None + for _ in range(12): + time.sleep(1) + pid_tokens = verifier.call([adb, "-s", args.serial, "shell", "pidof", + verifier.PACKAGE], check=False).split() + if pid_tokens: + pid = int(pid_tokens[0]) + break + if pid is None: + raise RuntimeError("APP_PID_MISSING") + print(f"SCENARIO={args.scenario} PID={pid} SHA_MATCHED={sha} SOURCE={head}", flush=True) + time.sleep(args.warmup) + warm_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S") + warm_samples = verifier.extract(warm_log, pid, args.scenario) + if not any(s.get("sceneReady") and not s.get("blockedReason") for s in warm_samples): + (out / "warmup_logcat.txt").write_text(warm_log, encoding="utf-8") + raise RuntimeError("scene not ready after warmup (saved log)") + + def cpu_sample(): + raw = device("shell", "sh", "-c", f"cat /proc/{pid}/stat; cat /proc/uptime") + lines = [line for line in raw.splitlines() if line.strip()] + if len(lines) != 2: + raise RuntimeError(f"unexpected /proc sample: {raw[:120]}") + ticks, born = parse_proc_stat(lines[0]) + uptime = float(lines[1].split()[0]) + return {"ticks": ticks, "startTimeTicks": born, "deviceElapsedMs": uptime * 1000, + "hostMonotonic": time.monotonic()} + + observations = [cpu_sample()] + deadline = observations[0]["hostMonotonic"] + args.seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(args.interval, remaining)) + sample = cpu_sample() + if sample["startTimeTicks"] != observations[0]["startTimeTicks"]: + raise RuntimeError("process PID reused/restarted: cannot compare") + observations.append(sample) + first_cpu, last_cpu = observations[0], observations[-1] + elapsed = last_cpu["hostMonotonic"] - first_cpu["hostMonotonic"] + raw_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S") + (out / "logcat.txt").write_text(raw_log + "\n", encoding="utf-8") + native = verifier.extract(raw_log, pid, args.scenario) + ready = [s for s in native if s.get("sceneReady") and not s.get("blockedReason") + and isinstance(s.get("sampleElapsedMs"), (float, int))] + def closest(device_time): + if not ready: + raise RuntimeError("no timestamped Native summary") + return min(ready, key=lambda s: abs(s["sampleElapsedMs"] - device_time)) + a, b = closest(first_cpu["deviceElapsedMs"]), closest(last_cpu["deviceElapsedMs"]) + offsets = [abs(a["sampleElapsedMs"] - first_cpu["deviceElapsedMs"]), + abs(b["sampleElapsedMs"] - last_cpu["deviceElapsedMs"])] + parity = all(verifier.scene_parity_valid(s, args.scenario) for s in (a, b)) + same_lifecycle = (a.get("sceneGeneration"), a.get("lifecycle")) == ( + b.get("sceneGeneration"), b.get("lifecycle")) + control_match = all(s.get("commands", {}).get("forceFullControl") == expected_full + for s in (a, b)) + metrics = collect_result((a, b), first_cpu, last_cpu, clock_ticks, elapsed) + loads = [] + for old, new in zip(observations, observations[1:]): + dt = new["hostMonotonic"] - old["hostMonotonic"] + if dt > 0: + loads.append((new["ticks"] - old["ticks"]) / clock_ticks / dt) + metrics["medianCpuCoreEquivalentPerInterval"] = statistics.median(loads) if loads else None + status = ( + "INVALID_PARITY_OR_LIFECYCLE" if not parity or not same_lifecycle or not control_match else + "SUMMARY_ALIGNMENT_LIMIT" if max(offsets) > 9000 else + "DECISION_MISMATCH" if b.get("decisionMismatch") != 0 else + "FULL_CONTROL_OBSERVED" if expected_full and metrics["nativeWindowFullFrames"] > 0 + and metrics["nativeWindowFastFrames"] == 0 else + "FAST_DOMINANT" if not expected_full and + metrics["fastFraction"] is not None and metrics["fastFraction"] >= 0.95 else + "MIXED_OR_NO_FAST" if not expected_full else "NO_EXPECTED_FRAMES" + ) + data = { + "schema": "step119-rcrr-cpu-benchmark-v1", + "status": status, "sourceCommit": head, "apkSha256": sha, + "device": args.serial, "pid": pid, "scenario": args.scenario, + "fullControl": expected_full, "durationSecondsRequested": args.seconds, + "durationSecondsObserved": elapsed, "warmupSeconds": args.warmup, + "clockTicksPerSecond": clock_ticks, "nativeCount": len(ready), + "nativeCpuBoundaryOffsetMs": offsets, "sameLifecycle": same_lifecycle, + "parityValid": parity, "controlMatched": control_match, + "nativeStart": a, "nativeEnd": b, + "cpuObservations": observations, "metrics": metrics, + "performanceQualification": ("进程级CPU测量仅可与同APK/相同Style相机负载的对照比较;" + "应取Full-Fast-Full或交叉顺序重复,排除热机、缓存热度和帧率不同造成的误判;" + "不能用此数据宣称GPU时间、省电或逐帧CPU精确耗时。"), + } + (out / "result.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + print(json.dumps({"status": status, "scenario": args.scenario, "metrics": metrics, + "evidence": str(out / "result.json")}, ensure_ascii=False), flush=True) + return 0 if status in ("FAST_DOMINANT", "FULL_CONTROL_OBSERVED") else 2 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--serial", default="192.168.200.139:5555") + parser.add_argument("--adb", default=None) + parser.add_argument("--scenario", choices=BENCH_SCENARIOS, required=True) + parser.add_argument("--seconds", type=int, default=90) + parser.add_argument("--warmup", type=int, default=35) + parser.add_argument("--interval", type=float, default=6.0) + args = parser.parse_args() + if args.seconds < 75 or args.warmup < 20 or args.interval < 1: + parser.error("观察至少75秒、Warmup至少20秒、采样间隔至少1秒") + try: + return run(args) + except (RuntimeError, OSError, ValueError, KeyError) as e: + print(f"STEP119_CPU_BENCH_BLOCKED: {e}", file=sys.stderr) + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/particle_android_validation/compare_step119_cpu.py b/tools/particle_android_validation/compare_step119_cpu.py new file mode 100644 index 000000000..22e81eff1 --- /dev/null +++ b/tools/particle_android_validation/compare_step119_cpu.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""STEP-119 RCRR CPU效益对比;仅整合已采集真机JSON,不会重新启动设备/构建。""" +import argparse +import json +import statistics +from datetime import datetime +from pathlib import Path + + +def read(path): + data = json.loads(Path(path).read_text(encoding="utf-8")) + if data.get("schema") != "step119-rcrr-cpu-benchmark-v1": + raise ValueError(f"invalid schema: {path}") + return data + + +def check_common(reports): + ref = reports[0] + for r in reports: + for key in ("sourceCommit", "apkSha256", "device"): + if r.get(key) != ref.get(key): + raise ValueError(f"cross-run {key} mismatch: {r['scenario']}") + for key in ("fingerprint", "actualLayers"): + if r["nativeStart"].get(key) != ref["nativeStart"].get(key): + raise ValueError(f"style/actual layers mismatch {key}: {r['scenario']}") + if r["nativeStart"].get("actualLayers") != 64: + raise ValueError("not 64-layer isolated Test App") + if r["nativeEnd"].get("lifecycle") != r["nativeStart"].get("lifecycle"): + raise ValueError("scene lifecycle changed") + if r.get("nativeCpuBoundaryOffsetMs") is None or max( + r["nativeCpuBoundaryOffsetMs"]) > 9000: + raise ValueError("CPU/Native summary not well-aligned") + if not r.get("parityValid") or not r.get("controlMatched"): + raise ValueError("parity/control mismatch") + if r["nativeEnd"].get("decisionMismatch") != 0: + raise ValueError("FastGate decisionMismatch") + return {"commit": ref["sourceCommit"], "apkSha256": ref["apkSha256"], + "device": ref["device"], "styleFingerprint": ref["nativeStart"]["fingerprint"]} + + +def summarize(full_before, fast, full_after, others): + inputs = [full_before, fast, full_after, *others] + identity = check_common(inputs) + if full_before["status"] != "FULL_CONTROL_OBSERVED" or full_after[ + "status"] != "FULL_CONTROL_OBSERVED": + raise ValueError("Full control run missing/invalid") + if full_before["scenario"] != "RCRR_FULL_CONTROL_NO_TIDE" or full_after[ + "scenario"] != "RCRR_FULL_CONTROL_NO_TIDE": + raise ValueError("Full AB control is not the same RCRR scene") + if fast["scenario"] != "RCRR_C_NARROW_NO_TIDE" or fast["status"] != "FAST_DOMINANT": + raise ValueError("fast run has no accepted particle-only-C FastPath") + def value(r, key): + x = r["metrics"].get(key) + if not isinstance(x, (int, float)) or x <= 0: + raise ValueError(f"invalid {key} in {r['scenario']}") + return x + baseline_ms = statistics.median([ + value(full_before, "estimatedCpuMsPerNativeFrame"), + value(full_after, "estimatedCpuMsPerNativeFrame"), + ]) + drift = abs(value(full_before, "cpuCoreEquivalent") - + value(full_after, "cpuCoreEquivalent")) / max( + value(full_before, "cpuCoreEquivalent"), 1e-9) + comparisons = [] + for r in [fast, *others]: + if r["status"] != "FAST_DOMINANT": + comparisons.append({"scenario": r["scenario"], "status": "FAST_NOT_STABLE", + "sourceStatus": r["status"]}) + continue + ms = value(r, "estimatedCpuMsPerNativeFrame") + comparisons.append({ + "scenario": r["scenario"], + "cpuMsPerNativeFrameEstimated": ms, + "cpuMsPerFrameSavingPercentVsFull": 100 * (1 - ms / baseline_ms), + "cpuCoreEquivalent": value(r, "cpuCoreEquivalent"), + "nativeFps": value(r, "nativeObservedFps"), + "fastFraction": r["metrics"]["fastFraction"], + "buildReuse": [ + {"segment": s["segment"], "build": s["build"], "reuse": s["reuse"], + "invalidation": s["invalidation"]} + for s in r["nativeEnd"].get("retained", []) + ], + }) + return { + "schema": "step119-rcrr-cpu-comparison-v1", "identity": identity, + "fullControlCpuMsPerFrameEstimatedMedian": baseline_ms, + "fullControlCpuCoreEquivalent": [value(full_before, "cpuCoreEquivalent"), + value(full_after, "cpuCoreEquivalent")], + "fullControlFrameRate": [value(full_before, "nativeObservedFps"), + value(full_after, "nativeObservedFps")], + "fullBeforeAfterCpuCoreRelativeDrift": drift, + "comparisons": comparisons, + "conclusion": "CPU_DRIFT_REQUIRES_RETEST" if drift > .15 else + "NUMERICAL_COMPARISON_ONLY_NEEDS_VISUAL_AND_THERMAL_VALIDATION", + "caveats": ("同源/同层序A-B-A的进程CPU时间近似对比;Full/Fast FPS不同时必须同时解读CPU/秒、CPU/帧和FPS。" + "CPU指标不等于GPU耗时或电量,未通过人工视觉和失效矩阵不能据此认定正式性能PASS。"), + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--full-before", required=True) + ap.add_argument("--fast", required=True) + ap.add_argument("--full-after", required=True) + ap.add_argument("--rcr") + ap.add_argument("--rcrrr") + ap.add_argument("--output") + args = ap.parse_args() + others = [read(p) for p in (args.rcr, args.rcrrr) if p] + result = summarize(read(args.full_before), read(args.fast), + read(args.full_after), others) + target = Path(args.output) if args.output else ( + Path(__file__).resolve().parents[2] / + "artifacts/maplibre-particle/android-validation/step119-rcrr-cpu" / + f"compare-{datetime.now().strftime('%Y%m%d_%H%M%S')}.json") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + print(f"EVIDENCE={target}") + + +if __name__ == "__main__": + main() diff --git a/tools/particle_android_validation/test_step119_rcrr_scenarios.py b/tools/particle_android_validation/test_step119_rcrr_scenarios.py index a3d71376a..9c4d1f7f1 100644 --- a/tools/particle_android_validation/test_step119_rcrr_scenarios.py +++ b/tools/particle_android_validation/test_step119_rcrr_scenarios.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""STEP-119 RCRR 验证器结构契约测试;合成样本不代表真机 GTest 或 GPU PASS。""" +"""STEP-119:基于真实 Style 索引的粒子独占 C 分段验证器测试。仅结构测试,非 GPU。""" import importlib.util import unittest from pathlib import Path @@ -10,72 +10,171 @@ verifier = importlib.util.module_from_spec(spec) spec.loader.exec_module(verifier) -def sample(retained): +def sample(ranges): return { - "planValid": True, - "basePreserved": False, - "tideLayerExcluded": True, - "originalBaseLayerCount": 63, - "effectiveBaseLayerCount": 62, - "excludedLayerId": "tide-spots", - "excludedLayerSource": "tide", - "excludedLayerCount": 1, - "actualLayers": 64, + "planValid": True, "basePreserved": False, "tideLayerExcluded": True, + "originalBaseLayerCount": 63, "effectiveBaseLayerCount": 62, + "excludedLayerId": "tide-spots", "excludedLayerSource": "tide", + "excludedLayerCount": 1, "actualLayers": 64, "commands": {"emptyBuiltinAnnotationLayerRemoved": True, "annotationSourcePreserved": True}, "sources": [{"sourceId": "navsea_delivery"}, {"sourceId": "rn-weather-fields"}], - "retained": retained, + "retained": [{"begin": start, "end": end} for start, end in ranges], + "segmentRanges": ( + [{"strategy": "RETAINED", "begin": ranges[0][0], "end": ranges[0][1]}, + {"strategy": "CONTINUOUS", "begin": ranges[0][1], "end": ranges[1][0]}] + + [{"strategy": "RETAINED", "begin": a, "end": b} + for a, b in ranges[1:]] + if len(ranges) >= 2 else [] + ), } -def regions(r0_end, r2_start): - return [ - {"begin": 0, "end": r0_end}, - {"begin": r2_start, "end": 63}, - {"begin": 63, "end": 64}, - ] +SHAPES = { + # Stable WeatherColor is in R[0,32); only Particle at [32,33) is C. + "RCRR_C_NARROW_NO_TIDE": [(0, 32), (33, 63), (63, 64)], + "RCRR_FULL_CONTROL_NO_TIDE": [(0, 32), (33, 63), (63, 64)], + "RCR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 64)], + "RCRRR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 38), (38, 63), (63, 64)], + # Intentionally unsupported boundaries: keep as independent negative scenes. + "RCRR_C_WIDE_NO_TIDE": [(0, 2), (38, 63), (63, 64)], + "RCRR_R_DYNAMIC_NO_TIDE": [(0, 33), (38, 63), (63, 64)], +} class Step119RcrrVerifierTests(unittest.TestCase): - def test_three_rcrr_shapes_have_distinct_expected_boundaries(self): - shapes = { - "RCRR_C_NARROW_NO_TIDE": regions(31, 33), - "RCRR_C_WIDE_NO_TIDE": regions(2, 38), - "RCRR_R_DYNAMIC_NO_TIDE": regions(33, 38), + def test_all_expected_shapes_pass_exact_validation(self): + for name, ranges in SHAPES.items(): + with self.subTest(name=name): + self.assertTrue(verifier.scene_parity_valid(sample(ranges), name)) + + def test_incorrect_ranges_do_not_pass_other_layouts(self): + for name, ranges in SHAPES.items(): + for other in SHAPES: + if other == name or {name, other} == { + "RCRR_C_NARROW_NO_TIDE", "RCRR_FULL_CONTROL_NO_TIDE"}: + continue + with self.subTest(name=name, other=other): + self.assertFalse(verifier.scene_parity_valid(sample(ranges), other)) + + def test_missing_source_or_annotation_guard_fails_closed(self): + for name, ranges in SHAPES.items(): + a = sample(ranges) + a["sources"].append({"sourceId": "tide"}) + self.assertFalse(verifier.scene_parity_valid(a, name)) + b = sample(ranges) + b["commands"]["emptyBuiltinAnnotationLayerRemoved"] = False + self.assertFalse(verifier.scene_parity_valid(b, name)) + + def test_invalid_last_extent_or_count(self): + for name, ranges in SHAPES.items(): + bad = [*ranges] + bad[-1] = (bad[-1][0], 65) + self.assertFalse(verifier.scene_parity_valid(sample(bad), name)) + self.assertFalse(verifier.scene_parity_valid(sample(ranges[:-1]), name)) + + def test_actual_c_segment_must_match_particle_only_boundary(self): + exact = sample(SHAPES["RCRR_C_NARROW_NO_TIDE"]) + exact["segmentRanges"][1]["end"] = 38 + self.assertFalse(verifier.scene_parity_valid( + exact, "RCRR_C_NARROW_NO_TIDE")) + missing = sample(SHAPES["RCRR_C_NARROW_NO_TIDE"]) + missing.pop("segmentRanges") + self.assertFalse(verifier.scene_parity_valid( + missing, "RCRR_C_NARROW_NO_TIDE")) + + def test_full_control_may_omit_cache_but_not_segment_plan(self): + control = sample(SHAPES["RCRR_FULL_CONTROL_NO_TIDE"]) + control["retained"] = [] + self.assertTrue(verifier.scene_parity_valid( + control, "RCRR_FULL_CONTROL_NO_TIDE")) + control["segmentRanges"][1]["begin"] = 31 + self.assertFalse(verifier.scene_parity_valid( + control, "RCRR_FULL_CONTROL_NO_TIDE")) + + def test_static_rc_no_tide_old_contract_unchanged(self): + old = sample([(0, 62)]) + self.assertTrue(verifier.scene_parity_valid(old, "STATIC_RC_NO_TIDE")) + self.assertFalse(verifier.scene_parity_valid(old, "RCRR_C_NARROW_NO_TIDE")) + + def test_legacy_original_63_layer_fails_if_changed(self): + original = {"planValid": True, "basePreserved": True, + "tideLayerExcluded": False} + self.assertTrue(verifier.scene_parity_valid(original, "PARTIAL_RCRR")) + original["tideLayerExcluded"] = True + self.assertFalse(verifier.scene_parity_valid(original, "PARTIAL_RCRR")) + + +class Step119ProcessCpuTests(unittest.TestCase): + def test_proc_stat_parses_cpu_and_stable_birth_with_parentheses(self): + spec_cpu = importlib.util.spec_from_file_location( + "benchmark", Path(__file__).with_name("benchmark_step119_cpu.py")) + # Direct loading tests the real no-third-party benchmark implementation. + import sys + parent = str(Path(__file__).parent) + if parent not in sys.path: + sys.path.insert(0, parent) + benchmark = importlib.util.module_from_spec(spec_cpu) + spec_cpu.loader.exec_module(benchmark) + parts = ["S"] + ["0"] * 19 + parts[11] = "120" + parts[12] = "20" + parts[19] = "90000" + ticks, born = benchmark.parse_proc_stat( + "222 (android app (bench)) " + " ".join(parts)) + self.assertEqual((ticks, born), (140, 90000)) + + def test_cpu_per_frame_is_normalized_by_actual_native_fps(self): + import sys + parent = str(Path(__file__).parent) + if parent not in sys.path: + sys.path.insert(0, parent) + import benchmark_step119_cpu as benchmark + a = {"fastRendered": 100, "fullRendered": 5, "sampleElapsedMs": 1000} + b = {"fastRendered": 200, "fullRendered": 5, "sampleElapsedMs": 61000} + result = benchmark.collect_result( + (a, b), {"ticks": 200}, {"ticks": 500}, 100, 60.0) + self.assertAlmostEqual(result["cpuSeconds"], 3.0) + self.assertAlmostEqual(result["cpuCoreEquivalent"], .05) + self.assertAlmostEqual(result["nativeObservedFps"], 100.0 / 60.0) + self.assertAlmostEqual(result["estimatedCpuMsPerNativeFrame"], 30.0) + + +class Step119CpuComparisonTests(unittest.TestCase): + @staticmethod + def report(scenario, status, ms, cores, fps): + native = {"fingerprint": "test-pbf", "actualLayers": 64, + "lifecycle": 7, "decisionMismatch": 0, "retained": []} + return { + "sourceCommit": "source-locked", "apkSha256": "sha-locked", + "device": "test-device", "scenario": scenario, "status": status, + "nativeStart": dict(native), "nativeEnd": dict(native), + "nativeCpuBoundaryOffsetMs": [100, 300], + "parityValid": True, "controlMatched": True, + "metrics": {"estimatedCpuMsPerNativeFrame": ms, "cpuCoreEquivalent": cores, + "nativeObservedFps": fps, "fastFraction": .98}, } - for scenario, retained in shapes.items(): - with self.subTest(scenario=scenario): - self.assertTrue(verifier.scene_parity_valid(sample(retained), scenario)) - for other in shapes: - if other != scenario: - self.assertFalse(verifier.scene_parity_valid(sample(retained), other)) - def test_missing_source_or_annotation_policy_never_passes(self): - for scenario, retained in [ - ("RCRR_C_NARROW_NO_TIDE", regions(31, 33)), - ("RCRR_C_WIDE_NO_TIDE", regions(2, 38)), - ("RCRR_R_DYNAMIC_NO_TIDE", regions(33, 38)), - ]: - with self.subTest(scenario=scenario): - a = sample(retained) - a["sources"].append({"sourceId": "tide"}) - self.assertFalse(verifier.scene_parity_valid(a, scenario)) - b = sample(retained) - b["commands"]["emptyBuiltinAnnotationLayerRemoved"] = False - self.assertFalse(verifier.scene_parity_valid(b, scenario)) + def test_valid_aba_cpu_saving_report(self): + import compare_step119_cpu as compare + a = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 40, .50, 15) + b = self.report("RCRR_C_NARROW_NO_TIDE", "FAST_DOMINANT", 20, .42, 28) + c = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 42, .51, 14) + result = compare.summarize(a, b, c, []) + self.assertAlmostEqual(result["fullControlCpuMsPerFrameEstimatedMedian"], 41) + self.assertAlmostEqual(result["comparisons"][0]["cpuMsPerFrameSavingPercentVsFull"], + 100 * (1 - 20 / 41)) + self.assertNotEqual(result["conclusion"], "CPU_DRIFT_REQUIRES_RETEST") - def test_wrong_final_extent_and_wrong_retained_count_rejected(self): - a = sample(regions(31, 33)) - a["retained"][-1]["end"] = 65 - self.assertFalse(verifier.scene_parity_valid(a, "RCRR_C_NARROW_NO_TIDE")) - b = sample(regions(31, 33)[:2]) - self.assertFalse(verifier.scene_parity_valid(b, "RCRR_C_NARROW_NO_TIDE")) - - def test_existing_static_no_tide_contract_preserved(self): - a = sample([{"begin": 0, "end": 62}]) - self.assertTrue(verifier.scene_parity_valid(a, "STATIC_RC_NO_TIDE")) - self.assertFalse(verifier.scene_parity_valid(a, "RCRR_C_NARROW_NO_TIDE")) + def test_aba_source_mismatch_is_rejected(self): + import compare_step119_cpu as compare + a = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 40, .5, 15) + b = self.report("RCRR_C_NARROW_NO_TIDE", "FAST_DOMINANT", 20, .4, 28) + c = self.report("RCRR_FULL_CONTROL_NO_TIDE", "FULL_CONTROL_OBSERVED", 41, .5, 15) + b["apkSha256"] = "different-apk" + with self.assertRaises(ValueError): + compare.summarize(a, b, c, []) if __name__ == "__main__": diff --git a/tools/particle_android_validation/verify_step113_fast_path.py b/tools/particle_android_validation/verify_step113_fast_path.py index 3f0f98363..52ac5dc9e 100755 --- a/tools/particle_android_validation/verify_step113_fast_path.py +++ b/tools/particle_android_validation/verify_step113_fast_path.py @@ -19,9 +19,12 @@ BUILD_INFO = ARTIFACTS / "build-info.txt" PACKAGE = "org.maplibre.android.testapp" ACTIVITY = f"{PACKAGE}/{PACKAGE}.activity.particle.ParticleCoreLayerValidationActivity" SCENARIOS = ("STATIC_RC", "PBF63", "PARTICLE_OFF", "PARTIAL_RCRR", "SOURCE_CHANGE", "STATIC_RC_NO_TIDE", - "RCRR_C_NARROW_NO_TIDE", "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE") + "RCRR_C_NARROW_NO_TIDE", "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE", + "RCR_PARTICLE_ONLY_NO_TIDE", "RCRRR_PARTICLE_ONLY_NO_TIDE", "RCRR_FULL_CONTROL_NO_TIDE") NO_TIDE_SCENARIOS = {"STATIC_RC_NO_TIDE", "RCRR_C_NARROW_NO_TIDE", - "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE"} + "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE", + "RCR_PARTICLE_ONLY_NO_TIDE", "RCRRR_PARTICLE_ONLY_NO_TIDE", + "RCRR_FULL_CONTROL_NO_TIDE"} LOG_ENTRY = re.compile(r"^[A-Z]/STEP113Gate\(\s*(\d+)\):\s*(\{.+\})\s*$") @@ -112,19 +115,36 @@ def extract(logcat, pid, expected): def rcrr_segments_valid(sample, scenario): """Verify exact R/C boundaries in the new isolated, 64-layer PBF Test App cases.""" retained = sample.get("retained", []) - if len(retained) != 3: + ranges = [(entry.get("begin"), entry.get("end")) for entry in retained] + expected = { + "RCRR_C_NARROW_NO_TIDE": [(0, 32), (33, 63), (63, 64)], + "RCRR_FULL_CONTROL_NO_TIDE": [(0, 32), (33, 63), (63, 64)], + "RCR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 64)], + "RCRRR_PARTICLE_ONLY_NO_TIDE": [(0, 32), (33, 38), (38, 63), (63, 64)], + "RCRR_C_WIDE_NO_TIDE": [(0, 2), (38, 63), (63, 64)], + "RCRR_R_DYNAMIC_NO_TIDE": [(0, 33), (38, 63), (63, 64)], + } + expected_r = expected.get(scenario) + if expected_r is None: return False - r0, r2, r3 = retained - if (r0.get("begin") != 0 or r2.get("end") != r3.get("begin") - or r3.get("end") != 64 or r3.get("begin") != 63): + # The actual Native segment ranges must prove that the single C consists + # only of Particle for the normal CPU candidates. An R-only list cannot + # prove the C type/range; fail closed if the Native list is missing. + boundaries = sample.get("segmentRanges") + if not isinstance(boundaries, list): return False - if scenario == "RCRR_C_NARROW_NO_TIDE": - return r0.get("end") == 31 and r2.get("begin") == 33 - if scenario == "RCRR_C_WIDE_NO_TIDE": - return r0.get("end") == 2 and r2.get("begin") == 38 - if scenario == "RCRR_R_DYNAMIC_NO_TIDE": - return r0.get("end") == 33 and r2.get("begin") == 38 - return False + expected_segments = [{"strategy": "RETAINED", "begin": expected_r[0][0], + "end": expected_r[0][1]}, + {"strategy": "CONTINUOUS", "begin": expected_r[0][1], + "end": expected_r[1][0]}] + expected_segments.extend({"strategy": "RETAINED", "begin": a, "end": b} + for a, b in expected_r[1:]) + if boundaries != expected_segments: + return False + # Cache-disabled Full control may omit cache diagnostics but must still + # supply the exact actual R/C segment list above. + return (not ranges or ranges == expected_r) if scenario == ( + "RCRR_FULL_CONTROL_NO_TIDE") else ranges == expected_r def scene_parity_valid(sample, scenario): diff --git a/粒子开发记录.md b/粒子开发记录.md index 6856aaeb2..b07d9e18c 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -1200,3 +1200,5 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 2026-10-11 STEP119实机首次FAST_OBSERVED:APK源3d989a00a822989339a6d7cb4d23acc04396670e SHA=57d68800770d431a2ce9247406f636e7a01cc19fccaf4c59f5eb3a7c99128135,FZG1-2安装/preflight PASS;STATIC_RC_NO_TIDE 90秒 FastDelta1819/FullDelta0、R Build1/Reuse3276、24Gate mismatch0、Native64,missingSourceIds=[]。仅正向目标首次通过,负例/视觉/GTest/iOS未验,STEP119 PARTIAL。详见 docs/报告/STEP-119_Android真机Fast首次通过_阶段验收_v1.md。 - 2026-10-11 STEP-119 RCRR续验:原四段 `PARTIAL_RCRR` 在已锁 APK 真机80秒 R0缺tide(Build0/Reuse0)、R2/R3各Build1/Reuse536;FastDelta0/FullDelta596、24Gate mismatch0,安全Full回退。真机原Style Manifest证实tide索引2、clip索引31、weather实际在宽C内。新增窄C、宽C、错误动态R三种Test App RCRR_NO_TIDE对照入口和精确验证器;Style结构静态核对PASS、非Jest Python 4 tests PASS。新APK编译/实机、13项GTest、Metal/iOS和视觉仍未验证,STEP119保持PARTIAL。见docs/60_测试/STEP-119_RCRR多配置验收矩阵_v1.md及docs/报告/STEP-119_RCRR多场景局限性_阶段验收_v1.md。 + +- 2026-10-11 STEP-119 RCRR CPU效率测试准备:按用户新原则,C仅保留RasterParticleLayer,WeatherColor+海图进入R;新增RCR/RCRR/RCRRR的粒子独占C布局和同Style关闭缓存的Full CPU对照。加入进程CPU核秒/秒、近似CPUms/Native完成帧、实际FPS与Full-Fast-Full A-B-A比较器,保留原24Gate和所有历史负例;修复Test App菜单ID冲突。Python 12项非Jest测试PASS、ADB CLK_TCK=100预检;新APK编译/安装和CPU真实对比仍NOT VERIFIED,STEP119继续PARTIAL。见docs/60_测试/STEP-119_RCRR多配置验收矩阵_v2.md及docs/报告/STEP-119_粒子独占C与CPU效率_阶段验收_v1.md。