Skip to content

Commit c31eb41

Browse files
perf: bound watcher hashing memory with reusable scratch (#530)
1 parent 3bd3004 commit c31eb41

9 files changed

Lines changed: 506 additions & 40 deletions

File tree

BENCHMARKS.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ WebUI itself.
1717

1818
| Bench | Layer | Wall time | What it measures | Use when |
1919
|---|---|---|---|---|
20-
| `cargo xtask bench all` | criterion micro | ~5 min | per-fn wall-clock for parser, handler, protocol, expressions, state, webui (incl. streaming + contact-book) | full snapshot of every micro-bench |
20+
| `cargo xtask bench all` | criterion micro | ~5 min | per-fn wall-clock for parser, handler, protocol, expressions, state, watcher hashing, webui (incl. streaming + contact-book) | full snapshot of every micro-bench |
2121
| `cargo xtask bench streaming` | criterion micro | ~60 s | writer-path wall-clock + first-chunk TTFB | inner-loop iteration on the streaming module |
2222
| `cargo xtask bench contact-book` | criterion micro | ~90 s | end-to-end render at 10/100/1000 contacts | inner-loop iteration on handler/state/expressions |
23+
| `cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench` | criterion micro | ~20 s | small/large file hashing and event bursts with reused scratch | watcher hashing CPU and I/O tradeoffs |
2324
| `cargo xtask bench node-addon` | Node/N-API | ~15 s after build | `Protocol` construction, buffered render, first callback, total stream time | changes to `webui-node` or the public Node wrapper |
2425
| `cargo xtask bench streaming-resource` | example | ~30 s | exact alloc count + bytes + getrusage CPU + RSS | proving zero-alloc claims; allocation regression hunting |
2526
| `cargo xtask bench streaming-e2e-ttfb` | example | ~10 s | HTTP-level TTFB / TTLB through actix | confirming wire-level streaming win |
@@ -126,6 +127,8 @@ Standard criterion harnesses. Each crate has its own `benches/` dir:
126127
* `crates/webui-state/benches/state_bench.rs`
127128
* `crates/webui/benches/contact_book_bench.rs`: end-to-end render
128129
* `crates/webui/benches/streaming_bench.rs`: writer-path wall-clock + TTFB
130+
* `crates/webui-dev-server/benches/watch_hash_bench.rs`: file hashing and
131+
32-file bursts with one reusable scratch buffer
129132
* `crates/webui/benches/component_assets_bench.rs`: static asset graph rendering
130133
* `crates/webui/benches/server_request_bench.rs`: router-aware full HTML and
131134
JSON requests, including sparse projection from a large parsed state tree
@@ -136,6 +139,19 @@ These integrate with criterion's HTML reports
136139
passes those flags through so you don't need to remember `cargo
137140
bench` invocation details.
138141

142+
The watcher hashing benchmark includes opening and metadata, but creates its
143+
fixtures outside timing. Run the same harness against both implementations:
144+
145+
```bash
146+
cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench -- --save-baseline before
147+
cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench -- --baseline before
148+
```
149+
150+
Report small-file bursts as well as large files: bounded reads can trade extra
151+
I/O calls for lower content-buffer allocation. Distinguish Criterion's printed
152+
time estimates from extracted median estimates, and source-derived buffer
153+
bounds from measured process RSS.
154+
139155
### `streaming-resource` (counting allocator + getrusage)
140156

141157
`crates/webui/examples/streaming_resource_bench.rs` installs a custom

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DESIGN.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2307,8 +2307,16 @@ changed file and drops events whose bytes are unchanged, so a no-op save
23072307
(repeated Ctrl+S that rewrites identical content) triggers no rebuild in the
23082308
clean state. While a rebuild error is active, unchanged events are forwarded so a
23092309
no-op save can retry transient failures without forcing a real content edit.
2310-
Deletions and oversized files always count as changed. Each rebuild's terminal
2311-
line names the triggering file (`↻ rebuilt app-shell.css …`, or `… (+N more)`).
2310+
Deletions and oversized files always count as changed. Hashing reuses one
2311+
8 KiB scratch buffer per watcher instead of allocating a whole-file content
2312+
buffer. Regular-file metadata is checked before and after opening, and reads
2313+
are bounded to 8 MiB plus one overflow-probe byte so growth after the metadata
2314+
check cannot bypass the cap. Short reads preserve the whole-file digest;
2315+
interrupted reads retry. Other read failures count as changes and never cache
2316+
a partial digest.
2317+
2318+
Each rebuild's terminal line names the triggering file
2319+
(`↻ rebuilt app-shell.css …`, or `… (+N more)`).
23122320
Incremental rebuild failures are retained in dev-server state. The rebuild
23132321
worker reports the error to the terminal and live-reload SSE; subsequent browser
23142322
refreshes, route renders, JSON partial requests, and component template requests

crates/webui-dev-server/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,9 @@ console = { workspace = true }
2727
time = { workspace = true }
2828

2929
[dev-dependencies]
30+
criterion = { workspace = true }
3031
tempfile = { workspace = true }
32+
33+
[[bench]]
34+
name = "watch_hash_bench"
35+
harness = false
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
//! File hashing latency, including opening/metadata, with scratch reused across events.
5+
//! Run: cargo bench -p microsoft-webui-dev-server --bench watch_hash_bench
6+
7+
use std::hint::black_box;
8+
use std::path::PathBuf;
9+
use std::time::Duration;
10+
11+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
12+
13+
#[path = "../src/watch_hash.rs"]
14+
mod watch_hash;
15+
16+
use watch_hash::{hash_file, HASH_BUFFER_SIZE};
17+
18+
fn watch_hash_bench(c: &mut Criterion) {
19+
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target");
20+
std::fs::create_dir_all(&target)
21+
.unwrap_or_else(|error| panic!("Cannot create benchmark fixture directory: {error}"));
22+
let fixtures = tempfile::Builder::new()
23+
.prefix("watch-hash-bench-")
24+
.tempdir_in(target)
25+
.unwrap_or_else(|error| panic!("Cannot create benchmark fixtures: {error}"));
26+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
27+
let mut group = c.benchmark_group("watch_hash");
28+
group
29+
.sample_size(50)
30+
.warm_up_time(Duration::from_secs(1))
31+
.measurement_time(Duration::from_secs(3));
32+
33+
for size in [1024, 1024 * 1024, 8 * 1024 * 1024] {
34+
let path = fixtures.path().join(format!("file-{size}"));
35+
std::fs::write(&path, vec![b'x'; size])
36+
.unwrap_or_else(|error| panic!("Cannot write benchmark fixture: {error}"));
37+
assert!(hash_file(&path, &mut buffer).is_some());
38+
group.throughput(Throughput::Bytes(size as u64));
39+
group.bench_with_input(BenchmarkId::new("file_bytes", size), &path, |b, path| {
40+
b.iter(|| black_box(hash_file(black_box(path), &mut buffer)));
41+
});
42+
}
43+
44+
for size in [1024, 256 * 1024] {
45+
let paths: Vec<PathBuf> = (0..32)
46+
.map(|index| {
47+
let path = fixtures.path().join(format!("burst-{size}-{index}"));
48+
std::fs::write(&path, vec![b'y'; size])
49+
.unwrap_or_else(|error| panic!("Cannot write burst fixture: {error}"));
50+
path
51+
})
52+
.collect();
53+
group.throughput(Throughput::Bytes(32 * size as u64));
54+
group.bench_with_input(
55+
BenchmarkId::new("burst_32_files", size),
56+
&paths,
57+
|b, paths| {
58+
b.iter(|| {
59+
for path in paths {
60+
black_box(hash_file(black_box(path), &mut buffer));
61+
}
62+
});
63+
},
64+
);
65+
}
66+
group.finish();
67+
}
68+
69+
criterion_group!(benches, watch_hash_bench);
70+
criterion_main!(benches);

crates/webui-dev-server/src/watch.rs

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
//! background thread; **the handle must be kept alive** for the watcher
99
//! to run.
1010
11-
use std::collections::hash_map::DefaultHasher;
1211
use std::collections::{HashMap, HashSet};
13-
use std::hash::Hasher;
1412
use std::path::{Path, PathBuf};
1513
use std::sync::Arc;
1614
use std::time::Duration;
@@ -24,6 +22,15 @@ use notify_debouncer_mini::{
2422
new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, Debouncer,
2523
};
2624

25+
#[path = "watch_hash.rs"]
26+
mod hash;
27+
28+
#[cfg(test)]
29+
#[path = "watch_hash_tests.rs"]
30+
mod hash_tests;
31+
32+
use hash::{hash_file, HASH_BUFFER_SIZE};
33+
2734
/// Owns the watcher background thread. Drop to stop watching.
2835
///
2936
/// `notify-debouncer-mini` spawns its background thread inside the
@@ -146,6 +153,7 @@ where
146153
.collect();
147154

148155
let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
156+
let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
149157
let retry_unchanged_when = cfg.retry_unchanged_when.clone();
150158
let explicit_filter = explicit_files.clone();
151159
let notify_config = notify::Config::default().with_follow_symlinks(false);
@@ -183,8 +191,14 @@ where
183191
let retry_unchanged = retry_unchanged_when
184192
.as_ref()
185193
.is_some_and(|predicate| predicate());
186-
paths
187-
.retain(|path| should_forward_path(&mut content_hashes, path, retry_unchanged));
194+
paths.retain(|path| {
195+
should_forward_path(
196+
&mut content_hashes,
197+
path,
198+
retry_unchanged,
199+
&mut hash_buffer,
200+
)
201+
});
188202
if !paths.is_empty() {
189203
on_event(paths);
190204
}
@@ -311,19 +325,17 @@ pub fn default_ignore_paths() -> Vec<PathBuf> {
311325
]
312326
}
313327

314-
/// Largest file the watcher will hash to detect a no-op change. Above this,
315-
/// an event is always treated as a change — hashing a huge file on every event
316-
/// would cost more than an occasional rebuild. Dev source files are tiny, so
317-
/// this only guards pathological inputs.
318-
const MAX_HASH_BYTES: u64 = 8 * 1024 * 1024;
319-
320328
/// Whether `path`'s content changed since the previous event, updating `cache`.
321329
///
322330
/// A path that cannot be read as a regular file within the size cap (deleted,
323331
/// a directory, a permissions error, or oversized) is treated as **changed** so
324332
/// deletions still trigger a rebuild and large files are never silently skipped.
325-
fn content_changed(cache: &mut HashMap<PathBuf, u64>, path: &Path) -> bool {
326-
match hash_file(path) {
333+
fn content_changed(
334+
cache: &mut HashMap<PathBuf, u64>,
335+
path: &Path,
336+
buffer: &mut [u8; HASH_BUFFER_SIZE],
337+
) -> bool {
338+
match hash_file(path, buffer) {
327339
Some(hash) => match cache.insert(path.to_path_buf(), hash) {
328340
Some(previous) => previous != hash,
329341
None => true,
@@ -339,22 +351,9 @@ fn should_forward_path(
339351
cache: &mut HashMap<PathBuf, u64>,
340352
path: &Path,
341353
retry_unchanged: bool,
354+
buffer: &mut [u8; HASH_BUFFER_SIZE],
342355
) -> bool {
343-
content_changed(cache, path) || retry_unchanged
344-
}
345-
346-
/// Hash the full contents of `path`, or `None` if it is not a readable regular
347-
/// file within [`MAX_HASH_BYTES`]. Uses the standard hasher — collision
348-
/// resistance is irrelevant here; we only need "did these bytes change".
349-
fn hash_file(path: &Path) -> Option<u64> {
350-
let metadata = std::fs::metadata(path).ok()?;
351-
if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES {
352-
return None;
353-
}
354-
let bytes = std::fs::read(path).ok()?;
355-
let mut hasher = DefaultHasher::new();
356-
hasher.write(&bytes);
357-
Some(hasher.finish())
356+
content_changed(cache, path, buffer) || retry_unchanged
358357
}
359358

360359
#[cfg(test)]
@@ -489,25 +488,26 @@ mod tests {
489488
let file = dir.path().join("a.css");
490489
std::fs::write(&file, "a { color: red; }").unwrap();
491490
let mut cache = HashMap::new();
491+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
492492

493493
// First sighting → changed (nothing cached yet).
494-
assert!(content_changed(&mut cache, &file));
494+
assert!(content_changed(&mut cache, &file, &mut buffer));
495495
// Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
496-
assert!(!content_changed(&mut cache, &file));
497-
assert!(!content_changed(&mut cache, &file));
496+
assert!(!content_changed(&mut cache, &file, &mut buffer));
497+
assert!(!content_changed(&mut cache, &file, &mut buffer));
498498

499499
// A real edit → changed.
500500
std::fs::write(&file, "a { color: blue; }").unwrap();
501-
assert!(content_changed(&mut cache, &file));
501+
assert!(content_changed(&mut cache, &file, &mut buffer));
502502
// Identical again → unchanged.
503-
assert!(!content_changed(&mut cache, &file));
503+
assert!(!content_changed(&mut cache, &file, &mut buffer));
504504

505505
// Deletion → changed, so a rebuild can clear stale output.
506506
std::fs::remove_file(&file).unwrap();
507-
assert!(content_changed(&mut cache, &file));
507+
assert!(content_changed(&mut cache, &file, &mut buffer));
508508
// The cache forgot it, so a later recreation is a fresh change.
509509
std::fs::write(&file, "a { color: blue; }").unwrap();
510-
assert!(content_changed(&mut cache, &file));
510+
assert!(content_changed(&mut cache, &file, &mut buffer));
511511
}
512512

513513
#[test]
@@ -516,9 +516,88 @@ mod tests {
516516
let file = dir.path().join("a.css");
517517
std::fs::write(&file, "a { color: red; }").unwrap();
518518
let mut cache = HashMap::new();
519+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
520+
521+
assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
522+
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
523+
assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
524+
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
525+
526+
std::fs::write(&file, "a { color: blue; }").unwrap();
527+
assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
528+
assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
529+
}
530+
531+
#[test]
532+
fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
533+
let dir = tempfile::tempdir()?;
534+
let file = dir.path().join("a.css");
535+
let mut cache = HashMap::new();
536+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
537+
538+
for replacement in ["deleted", "directory", "oversized"] {
539+
std::fs::write(&file, "original")?;
540+
assert!(content_changed(&mut cache, &file, &mut buffer));
541+
assert!(!content_changed(&mut cache, &file, &mut buffer));
542+
543+
std::fs::remove_file(&file)?;
544+
match replacement {
545+
"directory" => std::fs::create_dir(&file)?,
546+
"oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
547+
_ => {}
548+
}
549+
for _ in 0..2 {
550+
assert!(content_changed(&mut cache, &file, &mut buffer));
551+
assert!(!cache.contains_key(&file));
552+
}
553+
match replacement {
554+
"directory" => std::fs::remove_dir(&file)?,
555+
"oversized" => std::fs::remove_file(&file)?,
556+
_ => {}
557+
}
558+
}
559+
std::fs::write(&file, "original")?;
560+
assert!(content_changed(&mut cache, &file, &mut buffer));
561+
Ok(())
562+
}
519563

520-
assert!(should_forward_path(&mut cache, &file, false));
521-
assert!(!should_forward_path(&mut cache, &file, false));
522-
assert!(should_forward_path(&mut cache, &file, true));
564+
#[test]
565+
fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
566+
let dir = tempfile::tempdir()?;
567+
let paths = [
568+
dir.path().join("large.css"),
569+
dir.path().join("empty.css"),
570+
dir.path().join("small.css"),
571+
];
572+
for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
573+
std::fs::write(path, vec![b'x'; size])?;
574+
}
575+
let mut cache = HashMap::new();
576+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
577+
for changed in [true, false, false] {
578+
for path in &paths {
579+
assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
580+
}
581+
}
582+
Ok(())
583+
}
584+
585+
#[cfg(unix)]
586+
#[test]
587+
fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
588+
let dir = tempfile::tempdir()?;
589+
let file = dir.path().join("unreadable.css");
590+
std::fs::write(&file, "original")?;
591+
let mut cache = HashMap::new();
592+
let mut buffer = [0_u8; HASH_BUFFER_SIZE];
593+
assert!(content_changed(&mut cache, &file, &mut buffer));
594+
std::fs::remove_file(&file)?;
595+
std::os::unix::fs::symlink("unreadable.css", &file)?;
596+
assert!(content_changed(&mut cache, &file, &mut buffer));
597+
assert!(!cache.contains_key(&file));
598+
std::fs::remove_file(&file)?;
599+
std::fs::write(&file, "original")?;
600+
assert!(content_changed(&mut cache, &file, &mut buffer));
601+
Ok(())
523602
}
524603
}

0 commit comments

Comments
 (0)