Fix slow shader file watch on Windows - #576
Merged
Merged
Conversation
cf_shader_watch cost ~12.5 ms every 16th frame on the render thread, scaling
linearly with the number of files in the shader directory (221 files -> ~130 ms).
Three per-entry metadata queries were responsible: CF_Path::is_directory(),
fs_stat(), and one hidden inside PHYSFS_enumerateFiles, which stats every entry
via enumCallbackFilterSymLinks just to drop symlinks from its listing.
Replace the per-file stats with one bulk directory walk (FindFirstFileW /
fdopendir+fstatat), and cache the PhysFS enumeration so the remaining hidden
stat is only paid when the file set actually changes.
PhysFS stays the authority on which files exist -- the walk is only a metadata
lookup table, with an fs_stat fallback for anything it does not hold. Taking the
file list from the walk instead would silently stop hot-reloading any shader
shadowed by a lower mount.
Correctness rests on three proofs rather than on derivation:
- the Windows time conversion mirrors PhysFS's FileTimeToPhysfsTime exactly,
since stored timestamps are compared against what PHYSFS_stat produces next;
- one fs_stat per directory per scan proves the walked real directory really
is the one PhysFS resolves files from;
- the cached listing is used only while every name PhysFS reports is visible
in at least one walk, so no mount can contribute files whose changes we
cannot see.
The cache is revalidated on cf_fs_generation() (bumped by cf_fs_mount and
cf_fs_dismount, the only calls that touch the search path) and on one bulk walk
per contributing mount, reduced to a CF_DirSignature. No timers and no periodic
work: editing a shader never re-enumerates, and adding or removing one in any
mount is detected on the same scan.
Also:
- watch .vert and .frag, so two-file cf_make_shader pairs hot-reload; the
machinery already existed and the extension filter was rejecting them;
- adopt files created after cf_shader_directory instead of tripping Map::get's
assert;
- narrow CF_ShaderFileInfo to the one field the watcher compares;
- CF_SHADER_WATCH_VERIFY runs the PhysFS path alongside the native one and
asserts they agree; off by default.
Measured on Windows 11, 2000-sample captures, sim paused, present uncapped:
before bulk scan + cache
p99, 21 files 14.72 ms 4.78 ms 2.65 ms
p99, 221 files 131.04 ms 19.71 ms 3.33 ms
frames over 6.944ms 125/2000 0 0
marginal per file ~578 us ~74 us ~3.4 us
The periodic over-budget frames spaced exactly 16 apart are gone entirely.
Two things dominated an unchanged scan once the enumerate was cached: a single PHYSFS_stat for the §3.2 metadata probe, and a heap string per entry in the walk. Neither had to be paid. The probe establishes which real directory the walk is reading. That is a property of the mount table and of the directory's own existence, and a file being written changes neither -- so re-proving it every scan bought nothing. Hold it in the cache, revalidated on exactly the triggers that already revalidate the listing: cf_fs_generation() and the per-mount signatures. Only successes are held: a probe can fail transiently when a file is written between the walk and the stat, and a cached failure would stick until the file set changed, which might be never. A directory of nothing but subdirectories still cannot be proven, and still costs nothing to discover that -- it does no stats. CF_DirEntry::name becomes an interned pointer rather than a Cute::String, so an entry owns no allocation, comparing names is a pointer compare, and the struct is trivially copyable. The cached listing is interned once at refresh instead of three times per entry per scan, which also removes a CF_Path per entry. Measured, 21 files, nothing changing: an unchanged scan drops 0.584 -> 0.258 ms, of which the probe was 0.315 -> 0.0007. What remains is two directory opens (~0.1 ms each on this machine, one of them a mount that has no such directory and must be retried every scan to notice if it gains one) plus ~2 us/entry. p99, 21 files 2.65 -> 2.54 ms p99, 221 files 3.33 -> 2.91 ms marginal/file ~3.4 -> ~1.9 us frames over 6.944 ms: 0 Hot reload, the compile-error path, CF_SHADER_WATCH_VERIFY across three mounts, add/remove churn, and run_tests.bat -q all still pass.
The d_type shortcut skipped fstatat for directories, which saved a per-file metadata query on the one platform where that query is not expensive. It bought nothing and cost the only wart in the data model -- modified_time and size were meaningful for some entries and not others, which the header had to caveat and the verify pass had to special-case. Now every entry is stat'd the same way, and Linux takes the same path macOS already did. Also swap a dummy Map for a pointer, and move the explanation of how fast directory polling works into one comment at the top of the internal header rather than spread across three files.
Function comments in src/internal/ describe the call verb-first and state return semantics in prose. Two of these read as noun phrases and one described the value rather than the call.
test_shader_reload calls cf_shader_reload_from_files directly, so nothing
exercised cf_shader_watch itself -- the scan, the extension filter, the cached
directory listing and its invalidation were all untested.
Five cases, driven through cf_shader_on_changed: registering a callback both
drops the frame throttle (one cf_app_update is then exactly one scan) and gives
a direct observable without compiling anything.
vert_and_frag_edits .vert/.frag are watched, and an edit is reported
adopts_files_created_after.. a file created post-startup is picked up, not
asserted on, and the cached listing notices it
survives_removed_files a deletion is not a modification, and not a crash
sees_files_from_a_later_mount a second mount serving the directory contributes
sees_a_mount_point_below.. a mount point below the directory adds a virtual
subdirectory no walk can see
Each case removes its own files when it finishes, and the shared setup sweeps
up after a run that failed before it got there. Without that the files survive
in the build directory and are present at populate time on the next run, which
makes the cases pass without ever exercising the path they are named for.
Verified by mutation rather than by going green:
.vert/.frag dropped from the watched set -> only vert_and_frag_edits fails
cache ignores the per-mount signatures -> the three file-appearance cases fail
cf_fs_mount stops bumping the generation -> only the mount-point case fails
That last one is worth recording: a *root* mount is caught by the candidate list
growing, so the mount counter is load-bearing only for a mount point below the
watched directory, where nothing on disk changes and no signature moves.
The one sleep is not padding -- mtimes have one-second resolution, so a rewrite
inside the same second as the original write is indistinguishable from no change.
Build with -DCF_FRAMEWORK_BUILD_TESTS=ON; run `tests test_shader_directory`.
Removes test_shader_watch_reports_vert_and_frag_edits, its file cleanup, and the one sleep in the suite. The remaining four cases are unaffected -- they use .shd, a long-standing watched extension, so none of them depended on it. Nothing now covers .vert and .frag being in the watched set.
Interned names are already stable unique pointers, so identifying a directory's entry set needs no hash at all, and the one here was a poor fit for its input: pointers are aligned and clustered, so XOR preserved the structural zeros, and folding the two accumulators into one word with a single multiply threw away the property that made them worth having. For any two entries an XOR and a sum together pin the pair down exactly. Keep both accumulators and compare all three fields. Order independence is retained, since both are commutative and the walk order is not guaranteed to repeat, and there is no custom construction left to justify.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shader watch: bulk directory scan and cached enumeration
Branch:
shader-watch-bulk-scaninexternal/cute_framework, 6 commits onbeebd946.Problem
cf_app_updatecallscf_shader_watchevery frame, throttled to every 16th.Why it was slow
The scan did three per file metadata queries, and on Windows those are expensive:
CF_Path::is_directory()was a fullPHYSFS_stat.fs_stat()was a second one, re-fetching data the first call already had.PHYSFS_enumerateFilesdoes a third one internally. It stats every entry throughenumCallbackFilterSymLinksjust to drop symlinks from the listing it returns. Confirmed with a counter compiled into PhysFS: 221 stats for a 221 entry enumerate.Measured on this machine, same directory, 221 files:
FindFirstFileW)GetFileAttributesExW)A 56x gap between reading metadata in bulk and reading it per file is the signature of a filesystem filter driver, most likely Defender. Machines without one will see much less dramatic numbers throughout.
What this changes
FindFirstFileWon Windows andfdopendirplusfstataton POSIX.cf_fs_mountandcf_fs_dismount, and one bulk walk per contributing mount reduced to a small signature. Writing to a file changes neither, so editing a shader never re-asks PhysFS..vertand.frag, so two filecf_make_shaderpairs hot reload. The machinery already existed and the extension filter was rejecting them.cf_shader_directoryinstead of tripping an assert.Challenges
FindFirstFileWreports the link where PhysFS reports the target, so reparse points are left out of the walk and stated the old way.PHYSFS_getMountPointturned out to be public, which makes the mapping right rather than a guess, butPHYSFS_setRoothas no getter. So the mapping stays a candidate that gets proven, and a directory whose contributors cannot all be seen is never cached.Cute::String(start, end)along the way. It copieslengthbytes and then claims a length one longer without writing a terminator, so it leaves the string unterminated. Worked around locally, worth fixing separately.Results
Cost of one scan, 21 files, nothing changed:
Possible improvements
WIN32_FIND_DATAW.dwFileAttributesalready says whether an entry is a reparse point, and POSIXd_typecarriesDT_LNK. The symlink filter could skip the stat for the entries that plainly are not links. That would makePHYSFS_enumerateFilesfast for every PhysFS user with no behaviour change.PHYSFS_permitSymbolicLinks(1)removes the hidden stat today, taking enumerate from 81 us to 2.1 us per entry. Not done here: it is process wide, it drops PhysFS's symlink sandboxing, and it changes which files appear in a listing.GetFileInformationByHandleExwithFileFullDirectoryInfofills a large buffer in one call and could trim the walk itself, though not the directory opens that now dominate.Testing
Five cases in
test/test_shader_directory.cpp, driven throughcf_shader_on_changed, which both drops the frame throttle and gives a direct observable:Each case cleans up after itself. Without that the files survive in the build directory and are present at populate time on the next run, which makes the cases pass without exercising the path they are named for.
Verified by mutation rather than by going green:
cf_fs_mountstops bumping the counterAlso checked: the POSIX branch compiles clean under Linux clang, and a debug mode (
CF_SHADER_WATCH_VERIFY) that runs the PhysFS path alongside the native one and asserts they agree reports no divergence, including against a decoy directory, a shadowing mount, and a junction.Not covered:
.vertand.fragbeing in the watched set.