Skip to content

perf: skip re-reading files whose size and mtime are unchanged - #53

Merged
evolvedlight merged 1 commit into
evolvedlight:developfrom
swisspost:perf/file-stamp
Oct 11, 2026
Merged

evolvedlight merged 1 commit into
evolvedlight:developfrom
swisspost:perf/file-stamp

Conversation

@msallin

@msallin msallin commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #39 (which fixed #28).

Problem

Since #39, SystemWorld::reset sends every file slot back to disk before each compilation, and the next access does fs::metadata + fs::read + hash128 on every file the document touches, packages and plugins included. A label template importing @preview/tiaoma:0.3.0 re-reads its 879 KB zint_typst_plugin.wasm on every compile, about 0.5 ms on top of a 3 ms compile on Windows.

Change

  • SlotCell keeps a stamp of the file it was read from: path, length and modification time.
  • A reset cell with data stats the path first. If the stamp still matches, the cached value is returned without reading or hashing. Otherwise it falls through to the existing read + fingerprint path and stores a new stamp.
  • The stat is taken once per file slot and compilation and shared by the source and bytes views of the file, so a file used both ways costs one stat. When the file has to be read, that same stat (taken before the read) becomes the new stamp, so a changed file costs one stat plus the read instead of two stats.
  • No stamp is kept when the stat or read fails, the path is a directory, or the platform has no mtime, so a missing file that appears later, a file that was locked and is released, and a repaired file are all picked up as before.
  • In-memory cells and the now reset are untouched. Package files are not special-cased: the stamp makes them cheap, and vendored local packages keep picking up redeploys.
  • The path is part of the stamp because a package file's path is re-resolved on every access and moves when a package is vendored after the first read.

Racy-timestamp trade-off

A stamp is only trusted if the file's mtime is at least 2 s older than the read that took it (git's "racily clean" check), which covers FAT and SMB granularity. An untrusted stamp always falls through to read + fingerprint and is judged again after that read, so a freshly written file settles after its first read past the window. A future mtime is never trusted.

What remains undetected is a rewrite that keeps both length and mtime of a file older than 2 s, e.g. a timestamp-preserving copy over a file of the same size. README and release notes say so, next to the existing advice to replace templates atomically.

Two limits on network shares worth knowing: the guard compares the file's mtime with the local clock, so a server clock running behind narrows the window; and a stat may be answered from the client's attribute cache (NFS acregmax, SMB file info cache), so a redeploy on such a share can take that cache's lifetime to be seen.

Benchmark

Windows 11, release build, warm page cache, files dated an hour back, median of five batches of 200 compiles of one warm compiler, mean of two runs. Per compilation:

case before after no re-check (pre-#39)
1 .typ 319 µs 233 µs 189 µs
6 .typ 783 µs 398 µs 201 µs
21 .typ 2.05 ms 776 µs 232 µs
51 .typ 4.47 ms 1.62 ms 338 µs
1 MB file via read() 895 µs 176 µs 85 µs

Per-file cost drops from about 85 µs to about 25 µs, and the 1 MB file no longer scales with its size. The rest is the stat itself: fs::metadata on Windows opens a handle, which the on-access scanner sees. GetFileAttributesExW would avoid that, but needs FFI or a windows-sys dependency, so I left it out. The "no re-check" column is SlotCell::reset made a no-op, for reference only. Absolute numbers differ from #39 because the template content is different; the method is the same.

Tests

New world.rs unit tests:

  • an unchanged file dated in the past is served from cache without a read (content rewritten with the same length and the old mtime restored via File::set_modified)
  • a changed mtime, and separately a changed length, cause a re-read
  • a file written just now is re-read even with length and mtime forged back to their old values (racy guard)
  • a distrusted stamp becomes trusted after the read that follows the file settling
  • a stamp does not vouch for a file at another path
  • a directory keeps reporting IsDirectory and a file put in its place is picked up
  • a file that can be stat'ed but not read (exclusive handle on Windows, mode 000 on Unix) is read again once released
  • the source and bytes views share one stat within a compilation
  • a reset SystemWorld sees a rewrite of a settled file, i.e. the shared stat does not outlive the compilation
  • is_settled boundaries: future, 0, window minus 1 ms, exactly the window, an hour

The existing SlotCell tests now go through the same helper. Each of these mutations fails at least one test: disabling the stamp check, disabling the racy guard, keeping a stamp after a failed read, re-stating for the second view, and not clearing the shared stat on reset. tests/file_reload.rs and the managed tests pass either way: their rewrites change the length or move the NTFS mtime, so they cover the behaviour but don't depend on the guard.

cargo test --release passes 44 native tests and dotnet test passes 77 managed tests. The only warnings are the three existing CS0618 warnings in Tests.cs, which test the deprecated Buffers on purpose.

Since every compilation resets the file slots, each file a document
touches was read and hashed in full on every compile, packages and
plugins included. A slot now keeps the path, length and modification
time of the file it was read from, and a reset slot whose stamp still
matches a fresh stat is served from cache without a read. The stat is
taken once per file and compilation and shared by the source and bytes
views of the file, and it doubles as the stamp when the file is read.

A stamp is only trusted once the file is at least two seconds older
than the read that took it, so a rewrite within one tick of a coarse
file system clock is still picked up (git's racily-clean check).
@evolvedlight
evolvedlight merged commit b5dd51c into evolvedlight:develop Oct 11, 2026
5 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.

A reused TypstCompiler never picks up changes to files on disk

2 participants