Skip to content

Fix slow shader file watch on Windows - #576

Merged
RandyGaul merged 7 commits into
RandyGaul:masterfrom
jonstvns:shader-watch-bulk-scan
Aug 11, 2026
Merged

Fix slow shader file watch on Windows#576
RandyGaul merged 7 commits into
RandyGaul:masterfrom
jonstvns:shader-watch-bulk-scan

Conversation

@jonstvns

Copy link
Copy Markdown
Contributor

Shader watch: bulk directory scan and cached enumeration

Branch: shader-watch-bulk-scan in external/cute_framework, 6 commits on beebd946.

Problem

  • cf_app_update calls cf_shader_watch every frame, throttled to every 16th.
  • Each scan cost about 12.5 ms for a 21 file shader directory, on the render thread.
  • Cost scaled linearly with file count: 221 files took about 128 ms per scan.
  • Every over budget frame in a 2000 frame capture was exactly 16 frames after the previous one, with the simulation paused.

Why it was slow

The scan did three per file metadata queries, and on Windows those are expensive:

  • CF_Path::is_directory() was a full PHYSFS_stat.
  • fs_stat() was a second one, re-fetching data the first call already had.
  • PHYSFS_enumerateFiles does a third one internally. It stats every entry through enumCallbackFilterSymLinks just 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:

operation cost
bulk directory walk (FindFirstFileW) 1.1 us per entry
per file metadata query (GetFileAttributesExW) 62.6 us per entry

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

  • Replaces the per file stats with one bulk directory walk per mount, FindFirstFileW on Windows and fdopendir plus fstatat on POSIX.
  • Caches the PhysFS directory listing so the remaining hidden stat is only paid when the set of files actually changes.
  • Revalidates that cache every scan against two cheap things: a counter bumped by cf_fs_mount and cf_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.
  • Holds the metadata proof across scans instead of re-running it every scan.
  • Also watches .vert and .frag, so two file cf_make_shader pairs hot reload. The machinery already existed and the extension filter was rejecting them.
  • Adopts files created after cf_shader_directory instead of tripping an assert.

Challenges

  • PhysFS has to stay the authority on what exists. Several mounts can serve one directory and only PhysFS knows the merged result. Taking the file list from a walk would silently stop watching any file shadowed by a lower mount.
  • Timestamps have to match PhysFS, not be correct. PhysFS on Windows round trips through local time rather than subtracting the epoch. A stored timestamp is compared against whatever the next scan produces, so a divergence would make a file look permanently changed or permanently unchanged.
  • Symlink typing has to match too. FindFirstFileW reports the link where PhysFS reports the target, so reparse points are left out of the walk and stated the old way.
  • Mapping a mount to its real directory is not exact. PHYSFS_getMountPoint turned out to be public, which makes the mapping right rather than a guess, but PHYSFS_setRoot has no getter. So the mapping stays a candidate that gets proven, and a directory whose contributors cannot all be seen is never cached.
  • No periodic refresh. A timer would reintroduce exactly the recurring spike this removes, so staleness is detected from mount changes and per mount walks instead.
  • Found a bug in Cute::String(start, end) along the way. It copies length bytes 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:

scan cost marginal cost per file
before ~12.5 ms 578 us
bulk walk instead of per file stats 2.5 ms 74 us
plus cached directory listing 0.58 ms ~3 us
plus cached proof and no per entry allocation 0.26 ms ~2 us
  • About 48x cheaper per scan, and about 290x cheaper per file.
  • The periodic over budget frames are gone entirely.
  • What remains is two directory opens, about 0.1 ms each on this machine. One of them is a mount that has no such directory and has to be re-walked every scan to notice if it gains one.

Possible improvements

  • Fix the hidden stat in PhysFS. WIN32_FIND_DATAW.dwFileAttributes already says whether an entry is a reparse point, and POSIX d_type carries DT_LNK. The symlink filter could skip the stat for the entries that plainly are not links. That would make PHYSFS_enumerateFiles fast 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.
  • Wall clock throttle instead of every 16th frame, so the scan rate stops rising with frame rate.
  • GetFileInformationByHandleEx with FileFullDirectoryInfo fills a large buffer in one call and could trim the walk itself, though not the directory opens that now dominate.
  • A Defender exclusion for the working tree, which is the largest single lever on this machine but helps nobody else.

Testing

Five cases in test/test_shader_directory.cpp, driven through cf_shader_on_changed, which both drops the frame throttle and gives a direct observable:

  • a file created after startup is picked up rather than asserted on, and the cached listing notices it
  • a deletion is not reported as a modification and does not crash
  • a second mount serving the same directory contributes its files
  • a mount point below the shader directory adds a virtual subdirectory that no walk can see, which only the mount counter can catch

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:

mutation result
cache ignores the per mount signatures the two file appearance cases fail
cf_fs_mount stops bumping the counter only the mount point case fails

Also 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: .vert and .frag being in the watched set.

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.
@RandyGaul
RandyGaul merged commit dddc5b5 into RandyGaul:master Aug 11, 2026
14 checks passed
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