Skip to content

perf(user_dict): in-memory sorted cache for user dictionary queries [WIP] - #1196

Open
fxliang wants to merge 11 commits into
rime:masterfrom
fxliang:userdict
Open

perf(user_dict): in-memory sorted cache for user dictionary queries [WIP]#1196
fxliang wants to merge 11 commits into
rime:masterfrom
fxliang:userdict

Conversation

@fxliang

@fxliang fxliang commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Pull request

Issue tracker

Fixes will automatically close the related issue

Fixes #

Feature

Describe feature of pull request

Unit test

  • Done

Manual test

  • Done

Code Review

  1. Unit and manual test pass
  2. GitHub Action CI pass
  3. At least one contributor reviews and votes
  4. Can be merged clean without conflicts
  5. PR will be merged by rebase upstream base

Additional Info

@fxliang

fxliang commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@lotem 后面那个commit不要合呀

@jimmy54

jimmy54 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

无事,顺手让gtp 5.6看了一下。供参考:

结论:优化方向合理,LevelDB 查询收益也很明显,但当前实现存在两个会改变实际输入行为的阻塞问题,我不建议按当前 HEAD 直接合并。维护者已经批准、GitHub 当前显示可干净合并,不过现有 benchmark 没覆盖这些语义回归。[PR #1196](#1196)

必须修复

  1. [P1] 普通精确匹配结果丢失 DictEntry::code

[RecruitCacheEntry()](https://github.com/rime/librime/blob/e669918759dfe83963065819e466b6c6576030bb/src/rime/dict/user_dictionary.cc#L353) 只设置了:

  • text
  • commit_count
  • weight
  • quality_len

但原来的 DfsState::RecruitEntry() 会执行 e->code = code

直接影响是缓存路径返回的普通用户词条 code 为空;这些词条后续再次学习或更新时,UpdateEntry() 无法通过 TranslateCodeToString(entry.code, ...) 还原编码,可能导致用户词频无法继续累积,候选词的精确匹配语义也可能异常。

建议让 RecruitCacheEntry() 接收当前 state->code 并设置:

e->code = state->code;

预测词条可以继续由 RecruitPredictiveEntry() 覆盖成完整编码。

  1. [P1] new_entry_prefix 没有写入 pending cache key

[UpdateEntry()](https://github.com/rime/librime/blob/e669918759dfe83963065819e466b6c6576030bb/src/rime/dict/user_dictionary.cc#L696) 在新词不存在时把 new_entry_prefix 插入数据库 key:

key.insert(0, new_entry_prefix);

但随后记录 pending 时仍然使用未加前缀的:

pu.code = code_str;

UnityTableEncoder 正在用这个参数写入 \x7fenc\x1f 命名空间。因此,新创建的编码词组在下次重建 cache 前,会被 pending 层错误地当成普通用户词条参与 CacheLookup();重建以后又会消失,造成“刚学习时可见、重载后行为变化”的不一致。

应从最终 key 提取 code,或在插入前缀时同步更新 cache code;同时需要确认带编码前缀的数据是否应进入普通 cache_——我倾向于 BuildCache() 直接过滤该内部命名空间。

测试缺口

当前新增的 630 行主要是禁用状态的性能 benchmark,并非行为回归测试。[benchmark 结果](https://github.com/rime/librime/pull/1196/files) 显示 LevelDB 查询约提升 100~570 倍,但测试基本只检查“结果非空/数量大于零”,抓不到上述问题。

合并前至少补充缓存开关两侧的等价性测试:

  • 精确匹配结果逐字段比较:text/code/weight/commit_count/quality_len
  • 查询结果被再次传入 UpdateEntry() 后能够正确学习
  • 新增、更新、删除、删除后恢复
  • transaction abort 前后结果一致
  • predictive lookup 的完整 code 和 matching_code_size
  • UnityTableEncodernew_entry_prefix 的新增词,不泄漏到普通查询
  • 同一文本对应多个不同长编码的预测结果

其他建议

  • 第二个 commit 明确写着 don't merge this commit,合并时应 squash/drop benchmark commit,或整理成可维护的独立 benchmark。
  • cache_built_tick_ 目前只赋值、没有读取,可以删除或真正用于失效检测。
  • Reload() 忽略了 FetchTickCount() 的失败;建议失败时不要把 cache 标记为有效。
  • pending_ 查询是线性扫描,最坏会在每个 cache entry、每层 DFS 中重复扫描近 1000 项。不是当前阻塞点,但适合改为以 (code, text) 为键的 map。

整体评价:数据结构选择和性能目标值得保留,但缓存实现必须保证它只是数据库查询路径的透明替换。当前最关键的是补齐 DictEntry::code,并处理好内部编码前缀;完成这两项和等价性测试后再合并比较稳妥。

@fxliang

fxliang commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

更新了,我先拉人试毒看看

如果内存占用太大后面可以考虑加开关

@fxliang fxliang changed the title perf(user_dict): in-memory sorted cache for user dictionary queries perf(user_dict): in-memory sorted cache for user dictionary queries [WIP] Jul 31, 2026
fxliang added 8 commits July 31, 2026 11:12
Replace DfsLookup (per-query LevelDB forward-scans) with a sorted
in-memory array built at Load() time. CacheLookup binary-searches
the sorted cache (O(log N)) instead of performing multiple DB seeks.

LevelDB benchmark shows ~100-570× faster lookups with no impact on
load/write latency. TextDb performance is unchanged.
…Entry

User DB key format requires a trailing space before the tab separator
(code + " \t" + text).  When custom_code is provided directly,
it may lack the trailing space, causing key mismatch with DB entries
loaded via the parser (which always appends a trailing space).
Cached lookups serve entirely from the in-memory cache, but Lookup() still
opened a DB accessor (Query + Jump) on every call — LevelDB iterator I/O that
became a ~3x slowdown once the user DB switched to a small shared block cache
and 256KB write buffer to bound memory.

- Create the accessor only in the DfsLookup fallback path.
- Fast-path CacheLookup exact-match/predictive loops when pending_ is empty
  so the per-entry pending key construction is skipped.

LevelDB lookups drop from ~4.5us back to ~1.3us (pre-flatten level); cached
queries now only touch LevelDB for the tick point-read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants