Skip to content

mmap page-aligned dictionary cache - #13

Merged
Kenny2github merged 1 commit into
mainfrom
perf/mmap-dictionary-cache
Oct 23, 2026
Merged

Kenny2github merged 1 commit into
mainfrom
perf/mmap-dictionary-cache

Conversation

@ArnauvGilotra

Copy link
Copy Markdown
Collaborator

Memory-maps the dictionary. Branched from main, so none of the service-process work from #12 is in here.

Mapping the text file directly would not help much, so the cache stays. Its format is now built for mmap: page-aligned, with a page index, and pages packed tight.

Cache format (version 2, magic CANTOMAP)

  • Page 0, header: magic, version, page size (4096), the source .txt size + last-write time (a mismatch means the cache is stale), entry/block/page counts, index and data offsets, max key length.
  • Page 1 on, page index: one 12-byte entry per data block (first page, page count, first key's offset + length in a key blob), then the blob of first keys. Padded to a page boundary. For the stock Traditional dictionary this is a few thousand entries.
  • Data pages: entries in sorted key order, packed into 4 KB pages, and an entry never straddles a page. Each page starts with u16 count, u16 reserved, u16 offset[count], so the search inside a page is also binary. An entry is u16 keyLen, u16 candCount, key, candCount x (u16 len, chars).
  • Oversized entries: an entry too big for an empty page gets a run of whole pages to itself (the index records pageCount > 1).
  • Strings are UTF-16, so keys compare in place against std::wstring and candidates need no UTF-8 decode. That is also smaller than UTF-8 for CJK candidates.

Lookup path

CreateFileW (FILE_SHARE_READ | FILE_SHARE_DELETE) + CreateFileMapping + MapViewOfFile, read-only. Both handles are closed right after mapping (the view keeps the section alive). The view is owned by a shared_ptr with an UnmapViewOfFile deleter, so Dictionary stays movable for the existing background-load handoff, and reload/destroy unmaps.

  • Exact lookup: binary search the index for the last block whose first key is <= the key, then binary search that page.
  • KeysWithPrefix: same start point, then walk forward across page boundaries while keys match.
  • LongestExactPrefixLen / HasExact use exact lookup.
  • Lookup now returns by value because candidates are decoded from the mapping. The two call sites bind the result to a reference or a range-for, so they need no changes.

Overrides

The mapping is read-only, so overrides move to a small in-memory overlay that every query checks on top of the base. Semantics are unchanged: override candidates come first, stock duplicates are dropped, and override-only keys are added, including to prefix scans and the segmenter.

Safety and fallback

The cache is treated as untrusted input:

  • On map, the header (magic, version, page size, signature, region bounds against the real file size) and the whole index are validated. Blocks must tile the data region in order, and first keys must be in bounds and strictly increasing.
  • On every access, page offsets and lengths are bounds-checked against their block.
  • If the cache is missing, stale, truncated, or malformed, Load parses the text instead (the path that existed before). It then writes the cache and switches that instance to the mapping, which also frees the parsed map's memory in the host process.

Writes are atomic: the cache goes to a uniquely named temp file, then MoveFileEx replaces the old one. Windows refuses to replace a file that another process has mapped. In that case the old cache is renamed aside to .cache.old and deleted once nothing maps it.

Where caches live

This brings back the location policy from the earlier #11 iteration:

  • Stock dictionaries: the cache sits next to the file (traditional.txt.cache, simplified.txt.cache). The installer prebuilds both with the new canto-mkcache.exe. If the folder is read-only, it falls back to the per-user location.
  • Custom dictionaries: only per-user, at %LOCALAPPDATA%\CantoIME\cache\<fnv64-of-path>.cache. The options dialog prebuilds it in the background when a custom dictionary is locked in.
  • Uninstall removes {app}\*.cache, *.cache.old, and %LOCALAPPDATA%\CantoIME.

Test (runs in CI)

canto-dicttest.exe traditional.txt simplified.txt runs on a synthetic dictionary (a 5000-candidate entry that needs a multi-page run, among 1500 small keys) and on both stock dictionaries. For each one it:

  • builds the cache, maps it, and checks that Lookup, HasExact and LongestExactPrefixLen match the text parse for every key;
  • compares unbounded KeysWithPrefix for every first character (this crosses every page boundary and must cover every key), and capped (64) scans for every 1 to 3 character prefix;
  • checks that overrides give identical answers over both forms;
  • checks that stale signatures, six truncations, and corrupted header/index fields are rejected;
  • maps a cache with garbage written into its data pages and confirms a full query sweep does not crash.

The test log also prints parse time against map time.

Still needs VM testing

  • Switch-in latency with the stock dictionary after a fresh install. This is the 500 to 700 ms number from before, which should now be close to zero.
  • Switching to a custom dictionary: first switch (cold parse plus cache write) against later switches.
  • Upgrading over an install while an app has the IME active (the mapped-cache replace path).

Parsing the 10+ MB text dictionary dominated switch-in. Each dictionary now
also gets a binary cache built for memory mapping, and Load() maps it read-only
and answers queries in place instead of deserializing anything.

Format (version 2, magic CANTOMAP): a header page (source size + mtime
signature, counts, region offsets), a page-aligned index with one entry per
data block (first page, page count, first key), then 4 KB data pages packed
with sorted entries that never straddle a page, each page carrying its own
entry count and offset table. An entry too big for one page gets a run of
whole pages to itself, flagged by its page count in the index. Strings are
UTF-16 so keys compare in place and candidates need no decode.

Lookup binary-searches the index, then the one page. Prefix scans start
there and walk forward across page boundaries. The header and whole index
are validated on map, and every in-page offset and length is bounds-checked
on access, so a stale, truncated, or damaged cache falls back to the text
parse (which rewrites the cache) and never crashes the host app.

Overrides move to a small in-memory overlay consulted on top of the base
(same prepend + dedup semantics), since the mapping is read-only.

Caches are written atomically (temp file + rename; a mapped old cache is
renamed aside). The installer prebuilds both stock caches with the new
canto-mkcache.exe, the options dialog prebuilds a locked-in custom dictionary
per user, and CI runs canto-dicttest.exe, which checks every query on the
mapped cache against the text parse plus stale/truncated/corrupt handling.
@Kenny2github
Kenny2github merged commit e5d923d into main Oct 23, 2026
@Kenny2github
Kenny2github deleted the perf/mmap-dictionary-cache branch October 23, 2026 02:50
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.

2 participants