Cache directory listings to fix Linux case-folding I/O slowdown (#327) - #367
Cache directory listings to fix Linux case-folding I/O slowdown (#327)#367WillScarlettOhara wants to merge 2 commits into
Conversation
On Linux, WeiDU emulates a case-insensitive filesystem by routing every file operation through fcase() (fcase/fcase.c), whose casepath() resolved each path component with an opendir()+readdir() scan of the parent directory. With no caching a single lookup is O(entries), and load_resource() repeats it across every override directory for every resource, so a large modded override costs O(resources x files) directory scans and dominates install time. Issue WeiDUorg#327 reported COPY_EXISTING_REGEXP over BG2EE CRE going from ~1.5s to 2m46s (sys 1.2s -> 2m43s) between v249 and v251rc4, and an EET megamod install slowing by 7-8x. Add a per-directory listing cache (lowercased base name -> real base name) in fcase.c: each directory is scanned once, then component lookups are O(1). Invalidation is driven from OCaml, where the semantic operation is known: the case_ins_linux.ml wrappers add/remove/rename entries on create, unlink, mkdir, rmdir and rename, and exec_command clears the cache after running an external program. A few stray Unix.unlink calls in tpaction.ml (biff/backup handling) are routed through Case_ins.unix_unlink so those deletions are reflected in the cache. Match semantics are unchanged (same strcasecmp, same append-as-is for names that do not exist yet), so behavior on case-insensitive filesystems such as NTFS and CIOPFS is preserved. The cache is Linux-only; case_ins_{mac,win}.ml gain only a no-op fcase_cache_clear so shared code can call it unconditionally. The change is transparent and on by default whenever case-folding is on; the existing --no-case-fold / WEIDU_NO_CASE_FOLD escape hatch is unaffected. A standalone C harness over a synthetic modded-scale override shows the per-lookup cost collapse (50k files / 10k resolutions: 42.3s -> 0.018s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The install of a large mod (IWD2EE) failed with "resource [IEex_TRA.lua] not found for 'COPY'" on a file the same run had just written into override/. casepath() builds its cache keys from the path prefix it has resolved so far, and it appended every component -- including ".". WeiDU composes paths as game_path ^ "/override/...", with game_path = ".", so lookups resolved "override" under the prefix "./.", while the OCaml invalidation side keys on Filename.dirname, i.e. ".". Those are two cache entries for one directory: the mkdir of override/ and every file created in it were recorded under ".", and the "./." entry -- scanned before override/ existed -- stayed stale for the rest of the run. Resolution then stopped at the unmatched "override" component, fcase returned the path unchanged, and the case-sensitive stat() of the lowercase name failed. Give each directory exactly one key: drop "." and empty components (the latter from "//" and trailing slashes) instead of pushing them onto the resolved prefix. Add a second layer so a missed invalidation degrades into a re-scan rather than a wrong answer: stamp each cached directory with its mtime, and revalidate a MISS against it before believing it. Any mutation the OCaml wrappers do not report -- an unwrapped code path, another process -- bumps the directory's mtime and is picked up. Hits stay syscall-free, and our own add/remove re-stamp the entry so self-inflicted mutations do not force a re-scan. Installing IWD2EE Core (3073 files) on Linux, same resulting install: without the cache real 63.4s user 41.1s sys 21.7s with it real 54.9s user 40.1s sys 14.2s
|
Pushed a follow-up commit (c79688c) fixing a correctness bug I found while testing this branch against a large real-world mod install (IWD2EE, 3073 files into The bug. The install died with
The fix, in two layers:
Numbers — installing IWD2EE Core (3073 files) on Linux, identical resulting install (same file count, same
Before the fix, this same install failed outright at 0.4s, so the branch as it stood could not have completed a mod of this size. |
|
Nice work. Sorry I've been slow to respond. I hope to be able to more thoroughly review this in the next short while. |
Thank you. No rush on reviewing all this. I haven't fully tested it on my end yet because I'm currently busy with other projects, and running Linux/Windows comparisons forces me to switch back and forth via dual boot since I unfortunately only have one PC. Still, performance does tank via Wine, but these commits should allow native linux WeiDU to get closer to Windows speeds. |
|
Okay. The merge window for this will probably be v253, as I intend for v252 to be a bit of a bug-fix release, after v251 wound up being a bit useless after an ill-considered feature. |
Problem
Follow-up to #327. On Linux, WeiDU emulates a case-insensitive filesystem by
routing every file operation through
fcase()(fcase/fcase.c), whosecasepath()resolved each path component with anopendir()+ fullreaddir()scan of the parent directory. With no caching a single lookup is
O(entries),and
load_resource()(src/load.ml) repeats it across every override directoryfor every resource — so a large modded override costs
O(resources × files)directory scans and dominates install time. #327 measured
COPY_EXISTING_REGEXPover BG2EE CRE going from ~1.5s to 2m46s (
sys1.2s → 2m43s) between v249and v251rc4, and an EET megamod install slowing by 7–8×.
That issue was closed after adding the
--no-case-fold/WEIDU_NO_CASE_FOLDescape hatch, with the note that a real fix needs a different approach. This is
that approach.
Change
Add a per-directory listing cache (lowercased base name → real base name) in
fcase.c: each directory is scanned once, then component lookups areO(1).Invalidation is driven from OCaml, where the semantic operation is known — which
is what makes correct invalidation feasible (doing it blindly inside C is not):
case_ins_linux.mlmutation wrappers add / remove / rename entries on create,unlink,mkdir,rmdirandrename.exec_command(util.ml) clears the cache after running an external program.Unix.unlinkcalls intpaction.ml(biff/backup handling) arerouted through
Case_ins.unix_unlinkso those deletions are reflected.It is transparent and on by default whenever case-folding is on; the existing
--no-case-fold/WEIDU_NO_CASE_FOLDtoggle is unaffected. The cache isLinux-only —
case_ins_{mac,win}.mlgain only a no-opfcase_cache_clearsoshared code can call it unconditionally.
Correctness
strcasecmp, same append-as-is fornames that do not exist yet. Behavior on case-insensitive filesystems (NTFS,
CIOPFS, …) is therefore preserved — unlike the earlier micro-optimizations in
I/O performance on Linux should be improved #327 that broke NTFS.
FS); only a stale present entry is risky, which is why non-
Case_insmutations are routed through the wrappers and the cache is cleared after
external commands.
chdir()s, so relative cache keys (./Override) stay valid forthe whole run.
Performance
Standalone C harness exercising the real
fcase.c, oldcasepathvs new, over asynthetic modded-scale override (single pass, distinct files):
Testing
fcase.clogic verified with a standalone C harness (17 checks over a real tempdirectory tree): basic folding, create-then-read hot path without rescan,
staleness + clear, delete, nested + absolute paths, descend-into-file fallback,
rename. All pass, warning-free under
-Wall.case_ins_*.mlvariants typecheck clean.Please verify before merge in a normal build environment (the change was
developed on a box that could not build unsafe-string OCaml):
makeon Linux; confirmfcase.olinks.sysback to seconds.--no-case-fold/WEIDU_NO_CASE_FOLDstill bypass entirely.🤖 Generated with Claude Code