From 58f2d9bff4b27d7ab346f02193c5af3a6e1e86f9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 18:49:48 +0800 Subject: [PATCH] =?UTF-8?q?perf(reader):=20M32=20r2=20=E4=B9=A6=E6=9E=B6?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E6=94=B9=20level-1=20=E6=A0=A1=E9=AA=8C=20?= =?UTF-8?q?=E2=80=94=20=E7=9C=9F=E6=9C=BA=E5=86=B7=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=A6=96=E5=BC=80=203.47s=E2=86=920.13=E2=80=930.16s=EF=BC=88r?= =?UTF-8?q?1=20=E5=85=A8=E7=9B=AE=E5=BD=95=E6=A0=A1=E9=AA=8C=E8=A2=AB?= =?UTF-8?q?=E7=9C=9F=E6=9C=BA=E8=AF=81=E4=BC=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 用宿主 1.1µs/stat + M28 的**热** stat 150µs 外推「434 次≈65ms」,真机 drop_caches 后 实测是 3.15s(7.26ms/次),与 readdir 的 3.0s 同量级:全目录校验的索引命中冷启动仍要 3.06s,只比全扫快 0.2s。「3.0s→0.15s」在冷启动不成立。按任务 1 自己定的判据 (stat 接近 1s 就退回 level-1)改为只校验 Books 根 + 每个一级目录。 真机冷缓存实测(exFAT /usr/data/mnt/sd_0,434 目录/646 文件/595 本;sync + drop_caches; 探针走 /proc/uptime,UI 走 meow_ui_time_ms): - 索引命中首开:**127 / 138 / 157ms**(复测 147ms),热 dentry 107–120ms; - 全量扫描首开:**3428 / 3477ms**(复测 3711ms),热 dentry 不变(readdir 不吃 page cache); - 探针冷分项:索引载入 20–30ms;载入+rdprog×595 70–120ms;4 个目录 stat 0–30ms; - 从阅读页返回书架 29–31ms(M29 语义未变)。 → 冷启动 22–26 倍提升,方案值得做;索引实测 **63,579B**(v2;r1 全目录 97,490B)。 实现(格式 v2,magic MEOWIDX2 / version 2): - writer 回调过滤 rel 含 '/' 的目录:只落 root + 一级目录的 mtime;书/大小/格式记录仍全量; level-2+ 不落盘、载入不 stat。载入仍然 0 次 opendir/readdir(strace:0 getdents64, 真实库 4 次 stat;宿主模拟库 11 次)。 - 校验记录里出现 level-2+ rel 视为坏档;sum(books_here) 改为「≤ nbooks」合理性上界。 - fail-open/原子写/CRC/安全加固/ts 不缓存全部保留(r1 已过评审的部分未动)。 失效矩阵(报文逐条写清): 自动(根 mtime):Books 根下直接增/删/改名书;一级目录新增/删除/改名。 自动(一级目录 mtime):一级目录里直接增/删/改名书、新建子目录。 必须刷新:level-2 及更深处的任何变化(只改深层目录 mtime)。刷新=删索引+全扫。 真机 exFAT mtime 实测 **1 秒**(tm_nsec=0;同秒连续增删 mtime 不变)。我复测 10 轮 「目录刚改过就扫描、同秒再改」→ 9/10 仍命中(陈旧),与评审 29/30 一致;sleep 跨秒后 全部正常失效。头注释已把「10ms」改为「1 秒」,并写明「不在同一秒刻度的变更靠索引失效, 同秒二次变更需刷新」。 bench 修复(P3-3):MEOW_BENCH_IDX=only —— **不预先 load/scan**,直接进书架量生产首开; miss —— 先删索引再进书架。两者配合 drop_caches 即冷启动口径。旧行为(先 bench_index/ bench_scan 焐热)保留为默认模式。新增 test/shelf_index_probe_mipsel(write/scan/load/ loadts/statpaths/mtime/size)用于真机分项与 mtime 粒度验证,不进发行包。 测试:shelf_index_smoke 170 pass(新增:level-3/level-2 内变更「索引仍命中且先看到旧 列表 → 刷新后可见」、新建 level-2 目录自动失效、一级目录直接删书自动失效、v2 ndirs= 根+一级数);ASan+UBSan 干净;build_reader.sh 0 warning,core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 170 / font 14 / loaders PASS。真机 6 场景逐项验证:一级直接增/ 新建子目录/一级改名/根下删书/一级直接删书 = 自动失效;level-3 深处新增 = 仍命中(需刷新)。 交付:app.json 0.1.5/code6 → **0.1.6/code7**;reader md5 5c848c99… 1,711,988B; mpk md5 10d676f5… 814,574B;未签名不 push。设备验证后已清理 /tmp 与 m32probe, 真实 MeowData 的 shelf.idx 已删(app 自己的章节 .idx 未动),Books 434/646 不变, 已装 0.1.4 md5 4036b889… 未变。 --- patch_v2/reader_app/.gitignore | 1 + patch_v2/reader_app/app.json | 2 +- patch_v2/reader_app/app/shelf.c | 14 ++- patch_v2/reader_app/app/shelf_index.c | 22 +++- patch_v2/reader_app/app/shelf_index.h | 54 ++++++--- patch_v2/reader_app/build_reader.sh | 7 ++ patch_v2/reader_app/test/shelf_bench_ondev.c | 28 +++-- patch_v2/reader_app/test/shelf_index_bench.c | 30 ++++- patch_v2/reader_app/test/shelf_index_probe.c | 115 +++++++++++++++++++ patch_v2/reader_app/test/shelf_index_smoke.c | 93 ++++++++++++--- 10 files changed, 309 insertions(+), 57 deletions(-) create mode 100644 patch_v2/reader_app/test/shelf_index_probe.c diff --git a/patch_v2/reader_app/.gitignore b/patch_v2/reader_app/.gitignore index a3c9fb8..d708bce 100644 --- a/patch_v2/reader_app/.gitignore +++ b/patch_v2/reader_app/.gitignore @@ -21,3 +21,4 @@ *.o *.a /test/shelf_bench_ondev_mipsel +/test/shelf_index_probe_mipsel diff --git a/patch_v2/reader_app/app.json b/patch_v2/reader_app/app.json index 74e61f8..64f132a 100644 --- a/patch_v2/reader_app/app.json +++ b/patch_v2/reader_app/app.json @@ -1 +1 @@ -{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.5","version_code":6,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} +{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.6","version_code":7,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 3ec8dde..262325c 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -23,9 +23,11 @@ * M31:置顶时一并重算这本的副题(shelf_fmt_sub),「· 读」立即出现,不等下一次全量刷新; * 顶栏「刷新」同时丢弃字体路径缓存(新拷入的字库此时生效)。 * M32:书架索引落盘(app/shelf_index.c)。冷启动先试 `MeowData/os.meow.reader/index/shelf.idx`: - * 版本/CRC/root 路径/全部目录 mtime 校验通过 → 直接出书,**完全跳过 readdir** - * (真机全量扫描 3.0s 的 96% 是 434 个目录的 opendir+readdir;校验只做 434 次 stat); - * 任何异常 → 照常全量重扫(fail-open)。扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。 + * 版本、CRC、root 路径、**Books 根 + 一级目录** 的 mtime 校验通过 → 直接出书,**完全跳过 readdir**。 + * 真机冷缓存实测 434 个目录逐个 stat 要 3.15s(与 readdir 同量级),所以只校验 + * 根 + 一级目录(4 次 stat ≈20ms);代价是 level-2 及更深处的变更不会自动失效, + * 需点「刷新」(与 M29「新书靠刷新」同一约定)。任何异常 → 照常全量重扫(fail-open)。 + * 扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。 * M29 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。 */ #include "../reader_internal.h" @@ -122,7 +124,8 @@ static void scan_books_full(void) { const char *dir = READER_SD_ROOT "/Books"; shelf_scan_report rep; - /* 扫描时把每个目录的 mtime 直接流进索引临时文件,不占额外内存、也不二次 readdir。 */ + /* 扫描时把根 + 一级目录的 mtime 直接流进索引临时文件(level-2+ 由 writer 过滤掉), + * 不占额外内存、也不二次 readdir。 */ shelf_idx_writer *w = shelf_index_writer_open(dir); g_nbooks = shelf_scan_books_ex(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep, w ? shelf_index_writer_dir : NULL, w); @@ -341,7 +344,8 @@ static void shelf_on_enter(meow_view *v, void *ctx) g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0; /* 书架缓存:本进程只在首次进入时取一次 Books/(从阅读页返回不再重扫)。 * M32:首次先试索引(命中=只 stat 目录,跳过 readdir);索引不可用才全量扫描。 - * 扫描/校验在真机是 3.1s / ~0.1s 的差别(exFAT 434 目录 opendir/readdir 是扫描的唯一大头)。 + * 真机冷缓存实测:全量扫描首开 3.43–3.48s,索引命中首开 0.13–0.16s(M32 r2 报文); + * readdir 是扫描的唯一大头,而冷缓存下 434 次 stat 也要 3.15s——所以 v2 只校验根+一级。 * 代价:进程内不再自动发现新书,需点顶栏「刷新」;跨进程重启则由索引 mtime 校验兜底。 * M30:刚读完那本例外——promote_just_read() 只读它一个进度文件并重排,低成本置顶。 * 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */ diff --git a/patch_v2/reader_app/app/shelf_index.c b/patch_v2/reader_app/app/shelf_index.c index a022e40..ebd24f0 100644 --- a/patch_v2/reader_app/app/shelf_index.c +++ b/patch_v2/reader_app/app/shelf_index.c @@ -1,14 +1,18 @@ /* app/shelf_index.c — 书架索引落盘/校验(实现见 app/shelf_index.h)。 * - * 落盘格式 v1(全部小端、手工序列化,不依赖结构体布局): - * [header 24B] "MEOWIDX1" | u32 version | u32 header_size | u32 root_len | u32 rsv + * 落盘格式 v2(全部小端、手工序列化,不依赖结构体布局): + * [header 24B] "MEOWIDX2" | u32 version | u32 header_size | u32 root_len | u32 rsv * [root bytes] * [dir 记录 ×N] u32 rel_len | u32 books_here | u64 mtime_sec | u32 mtime_nsec | u32 rsv | rel + * 只含 Books 根(rel="")与一级目录(rel 不含 '/');level-2+ 不落盘。 * [book 记录 ×M] u32 rel_len | u32 size | u32 fmt | u32 rsv | rel * [trailer 24B] u32 ndirs | u32 nbooks | u32 flags | u32 depth_skipped | u32 crc32 | u32 magic * crc32 覆盖 [0, trailer_off)(含 header/root/全部记录),用来发现截断/坏页。 * rel 一律相对 Books 根,路径做 ".."/绝对路径/内嵌 NUL 校验,杜绝索引坏档造成越界读。 * + * 校验范围只有根 + 一级目录(真机冷缓存 434 次 stat=3.15s,4 次≈20ms;见头文件)。 + * level-2 及更深处的变更不会自动失效,需点「刷新」——这是本方案明确接受的代价。 + * * 写入顺序:临时文件 `shelf.idx.tmp` 全量写完(flash 尽力 fsync)后 rename 发布。 * 扫描中途被杀只会留下 tmp(下次覆盖),正式索引要么是旧的、要么是完整新的。 */ @@ -31,7 +35,7 @@ #define IDX_BOOK_REC_FIX 16 /* book 记录定长部分 */ #define IDX_MAX_SIZE (8 * 1024 * 1024) -static const unsigned char IDX_MAGIC[8] = { 'M','E','O','W','I','D','X','1' }; +static const unsigned char IDX_MAGIC[8] = { 'M','E','O','W','I','D','X','2' }; /* ---- 小端序列化 ---- */ static void put32(unsigned char *p, uint32_t v) @@ -244,7 +248,8 @@ int shelf_index_load(const char *root, shelf_book *books, int cap, rootbuf[root_len] = 0; if (strcmp(rootbuf, root) != 0) goto done; /* 换了卡/挂载点 → 重扫 */ - /* 目录记录:逐个 stat 比对 mtime(唯一允许的校验 IO,不做 readdir)。 */ + /* 校验记录:只有 Books 根 + 一级目录(v2)。逐个 stat 比对 mtime(唯一允许的校验 IO, + * 不做 readdir)。level-2+ 的 rel 含 '/',出现在 v2 文件里即视为坏档。 */ uint32_t sum_here = 0; for (uint32_t i = 0; i < ndirs; i++) { unsigned char rh[IDX_DIR_REC_FIX]; @@ -260,6 +265,7 @@ int shelf_index_load(const char *root, shelf_book *books, int cap, if (rel_len > 0) crc = crc_step(crc, rel, rel_len); rel[rel_len] = 0; if (!rel_valid(rel, (int)rel_len, 1)) goto done; + if (rel_len > 0 && strchr(rel, '/')) goto done; /* v2 只允许一级 */ char path[400]; if (join_path(root, rel, (int)rel_len, path, sizeof path) != 0) goto done; @@ -268,7 +274,7 @@ int shelf_index_load(const char *root, shelf_book *books, int cap, if (!S_ISDIR(st.st_mode)) goto done; if ((uint64_t)(int64_t)st.st_mtime != mt_sec) goto done; if ((uint32_t)(long)st.st_mtim.tv_nsec != mt_nsec) goto done; - sum_here += books_here; + sum_here += books_here; /* 只作合理性上界:根+一级里直接放的书写数 ≤ nbooks */ } /* 书记录 */ @@ -299,7 +305,8 @@ int shelf_index_load(const char *root, shelf_book *books, int cap, /* 必须正好读到 trailer 起点(多/少字节都视为坏档) */ if (ftell(f) != trailer_off) goto done; if ((crc ^ 0xFFFFFFFFu) != crc_expect) goto done; - if (sum_here != nbooks) goto done; + /* v2 只校验根+一级:books_here 之和是「根/一级里直接放的书写数」,不可能超过总书数。 */ + if (sum_here > nbooks) goto done; /* 校验全过:现读进度 ts(与全量扫描同一口径),重建副题并按最近阅读排序。 */ for (uint32_t i = 0; i < nbooks; i++) { @@ -386,6 +393,9 @@ void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec, { shelf_idx_writer *w = (shelf_idx_writer *)ctx; if (!w || !w->ok || !rel || books_here < 0) return; + /* v2:只落 Books 根("")与一级目录(rel 不含 '/')——真机冷缓存下 434 次 stat=3.15s, + * 4 次≈20ms;level-2+ 的 mtime 不参与校验(深层变更靠「刷新」,见头文件)。 */ + if (rel[0] && strchr(rel, '/')) return; size_t rl = strlen(rel); if (rl > IDX_REL_MAX) { w->ok = 0; return; } diff --git a/patch_v2/reader_app/app/shelf_index.h b/patch_v2/reader_app/app/shelf_index.h index e55a4c5..fc6d692 100644 --- a/patch_v2/reader_app/app/shelf_index.h +++ b/patch_v2/reader_app/app/shelf_index.h @@ -1,38 +1,54 @@ -/* app/shelf_index.h — 书架索引落盘(M32):把一次全量扫描的结果 + 每个目录的 mtime - * 存到 `MeowData/os.meow.reader/index/shelf.idx`,冷启动时用「逐个 stat 目录 mtime」校验, - * 命中就完全跳过 readdir(真机全量扫描 3.0s 的大头是 434 次 opendir+readdir)。 +/* app/shelf_index.h — 书架索引落盘(M32):把一次全量扫描的结果 + **Books 根与一级目录** + * 的 mtime 存到 `MeowData/os.meow.reader/index/shelf.idx`,冷启动时只做 4 次左右的 + * stat 校验,命中就完全跳过 readdir。 * - * 安全优先(fail-open):版本不符 / 文件缺失 / 截断 / CRC 错 / 任一目录 mtime 变化 / - * 任何 IO 错误 → shelf_index_load() 返回 0,调用方照常全量重扫。**绝不**用陈旧索引 - * 冒充真实书库,也绝不因为索引写失败而让书架打不开。 + * 为什么只校验一级(M32 r2,真机冷缓存实测后按任务 1 的判据改): + * 真机 exFAT(drop_caches 后)量到 434 个目录逐个 stat = **3.15s(7.26ms/次)**, + * 与全量 readdir 的 3.0s 同量级;全目录校验的索引命中冷启动仍要 3.06s,只比全扫快 0.2s, + * 任务目标不成立。而 Books 根 + 3 个一级目录共 4 次 stat,冷缓存只有 **~20ms**。 + * 真实数字(真机 drop_caches 实测,2026-11-10): + * - 冷缓存:level-1 索引命中首开 ≈0.13–0.16s;全量扫描首开 ≈3.43–3.48s; + * - 热 dentry:索引命中 ≈0.03–0.11s;全量扫描始终 ≈3.1–3.3s(readdir 不吃 page cache)。 + * - 探针分项(冷):索引载入 20–30ms;载入+rdprog×595 70–120ms;4 个目录 stat 0–30ms。 * - * 索引里**不存阅读进度 ts**:ts 每次由调用方的 ts_fn(生产=rdprog_load)现读, - * 否则「刚读完回书架」会显示旧的置顶顺序。索引只存与进度无关的扫描事实。 + * 校验范围 = Books 根 + 每个一级目录(rel 为空或不含 '/')。**level-2 及更深的目录不落盘、 + * 不 stat**(代价见下)。载入路径仍然完全没有 opendir/readdir。 * - * 目录 mtime 失效语义(存「扫描过的每个目录」的 mtime,非只存 level-1): - * - 任意目录里新增/删除/改名文件 → 该目录 mtime 变 → 校验失败 → 重扫(含 level-3 深处); - * - 一级目录改名 → 父目录 mtime 变 + 旧目录 stat 失败 → 重扫; - * - 超深(> SHELF_SCAN_MAX_DEPTH)未扫描的子树不在索引里,其内部变化不会自动失效 - * (那些书本来就不显示),需点「刷新」更新 depth_skipped 计数。 - * mtime 精度受文件系统限制(exFAT 约 10ms / FAT32 2s):与上次扫描落在同一时间刻度的 - * 变更可能漏判,点一次「刷新」必可纠正(刷新=清索引+全量重扫)。 + * 能自动失效 vs 必须点「刷新」: + * 自动(Books 根 mtime 变):Books 根下直接放书/删书/改名;一级目录新增/删除/改名。 + * 自动(一级目录 mtime 变):一级目录里**直接**放书/删书/改名/新建子目录。 + * 必须刷新:level-2 及更深处的任何变化(新增/删除/改名书籍或目录)——这些只改更深层 + * 目录的 mtime,一级目录与 Books 根都不变。这与 M29「新书靠刷新」是同一 UX。 + * 刷新 = shelf_index_remove() + 全量重扫(之后写回新索引)。 + * + * mtime 精度(真机 exFAT 实测):**1 秒**(tm_sec 有值、tm_nsec=0);同一秒内连续两次改动, + * 目录 mtime 完全不变,30 轮「扫描后同秒内新增」有 29 轮索引仍命中。因此:**不在同一 mtime + * 秒刻度内的变更靠索引失效;同秒二次变更需刷新**。FAT32 是 2 秒。 + * + * 安全优先(fail-open):版本不符 / 文件缺失 / 截断 / CRC 错 / 根或一级目录 mtime 变化 / + * 任何 IO 错误 → shelf_index_load() 返回 0,调用方照常全量重扫。**绝不**用陈旧索引冒充 + * 真实书库,也绝不因为索引写失败而让书架打不开。 + * + * 索引里**不存阅读进度 ts**:ts 每次由调用方的 ts_fn(生产=rdprog_load)现读,否则 + * 「刚读完回书架」会显示旧的置顶顺序。索引只存与进度无关的扫描事实。 */ #ifndef SHELF_INDEX_H #define SHELF_INDEX_H #include "shelf_model.h" -/* 落盘格式版本:**扫描语义变了必须 +1**(新增格式扩展名/改深度/改分组成因等), - * 版本不符时旧索引直接作废重扫。 */ -#define SHELF_INDEX_VERSION 1 +/* 落盘格式版本:**扫描语义变了必须 +1**(新增格式扩展名/改深度/改分组/改校验范围等), + * 版本不符时旧索引直接作废重扫。v2 = 只存根 + 一级目录的 mtime。 */ +#define SHELF_INDEX_VERSION 2 /* 载入并校验索引。成功返回 1(books/rep 填好、已按 ts 排序,等价于一次全量扫描); * 任何异常返回 0(fail-open,books 内容不可用,调用方必须全量重扫)。 - * 成功路径**不做 opendir/readdir**:只 open/read 索引 + 逐目录 stat + 每本书 ts_fn。 */ + * 成功路径**不做 opendir/readdir**:只 open/read 索引 + stat 根/一级目录 + 每本书 ts_fn。 */ int shelf_index_load(const char *root, shelf_book *books, int cap, shelf_ts_fn ts_fn, shelf_scan_report *rep); /* 增量写索引(扫描时逐目录上报 → 扫描结束提交)。 + * dir 回调会**过滤掉 level-2+**(只落根 + 一级目录的 mtime);书籍记录照旧全量。 * open 失败返回 NULL(卡满/写保护/无 MeowData):调用方传 NULL 即可,扫描照常。 * dir 回调签名与 shelf_dir_fn 一致,可直接传给 shelf_scan_books_ex。 * commit 成功返回 1 并原子发布(临时文件 + rename);失败返回 0 且清理临时文件。 diff --git a/patch_v2/reader_app/build_reader.sh b/patch_v2/reader_app/build_reader.sh index f21de04..9f10a82 100755 --- a/patch_v2/reader_app/build_reader.sh +++ b/patch_v2/reader_app/build_reader.sh @@ -194,3 +194,10 @@ $CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Ien ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o test/shelf_bench_ondev_mipsel ls -la test/shelf_bench_ondev_mipsel echo "build_reader OK" + +# ---------- 9) 真机索引探针(不进发行包;直接调 model/index,无 UI) ---------- +# 用法(设备):MEOW_PROBE_CFG=/MeowData/m32probe/data ./shelf_index_probe_mipsel load +echo "==> mipsel index probe (not shipped)" +$CC -Os -static -Wall -Wextra -I../sdk -Iapp test/shelf_index_probe.c \ + app/shelf_model.c app/shelf_index.c app/progress.c -o test/shelf_index_probe_mipsel +ls -la test/shelf_index_probe_mipsel diff --git a/patch_v2/reader_app/test/shelf_bench_ondev.c b/patch_v2/reader_app/test/shelf_bench_ondev.c index 055b904..0bfc6a9 100644 --- a/patch_v2/reader_app/test/shelf_bench_ondev.c +++ b/patch_v2/reader_app/test/shelf_bench_ondev.c @@ -71,14 +71,26 @@ int main(int argc, char **argv) if (meow_ui_init(&o) != 0) { printf("BENCH init_fail\n"); return 1; } meow_theme_add_listener(NULL); - bench_index(); /* M32:索引命中/未命中 + 扫描落盘(不含 UI) */ - bench_scan(); /* 纯扫描基线(不建 UI) */ - - /* 首次进书架:默认走索引命中;MEOW_BENCH_IDX=miss 先删索引 → 走全量扫描(对比用)。 */ + /* M32 r2:MEOW_BENCH_IDX 三种模式 + * 未设置 —— 先跑 bench_index()+bench_scan()(会焐热缓存,只适合看热值); + * only —— **不预先 load/scan**:直接进书架,量生产首次进入(配合 drop_caches 看冷启动); + * miss —— 同上,且先删索引:量生产「无索引全量扫描」首次进入(冷启动对比)。 + * 冷/热对照用法:sync; echo 3 > /proc/sys/vm/drop_caches; MEOW_BENCH_IDX=only ./bench */ const char *eidx = getenv("MEOW_BENCH_IDX"); - if (eidx && strcmp(eidx, "miss") == 0) { - shelf_index_remove(READER_SD_ROOT "/Books"); - printf("BENCH index_removed (miss path)\n"); + int idx_only = eidx && strcmp(eidx, "only") == 0; + int idx_miss = eidx && strcmp(eidx, "miss") == 0; + + if (!idx_only && !idx_miss) { + bench_index(); /* 索引命中/未命中 + 扫描落盘(不含 UI;会焐热缓存) */ + bench_scan(); /* 纯扫描基线(不建 UI) */ + } else { + char ip[500]; + struct stat st; + int have = (shelf_index_path(READER_SD_ROOT "/Books", ip, sizeof ip) == 0 && + stat(ip, &st) == 0); + printf("BENCH index_mode %s prewarm=0 index_present=%d index_bytes=%lld\n", + idx_miss ? "miss" : "only", have, have ? (long long)st.st_size : -1LL); + if (idx_miss) shelf_index_remove(READER_SD_ROOT "/Books"); fflush(stdout); } @@ -86,7 +98,7 @@ int main(int argc, char **argv) reader_view_create(); settings_view_create(); - /* ① 首次进书架(新进程:含全量扫描 + 建控件 + 首帧上屏) */ + /* ① 首次进书架(only/miss 模式下这就是生产首次进入;配合 drop_caches 为冷启动值) */ uint64_t t0 = now_ms(); meow_view_page_push(ACT_SHELF); uint64_t t1 = now_ms(); diff --git a/patch_v2/reader_app/test/shelf_index_bench.c b/patch_v2/reader_app/test/shelf_index_bench.c index a8a4c6c..4b10547 100644 --- a/patch_v2/reader_app/test/shelf_index_bench.c +++ b/patch_v2/reader_app/test/shelf_index_bench.c @@ -2,9 +2,12 @@ * * 用法: * ./shelf_index_bench_host [bench|indexonly] [root] - * bench —— 建虚拟书库(默认 452 目录 / 688 本,贴合真机 434/595 量级)并计时 + * bench —— 建虚拟书库(434 目录 / 595 本 / 51 个 .rar,贴真机量级)并计时 * indexonly —— 只载入已有索引一次(供 `strace -e getdents64` 断言「载入不 readdir」) * + * v2 校验范围 = Books 根 + 一级目录(真机冷缓存 4 次 stat ≈20ms;434 次=3.15s,已弃用)。 + * 宿主数字只用于看相对成本/调用次数;真机绝对值见 M32 r2 报文(drop_caches 实测)。 + * * 为什么宿主数字不能直接当真机用:宿主是 ext4/f2fs 热缓存(每次 stat ~1µs),真机 TF 卡是 * exFAT(M28 实测 1079 次 stat = 160–170ms,约 150µs/次)。本工具用来量**调用次数与代码路径** * 的相对成本;绝对毫秒按 M28 的每次 stat/条目成本外推(见报文)。 @@ -180,7 +183,7 @@ int main(int argc, char **argv) int nfiles = count_files(root, 0); printf("library: %s (%s) dirs=%d files=%d\n", root, created ? "created" : "reused", nd, nfiles); - /* ① 目录逐个 stat(校验索引的唯一 IO) */ + /* ① 目录逐个 stat(v1 全目录校验的代价;已弃用,留作对照:真机冷缓存 3.15s) */ struct stat st; int reps = 20; double t0 = now_ms(); @@ -190,6 +193,29 @@ int main(int argc, char **argv) printf("stat_all_dirs: %d x%d = %.2f ms total, %.3f ms/pass, %.2f us/stat\n", nd, reps, t1 - t0, (t1 - t0) / reps, (t1 - t0) * 1000.0 / (reps * nd)); + /* ①b v2 实际校验成本:只 stat Books 根 + 一级目录 */ + static char l1[64][512]; + int nl1 = 0; + snprintf(l1[nl1++], 512, "%s", root); + DIR *ld = opendir(root); + if (ld) { + struct dirent *e; + while ((e = readdir(ld)) && nl1 < 64) { + if (e->d_name[0] == '.') continue; + char p[512]; + snprintf(p, sizeof p, "%s/%s", root, e->d_name); + struct stat t; + if (stat(p, &t) == 0 && S_ISDIR(t.st_mode)) snprintf(l1[nl1++], 512, "%s", p); + } + closedir(ld); + } + t0 = now_ms(); + for (int r = 0; r < reps; r++) + for (int i = 0; i < nl1; i++) stat(l1[i], &st); + t1 = now_ms(); + printf("stat_level1: %d dirs (root + level-1) x%d = %.3f ms/pass, %.2f us/stat\n", + nl1, reps, (t1 - t0) / reps, (t1 - t0) * 1000.0 / (reps * nl1)); + /* ② 全量扫描(无 ts) */ double best_scan = 1e9, best_save = 1e9; int n = 0; diff --git a/patch_v2/reader_app/test/shelf_index_probe.c b/patch_v2/reader_app/test/shelf_index_probe.c new file mode 100644 index 0000000..07c8141 --- /dev/null +++ b/patch_v2/reader_app/test/shelf_index_probe.c @@ -0,0 +1,115 @@ +/* test/shelf_index_probe.c — M32 真机测量探针(不进发行包/不参与 mpk)。 + * + * 只调 shelf_model/shelf_index/progress,不初始化 UI;用 /proc/uptime 计时 + * (该内核 time32 ABI 下 clock_gettime 不可靠,见 sdk/CLOCK_ABI.md;分辨率 10ms)。 + * + * 用法(设备): + * MEOW_PROBE_CFG=/usr/data/mnt/sd_0/MeowData/m32probe/data \ + * ./shelf_index_probe /usr/data/mnt/sd_0/Books + * mode: + * write 全量扫描 + 写索引(打印 n/索引字节数) + * scan 全量扫描(不写索引) + * load 索引载入(ts_fn=NULL,只量索引+校验) + * loadts 索引载入 + 逐本 rdprog_load(生产口径) + * statpaths 冷缓存 stat 指定路径(argv[2..],不做任何枚举,避免预热) + * mtime 打印路径的 st_mtime/sec+nsec(验证 exFAT 1 秒粒度) + * size 打印索引文件字节数 + * 冷缓存:每次运行前 `sync; echo 3 > /proc/sys/vm/drop_caches`。 + * 注意:load/level1 本身会按需把 dentry/page cache 焐热,所以冷值必须每次 drop 后单跑一次。 + */ +#include "shelf_model.h" +#include "shelf_index.h" +#include "progress.h" + +#include +#include +#include +#include +#include + +static char g_cfgdir[500]; +const char *meow_config_dir(void) { return g_cfgdir; } + +static int64_t now_us(void) +{ + FILE *f = fopen("/proc/uptime", "r"); + if (!f) return 0; + long sec = 0, cs = 0; + if (fscanf(f, "%ld.%ld", &sec, &cs) != 2) cs = 0; + fclose(f); + return (int64_t)sec * 1000000 + (int64_t)cs * 10000; +} + +static uint64_t prog_ts(const char *path) +{ + rdprog p; + return (rdprog_load(path, &p) == 1) ? p.ts : 0; +} + +static int64_t index_bytes(const char *root) +{ + char ip[500]; + struct stat st; + if (shelf_index_path(root, ip, sizeof ip) != 0 || stat(ip, &st) != 0) return -1; + return (int64_t)st.st_size; +} + +int main(int argc, char **argv) +{ + if (argc < 3) { + fprintf(stderr, "用法: %s \n", argv[0]); + return 2; + } + const char *mode = argv[1]; + const char *root = argv[2]; + const char *cfg = getenv("MEOW_PROBE_CFG"); + snprintf(g_cfgdir, sizeof g_cfgdir, "%s", (cfg && cfg[0]) ? cfg : "/tmp/m32probe_cfg/data"); + + static shelf_book books[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + int64_t t0, t1; + + if (strcmp(mode, "scan") == 0) { + t0 = now_us(); + int n = shelf_scan_books(root, books, SHELF_MAX_BOOKS, NULL, &rep); + t1 = now_us(); + printf("PROBE scan_ms %lld n=%d trunc=%d skip=%d root_missing=%d\n", + (long long)((t1 - t0) / 1000), n, rep.truncated, rep.depth_skipped, rep.root_missing); + } else if (strcmp(mode, "write") == 0) { + t0 = now_us(); + shelf_idx_writer *w = shelf_index_writer_open(root); + int n = shelf_scan_books_ex(root, books, SHELF_MAX_BOOKS, NULL, &rep, + w ? shelf_index_writer_dir : NULL, w); + int ok = w ? shelf_index_writer_commit(w, books, n, &rep) : 0; + t1 = now_us(); + printf("PROBE write_ms %lld n=%d commit=%d idx_bytes=%lld\n", + (long long)((t1 - t0) / 1000), n, ok, (long long)index_bytes(root)); + } else if (strcmp(mode, "load") == 0 || strcmp(mode, "loadts") == 0) { + int with_ts = (strcmp(mode, "loadts") == 0); + t0 = now_us(); + int hit = shelf_index_load(root, books, SHELF_MAX_BOOKS, with_ts ? prog_ts : NULL, &rep); + t1 = now_us(); + printf("PROBE %s_ms %lld hit=%d n=%d trunc=%d skip=%d idx_bytes=%lld\n", + with_ts ? "loadts" : "load", (long long)((t1 - t0) / 1000), + hit, rep.nbooks, rep.truncated, rep.depth_skipped, (long long)index_bytes(root)); + } else if (strcmp(mode, "statpaths") == 0) { + /* 冷缓存 stat 指定路径(不做任何 readdir/枚举,避免预热):argv[2..] 都是路径。 + * 设备用法示例:drop_caches 后 probe statpaths ... */ + struct stat st; + int cnt = argc - 2; + t0 = now_us(); + for (int i = 2; i < argc; i++) (void)stat(argv[i], &st); + t1 = now_us(); + printf("PROBE statpaths_ms %lld npaths=%d\n", (long long)((t1 - t0) / 1000), cnt); + } else if (strcmp(mode, "mtime") == 0) { + struct stat st; + int rc = stat(argv[2], &st); + printf("PROBE mtime rc=%d sec=%lld nsec=%ld\n", rc, + (long long)st.st_mtime, (long)st.st_mtim.tv_nsec); + } else if (strcmp(mode, "size") == 0) { printf("PROBE idx_bytes %lld\n", (long long)index_bytes(root)); + } else { + fprintf(stderr, "未知 mode: %s\n", mode); + return 2; + } + return 0; +} diff --git a/patch_v2/reader_app/test/shelf_index_smoke.c b/patch_v2/reader_app/test/shelf_index_smoke.c index b4da798..35bb1a6 100644 --- a/patch_v2/reader_app/test/shelf_index_smoke.c +++ b/patch_v2/reader_app/test/shelf_index_smoke.c @@ -3,8 +3,9 @@ * 覆盖: * I1 全量扫描 → 写索引 → 载入:书数/书名/分组/path/fmt/size/ts 全部与扫描一致 * I2 载入不缓存阅读进度:同一索引换 ts_fn 再载入,ts 立刻反映新值(含真实 rdprog 文件) - * I3 四种书库变更都必须失效(fail-open 重扫): - * level-2 新增 / level-3 深处新增 / level-1 目录改名 / 删书 + * I3 v2 校验范围 = Books 根 + 一级目录: + * 自动失效:一级目录直接增/删书、新建 level-2 目录、一级目录改名、根下增删书; + * 必须刷新:level-3 深处新增、已有 level-2 目录内新增(索引仍命中,先看到旧列表) * I4 坏档 fail-open:截断 / 中间字节翻转 / 垃圾内容 / 文件缺失 / 索引路径是目录 / * 版本不符 / Books 根路径不符 —— 一律返回 0,不崩 * I5 truncated / depth_skipped 如实重建(索引里存了这两个值) @@ -86,6 +87,21 @@ static int idx_path(const char *root, char *out, int outsz) return shelf_index_path(root, out, outsz); } +/* 读索引 trailer 的 ndirs(v2 应 = 1 + 一级目录数)。 */ +static uint32_t idx_trailer_ndirs(const char *root) +{ + char ip[500]; + if (idx_path(root, ip, sizeof ip) != 0) return 0; + FILE *f = fopen(ip, "rb"); + if (!f) return 0; + unsigned char tr[24]; + if (fseek(f, -(long)sizeof tr, SEEK_END) != 0 || + fread(tr, 1, sizeof tr, f) != sizeof tr) { fclose(f); return 0; } + fclose(f); + return (uint32_t)tr[0] | ((uint32_t)tr[1] << 8) | ((uint32_t)tr[2] << 16) | + ((uint32_t)tr[3] << 24); +} + static int scan_and_save(const char *root, shelf_book *books, int cap, shelf_scan_report *rep) { shelf_idx_writer *w = shelf_index_writer_open(root); @@ -134,6 +150,9 @@ static void test_roundtrip(const char *root) struct stat st; CHECK(stat(ip, &st) == 0 && st.st_size > 0, "索引文件已落盘"); printf("index: %s (%lld bytes)\n", ip, (long long)st.st_size); + /* v2:只落根 + 一级目录(本树一级 = 轻小说/玄幻/B/D/G/400+合集/空目录 → 7,加根 = 8)。 + * 这个数直接决定载入时的 stat 次数(真机冷缓存 4 次≈20ms,434 次=3.15s)。 */ + CHECK(idx_trailer_ndirs(root) == 8, "v2 目录记录 = Books 根 + 7 个一级目录"); int n2 = shelf_index_load(root, loadb, SHELF_MAX_BOOKS, ts_of, &r2); CHECK(n2 == 1, "索引命中"); @@ -191,8 +210,21 @@ static void test_ts_not_cached(const char *root) } } -/* I3:四种变更都必须让索引失效。每次变更后:先断言 load=0(不静默用陈旧索引), - * 再全量重扫 + 写新索引,断言变化可见。 */ +/* I3(v2 = 只校验 Books 根 + 一级目录): + * 自动失效(load 返回 0 → 全扫): + * ① 一级目录里直接新增书(一级目录 mtime 变) + * ② 一级目录里新建子目录(一级目录 mtime 变) + * ③ 一级目录改名(Books 根 mtime 变 + 旧目录 stat 失败) + * ④ Books 根下删书(根 mtime 变) + * ⑤ 一级目录里直接删书(一级目录 mtime 变) + * 必须点「刷新」(load 仍命中,先看到旧列表): + * ⑥ level-3 深处新增(只改更深目录的 mtime) + * ⑦ 已有 level-2 目录里新增(只改 level-2 的 mtime) + * + * 注意:宿主 ext4/f2fs 的 mtime 有纳秒精度,usleep(20000) 足以拉开刻度;**真机 exFAT 的 + * 目录 mtime 只有 1 秒精度**(实测 tm_nsec=0),同秒内二次改动不会变——本测试无法模拟 + * 那个真机窗口(见 shelf_index.h 的 mtime 精度说明)。 + */ static void test_invalidation(const char *root) { static shelf_book b[SHELF_MAX_BOOKS]; @@ -201,22 +233,22 @@ static void test_invalidation(const char *root) CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引"); - /* ① level-2 直接新增 */ + /* ① 一级目录里直接新增(level-2 相对 Books 是直接子项) */ usleep(20000); snprintf(p, sizeof p, "%s/轻小说/新书.txt", root); write_file(p, "new"); CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, - "level-2 新增 → 索引失效"); + "一级目录直接新增 → 索引失效"); int n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); CHECK(find_title(b, n, "新书") != NULL, "重扫后新书可见"); - /* ② level-3 深处新增(Books/400+.../01、玄幻/01、斗破/) */ + /* ② 一级目录里新建 level-2 子目录(一级目录 mtime 变)→ 自动失效 */ usleep(20000); - snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/新书2.txt", root); - write_file(p, "new2"); + snprintf(p, sizeof p, "%s/轻小说/新子目录", root); mkdirs(p); + snprintf(p, sizeof p, "%s/轻小说/新子目录/深书.txt", root); write_file(p, "deep"); CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, - "level-3 深处新增 → 索引失效"); + "新建 level-2 目录 → 索引失效"); n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); - CHECK(find_title(b, n, "新书2") != NULL, "重扫后深处新书可见"); + CHECK(find_title(b, n, "深书") != NULL, "重扫后新子目录里的书可见"); /* ③ 一级目录改名 */ usleep(20000); @@ -230,14 +262,43 @@ static void test_invalidation(const char *root) CHECK(m && strcmp(m->top, "玄幻改") == 0 && strcmp(m->group, "玄幻改") == 0, "重扫后分组键跟随新目录名"); - /* ④ 删书 */ + /* ④ Books 根下删书(根 mtime 变) */ usleep(20000); snprintf(p, sizeof p, "%s/根下.txt", root); - CHECK(unlink(p) == 0, "删除一本书"); + CHECK(unlink(p) == 0, "删除 Books 根下的一本书"); CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, - "删书 → 索引失效"); + "根下删书 → 索引失效"); n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); CHECK(find_title(b, n, "根下") == NULL, "重扫后删掉的书消失"); + + /* ⑤ 一级目录里直接删书(一级目录 mtime 变) */ + usleep(20000); + snprintf(p, sizeof p, "%s/轻小说/2.epub", root); + CHECK(unlink(p) == 0, "删除一级目录里直接放的书"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, + "一级目录直接删书 → 索引失效"); + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "2") == NULL, "重扫后该书消失"); + + /* ⑥ level-3 深处新增:**不自动失效**(level-1 与根 mtime 都没变)→ 需刷新 */ + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/新书2.txt", root); + write_file(p, "new2"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, + "level-3 深处新增 → 索引仍命中(预期:需刷新)"); + CHECK(find_title(b, rep.nbooks, "新书2") == NULL, "命中时看不到深处新书(陈旧直到刷新)"); + shelf_index_remove(root); /* 顶栏「刷新」= 删索引 + 全扫 */ + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "新书2") != NULL, "刷新后深处新书可见"); + + /* ⑦ 已有 level-2 目录里新增:**不自动失效** → 需刷新 */ + snprintf(p, sizeof p, "%s/B/sub/新书3.txt", root); + write_file(p, "new3"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, + "level-2 目录内新增 → 索引仍命中(预期:需刷新)"); + CHECK(find_title(b, rep.nbooks, "新书3") == NULL, "命中时看不到 level-2 新书(陈旧直到刷新)"); + shelf_index_remove(root); + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "新书3") != NULL, "刷新后 level-2 新书可见"); } static void test_bad_index(const char *root) @@ -299,12 +360,12 @@ static void test_bad_index(const char *root) CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, ".tmp 残渣不影响载入"); unlink(tmpip); - /* 版本不符:先把版本字段改成 2 */ + /* 版本不符:先把版本字段改成 3 */ CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引"); f = fopen(ip, "r+b"); CHECK(f != NULL, "打开索引改版本"); if (f) { - unsigned char v[4] = { 2, 0, 0, 0 }; + unsigned char v[4] = { 3, 0, 0, 0 }; CHECK(fseek(f, 8, SEEK_SET) == 0, "定位版本字段"); fwrite(v, 1, sizeof v, f); fclose(f);