Skip to content

perf: skip hashing files whose size no other file shares - #20

Merged
fahadsiddiqui merged 1 commit into
mainfrom
perf/size-first-funnel
Aug 25, 2026
Merged

perf: skip hashing files whose size no other file shares#20
fahadsiddiqui merged 1 commit into
mainfrom
perf/size-first-funnel

Conversation

@fahadsiddiqui

Copy link
Copy Markdown
Member

Closes #12. Turns the scan into two passes: stat everything, then hash only files that share a size with another. A unique size is proof a file cannot be a duplicate.

Measured

456-file / 425 MB fixture mirroring a real Desktop (98% unique sizes — typical of screenshots):

before after
files hashed 456 6
bytes read 425 MB 1.8 MB
wall time (warm) 0.22s 0.00s

Why it matters beyond CPU

On macOS with iCloud "Desktop & Documents" + Optimize Mac Storage, files can be dataless placeholders whose bytes live in iCloud — opening one forces a download. A real Desktop measured 272/456 files dataless, 200 MB the old scan would silently pull over the network: a stat-only pass took 0.115s where the full scan hadn't finished after 120 seconds.

os.Stat does not materialise a placeholder, so the size filter avoids nearly all of it. The residual case (two placeholders colliding on size) is #19.

Correctness

Grouping is still decided solely by the full SHA-256 — the size pass is a pure filter. Verified by diffing complete report output from the old and new binaries over a fixture with duplicates, same-size-different-content, unique sizes, empty files and named copies: byte-identical. The existing golden tests also pass unchanged.

Progress rendering

Also replaced the per-file Processing: lines with a single in-place Hashing 6/456 counter — the old form emitted thousands of lines and rendering them dominated the run (0.227s vs 0.152s through a pty for 5000 files). Suppressed entirely when stderr isn't a terminal.

Behaviour change

An unreadable file with a unique size no longer warns, because nothing opens it. Both the old and new behaviours are now pinned by explicit tests rather than left implicit.

56 tests, race-clean.

🤖 Generated with Claude Code

Closes #12.

scan() now runs in two passes instead of one. The first stats every file and
groups by size; the second hashes only files that share a size with at least
one other. Identical content implies identical size, so a unique size is proof
that a file cannot be a duplicate, and reading it is provably wasted work.

Grouping is still decided solely by the full SHA-256, so results are unchanged.
Verified by diffing complete report output between the old and new binaries on
a fixture covering duplicates, same-size-different-content, unique sizes, empty
files and named copies: byte-identical.

Measured on a 456-file / 425 MB fixture mirroring a real Desktop (98% of files
having a size no other file shares, which is typical of screenshots):

    files hashed   456    ->  6
    bytes read     425 MB ->  1.8 MB   (99.6% less I/O)
    wall time      0.22s  ->  0.00s

Why this matters beyond CPU

On macOS with iCloud Drive's "Desktop & Documents" and Optimize Mac Storage,
files can be dataless placeholders whose contents live in iCloud. Opening one
forces a download. A real Desktop measured 272 of 456 files dataless, 200 MB
that the old scan would silently pull over the network - a stat-only pass took
0.115s where the full scan had not finished after 120 seconds. os.Stat does not
materialise a placeholder, so the size filter avoids nearly all of that. The
residual case, a size collision between two placeholders, is tracked as #19.

Changes

- scan.go: two-pass scanWithProgress; new candidate type; Report gains
  FilesHashed and BytesHashed. os.Stat is used for sizing (it follows
  symlinks) so sizes match what the single-pass scan saw.
- Hash candidates are sorted by path before hashing, so progress and warning
  order stay deterministic.
- report.go: files_hashed and bytes_hashed added to the JSON summary.
- main.go: progress is now a single line rewritten in place ("Hashing 6/456")
  rather than one line per file. The old form emitted thousands of lines and
  rendering them dominated the run - measured at 0.227s vs 0.152s through a
  pty for 5000 files. Progress is suppressed entirely when stderr is not a
  terminal, where carriage returns would just be noise in a log.

Behaviour change worth noting

An unreadable file with a unique size no longer produces a warning, because
nothing ever opens it. TestScanRecordsWarningForUnreadableFile was updated to
give the unreadable file a same-sized sibling so it still reaches the hash
pass, and TestScanDoesNotWarnAboutUnreadableFileWithUniqueSize was added to
pin the new behaviour deliberately rather than by omission.

Tests

56 tests (up from 51), race-clean, gofmt and vet clean. New tests assert that
unique sizes are never hashed, that size collisions are hashed but not grouped,
that real duplicates are still found with exact FilesHashed/BytesHashed counts,
and that progress fires only for hashed files with a correct running total.
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.

Performance: every file is fully hashed, even when its size is unique

1 participant