find_dupes: streaming row-chunked compare (fits ~4GB cgroup caps)

The single-matrix fix still oom-killed at n=31,937: Hermes background
jobs run in a systemd scope capped near 4GB, and the n^2 uint64 xor
matrix alone is 8GB. Now row chunks of 1024 stream through both hash
gates (peak a few hundred MB temps), the only resident n^2 structure
is the bool close-matrix (1GB), and the color-histogram L1 is computed
only for rows surviving both hashes (replaces the 4GB float32 matrix).
Verified on a 2,502-file corpus incl planted cross-chunk duplicate
pair and an orphan copy (correctly untouched).
This commit is contained in:
2026-10-29 08:04:39 -04:00
parent b35d3da375
commit d9312ed431
+26 -23
View File
@@ -111,21 +111,18 @@ def main():
print(f" {i+1}/{len(files)}", flush=True) print(f" {i+1}/{len(files)}", flush=True)
names = list(data) names = list(data)
# strict (complete-linkage) clustering — NO transitive chaining:
# seed with the ungrouped image having most neighbours, take its
# within-threshold neighbourhood, then keep only members pairwise
# within threshold. Union-find chaining merges unrelated frames
# after enough hops (verified failure: 90-frame 'group' of six
# different scenes).
n = len(names) n = len(names)
print(f"Pairwise compare {n}x{n} (numpy hamming) ...") print(f"Pairwise compare {n}x{n} (streaming hamming, chunked) ...")
def hamming64(mat_u64): # Memory-safe at 30k+ scale under ~4 GB cgroup caps (verified: the
"""Full pairwise Hamming, memory-safe at 30k+ scale. The old # full n^2 uint64 xor matrix — 8 GB at n=32k — got a background run
64-pass int64 shift-add peaked at ~27 GB on 32k frames (OOM); # oom-killed in a hermes-worker systemd scope on 2026-10-29). Row
bitwise_count (numpy>=2.3) or a per-byte popcount LUT keeps the # chunks of 1024 keep peak temps at a few hundred MB; only the final
peak at one uint64 xor matrix + a uint8 result (max dist 64).""" # bool close-matrix (n^2 bytes) stays resident. uint8 distances
x = mat_u64[:, None] ^ mat_u64[None, :] # suffice (max 64) via np.bitwise_count (numpy>=2.3) or a per-byte
# popcount LUT.
def hamming_chunk(mat, rows):
x = mat[rows, None] ^ mat[None, :]
if hasattr(np, "bitwise_count"): if hasattr(np, "bitwise_count"):
ham = np.bitwise_count(x) ham = np.bitwise_count(x)
else: else:
@@ -139,22 +136,28 @@ def main():
return ham return ham
dmat = np.array([data[f][0] for f in names], dtype=np.uint64) dmat = np.array([data[f][0] for f in names], dtype=np.uint64)
close = hamming64(dmat) <= args.max_dist amat = np.array([data[f][1] for f in names], dtype=np.uint64)
max_a = args.max_adist if args.max_adist is not None else 8
CH = 1024
close = np.zeros((n, n), dtype=bool)
for s in range(0, n, CH):
rows = slice(s, min(s + CH, n))
close[rows] = ((hamming_chunk(dmat, rows) <= args.max_dist)
& (hamming_chunk(amat, rows) <= max_a))
if (s // CH) % 8 == 0:
print(f" hashes {min(s + CH, n)}/{n}", flush=True)
del dmat, amat
# combined close matrix: dHash AND aHash AND color-histogram gates. # combined close matrix: dHash AND aHash AND color-histogram gates.
# dHash alone has a false-positive floor (verified: distinct frames # dHash alone has a false-positive floor (verified: distinct frames
# at distance <=10); a second, differently-computed hash plus a # at distance <=10); a second, differently-computed hash plus a
# color signature is what separates 'same frame replayed' from # color signature is what separates 'same frame replayed' from
# 'same show, different shot'. # 'same show, different shot'. Color L1 is computed per row only for
amat = np.array([data[f][1] for f in names], dtype=np.uint64) # rows that survived both hashes — few rows at real corpus scale,
close &= hamming64(amat) <= (args.max_adist # and it replaces the old n^2 float32 matrix (4 GB at n=32k).
if args.max_adist is not None else 8)
hmat = np.stack([data[f][3] for f in names]) # N x 192 hist rows hmat = np.stack([data[f][3] for f in names]) # N x 192 hist rows
cdist = np.zeros((n, n), dtype=np.float32) for i in np.flatnonzero(close.any(axis=1)):
for i in range(n): # row-wise keeps it ~seconds and RAM-light close[i] &= np.abs(hmat - hmat[i]).sum(axis=1) <= args.max_cdist
cdist[i] = np.abs(hmat - hmat[i]).sum(axis=1)
close &= cdist <= args.max_cdist
np.fill_diagonal(close, False) np.fill_diagonal(close, False)
# strict (complete-linkage) clustering — NO transitive chaining: # strict (complete-linkage) clustering — NO transitive chaining: