Repository navigation
perf: let walk workers queue more batches ahead of the iterator - #413
Conversation
The result channel held `threads * 2` batches. When the iterator's consumer pauses, each worker that finishes a batch then blocks until the consumer drains the channel, so the walk stops whenever the consumer is busy. A consumer that does real work per entry, such as parsing some of the files it's handed, spends much of its time waiting on workers that sat idle while it worked. Allow 128 batches per worker instead. The channel stays bounded, and `dua aggregate` is no slower. The unit tests that block a worker on a full channel fill two batches to do it, so they keep that bound.
|
Thanks! I see why I'd never notice this, as in my tests nobody ever blocks. |
<!-- agent --> Replace the fixed QUEUED_BATCHES_PER_WORKER constant with Options::queued_batches_per_worker and a chainable builder method. Use two queued batches per worker by default in both production and tests, preserving the original bounded buffering behavior. Consumers that pause to process entries can opt into a larger queue to keep traversal workers busy, trading memory for less idle time. A value of zero selects an unbuffered channel. Check the worker-count multiplication so excessive capacities cannot silently overflow. Update existing Options literals to inherit the new default. Verify the actual queue capacity with one and two workers, and exercise larger and unbuffered queues through the public single-root and multi-root walkers. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol <[email protected]>
<!-- agent --> Replace empty-container assertions with equality comparisons and empty string checks with inequality comparisons. This satisfies Clippy's assert_is_empty lint while showing actual values when assertions fail. Locally allow the deprecated fetch_update alias in directory identifier allocation. Its replacement, try_update, requires Rust 1.95, while dua-core supports Rust 1.88. Preserve the existing atomic update and overflow handling without raising the minimum supported Rust version. Validation: cargo clippy --workspace --all-targets -- -D warnings -A unknown-lints --no-deps, workspace tests, and formatting all pass. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol <[email protected]>
Byron/dua-cli#413 merged as Options::queued_batches_per_worker, which keeps the default of two batches, so pks will set it once it is released.
* Walk the app with dua-core in place of jwalk jwalk 0.9's changelog says it is no longer maintained and points to dua-core, the walker its author now develops in dua-cli. dua-core asks before it reads each directory, so pks still skips hidden, gitignored and excluded directories without reading them. Each entry names the directory it's in, so once the walk is done, each file is matched to the package.yml nearest above it, from the package.yml files the walk found, with no stat of each directory for one. pks skips hidden entries, as jwalk did and packwerk's globs do. dua-core never follows symlinks. A symlink to a file still counts as the file, but pks no longer descends into a symlinked directory, as packwerk's globs don't either. jwalk followed links, and a link to a directory that contains it, such as `loop -> ..`, made pks list the same files and packs dozens of times. dua-core's workers stop once a few batches of entries wait for pks's loop, which happens each time the loop parses a package.yml. Until a release has Byron/dua-cli#413, a thread takes the entries as they come, so the walk takes as long as it did. On every fixture and four Rails apps, list-included-files, list-packs and list-references print the same as before. * Trim the comments on the walk Two restated the code under them, and three said more than they needed to. * Say which dua-core option will replace the draining thread Byron/dua-cli#413 merged as Options::queued_batches_per_worker, which keeps the default of two batches, so pks will set it once it is released. * Don't panic on a file for the project root, and follow RAYON_NUM_THREADS - `--project-root` naming a file panicked on an `expect`, as dua-core yields a file root with no directory. It again ends in "No root pack found", as on `main`. - The walk takes rayon's number of threads, so `RAYON_NUM_THREADS` limits it, as it limited jwalk, which ran on rayon's pool. - `read_directory` skips a directory it can't make relative instead of unwrapping. dua-core's workers don't catch a panic, so one there would hang pks rather than abort it. - Directories are recorded before hidden names are skipped, so a file's directory is always known. That held only because `read_directory` skips hidden directories too. - Which package.yml each file belongs to is collected into a map of the right size. Growing it took about 80 ms on 118,000 files, against 27. - That resolution is now `package_ymls_of_directories`, with a unit test that gives it directories before their parents. Removing the sort by depth failed no test, since whether the walk yields a directory first varies from run to run. - The symlinked-directory test also links a directory with a name like a Ruby file's, which `exists()` in place of `is_file()` would list. - Comments that described jwalk's design are updated, and the thread that drains the walk is `draining`.
|
Thanks for merging this, and for making it an option. I tried Would you cut a dua-core release with it when it suits you? pks will drop the thread then. |
|
Great! |
) dua-core 4.2.0 has `Options::queued_batches_per_worker`, from Byron/dua-cli#413. With room for 128 batches per worker, the walk's loop in a large Rails app waited a median 64 ms on dua-core, against 113 ms with the thread and 301 ms with dua-core's default of 2.
walk's result channel holdsthreads * 2batches. When the iterator's consumer pauses, each worker that finishes a batch blocks until the consumer drains the channel, so the walk stops while the consumer is busy. This allows 128 batches per worker instead (QUEUED_BATCHES_PER_WORKER). The channel stays bounded.I ran into this moving pks, a Rust port of Shopify's packwerk, from jwalk to dua-core, as jwalk's changelog suggests. pks walks a Rails app to find its Ruby files and the
package.ymlfiles that own them, and parses eachpackage.ymlas it reaches it. Each of those pauses fills the channel and leaves the workers blocked.Measurements
On an Apple M4 Pro (12 cores, macOS 27), with a warm cache. The machine was busy throughout (load average around 14), so the totals are noisy. The time the consumer spends waiting on the iterator is the steadier signal.
pks on a large Rails app, medians of 7 runs, with the bound read from an environment variable for the experiment:
With jwalk 0.9, the same loop took 454 ms and waited 25 ms.
A standalone repro (below) walks a tree of 23,431 directories and 90,000 empty files in completion order with
skip_metadata, spending 3 µs on each entry and 150 µs more on every 150th. Four runs each:dua itself is no slower. With hyperfine, 15 runs each,
dua aggregatetook 233 ± 40 ms before and 189 ± 33 ms after on a checkout of ruby/ruby, and 805 ± 105 ms and 801 ± 88 ms on the synthetic tree.Repro
The tree:
Cargo.toml:src/main.rs:Run with
SPIN_NS=3000 PAUSE_EVERY=150 PAUSE_NS=150000 target/release/walkbench dua <tree>, and with a[patch.crates-io]entry pointingdua-coreat this branch for the comparison.Tests
Three unit tests block a worker on a full channel by filling it with two batches:
streaming_input_closes_and_cancels_without_draining_output,dropping_a_walk_disconnects_workers_blocked_on_full_outputandcancelling_a_walk_disconnects_workers_blocked_on_full_output. With 128 batches per worker they would still pass but no longer block anything, so undercfg(test)the bound stays at two.cargo test --workspace(309 tests) andcargo clippy --workspace --all-targets -- -D warningspass.