Hash largest files first (LPT scheduling) - #91
Conversation
063239a to
031241a
Compare
|
Switched the dispatch from the exact max-heap to your size-bucketing idea and A/B'd them head to head — you were right, it's the better call. Design: bucket 0 = <1 MiB, then ×4 per bucket ( Adversarial tree (8 GiB file + 80×100 MiB,
This is the deciding data: with everything in bucket 0 the reorder is literally free, so buckets match plain FIFO's CPU while the heap pays ~0.45s for comparisons that buy nothing here. Same scheduling quality as the heap, lower overhead on the common case, and simpler O(1) code with no comparator/sift/realloc. Amended onto the branch. |
031241a to
20a6efc
Compare
|
Refined the buckets from ×4 to one bucket per power of two (2× granularity), so files within a size class differ by <2× instead of <4× — finer ordering among large files at no cost. This needs up to ~45 buckets, so the occupancy bitmask is now a |
4d90a1d to
4ac3ad1
Compare
A scan whose files vary a lot in size can leave a big file as the last thing in the queue, so one csum thread hashes it single-threaded while every other thread has already gone idle. Dispatch longest-processing- time-first instead: hashing still starts immediately as files stream in from the walk, but a free csum thread always takes work from the largest non-empty size class first, keeping all threads busy to the end. Files are bucketed by size on a log scale: bucket 0 is everything <1 MiB, then one bucket per power of two (1, 2, 4, 8 MiB, ...). Each bucket is an intrusive FIFO (walk order kept within a class) and a u64 occupancy bitmask names the non-empty buckets, so push and pop are both O(1) -- pop finds the top bucket via clz on the mask, never a scan over buckets. A huge file sits alone in a high bucket and is dispatched first; the only slack vs exact ordering is the <2x spread within one bucket, which does not affect the idle tail. A tree of only small files all lands in bucket 0 and this degrades to plain FIFO at zero cost. (An exact O(log n) max-heap was measured makespan-equivalent but costs ~0.45s more user CPU on the 174k-file ~/git tree, where the buckets are free; GThreadPool's own sort function is O(queue depth) per push and was far worse -- 13s -> 35s -- because the walk front-loads the queue.) The scan phase now runs its own worker threads instead of a GThreadPool, so each worker owns its read buffer and frees it on exit -- no separate pool/cleanup registry for the scan path. Measured, --io-threads=2, one 8 GiB file + 80x100 MiB, warm: steady 2.16s (the max(largest_file, total/2) optimum) vs FIFO up to 4.46s (~2x) when the big file is dispatched late. No wall regression on ~/git (~174k files). verify.sh green incl. valgrind smoke. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4ac3ad1 to
4e8d8d4
Compare
Closes #88 — the cheap alternative to the chunked-hashing approach in #90.
Problem
When a scan's files vary a lot in size, one very large file can end up as the last thing in the hash queue. A single csum thread then hashes it alone while every other thread has already gone idle — the makespan is dominated by that one straggler.
Approach — longest-processing-time-first, via size buckets
Hashing still starts immediately as files stream in from the walk. The only change is the order work is handed out: a free csum thread always takes work from the largest non-empty size class first.
Files are bucketed by size on a log scale — bucket 0 is everything <1 MiB, then one bucket per power of two (1, 2, 4, 8 MiB, …) — into per-bucket intrusive FIFOs, with a
u64occupancy bitmask. Push and pop are both O(1): pop finds the top non-empty bucket with a singleclzon the mask (never a scan over buckets). A huge file sits alone in a high bucket and is dispatched first; the only slack vs exact ordering is the <2× spread within a bucket, which doesn't affect the idle tail. Walk order is preserved within a bucket, and a tree of only small files all lands in bucket 0, degrading to plain FIFO at zero cost.Why buckets and not a sort function or a heap
g_thread_pool_set_sort_functionwas the first cut and it's a trap: GLib's sorted push is O(queue depth), and the walk front-loads tens of thousands of files, so it turned a 13s~/gitscan into 35s.~/gittree (all files <1 MiB, ordering buys nothing). Buckets get that back and match plain FIFO's CPU.Results
Adversarial tree — one 8 GiB file + 80×100 MiB,
--io-threads=2, warm:max(largest_file, total/2)optimumUniform
~/git(~174k files, all <1 MiB), mean user CPU / wall:Buckets match FIFO's CPU (the reorder is free when everything is bucket 0); wall is neutral across all three.
What this does not do
It can't parallelize a single file larger than the sum of everything else — only intra-file chunking (#90) can, at the cost of a digest redefinition + hashfile rebuild. This covers the common multi-file case for free: no schema bump, no new digest, no per-file concurrency path.
Verification
scan_bucketboundaries, and the work queue itself (push/pophand back largest-bucket-first, FIFO within a bucket).scripts/verify.shgreen: build (warnings=errors), 90 integration + 9 unit tests, valgrind scan/dedupe/replay smoke clean on the new threading.🤖 Generated with Claude Code