Skip to content

Speed up the chunker hot loop and fix a false-boundary edge case#367

Draft
folbricht wants to merge 1 commit into
masterfrom
chunker-speedup
Draft

Speed up the chunker hot loop and fix a false-boundary edge case#367
folbricht wants to merge 1 commit into
masterfrom
chunker-speedup

Conversation

@folbricht

Copy link
Copy Markdown
Owner

Restructures the buzhash rolling-hash loop in Chunker.Next for a ~35% throughput gain on real data (~70% on boundary-free data), and fixes a subtle boundary-detection bug along the way.

Changes

  • Drop the ring-buffer window. The window bytes always live in the read buffer, so the byte leaving the window at position pos is simply buf[pos-ChunkerWindowSize]. This removes a store, an index increment and a wrap branch per byte, along with the hValue/hWindow/hIdx fields whose state never survived across chunks anyway.

  • Branch-free boundary test. hValue % d == d-1 is now evaluated as "hValue+1 divisible by d" using the multiply-and-rotate divisibility test (Hacker's Delight §10-17): rotr32((hValue+1)*inv(odd), k) - qBias <= qMax. The previous two-part test compiled to a variable shift with Go's shift-overflow guard (4 instructions) and kept 5 constants live, which spilled two of them to the stack inside the hot loop.

  • 2-way unroll. Using h2 = rol2(h) ^ rol1(a0) ^ a1 (rotation distributes over xor) halves the loop-carried rotate/xor dependency chain; the table loads and boundary tests run off the critical path. A 4-way unroll measured within noise, so the simpler form is kept.

Bug fix

The Lemire-style test introduced in #307 computed hValue - (d-1), which wraps around for hValue < d-1. Since 2^32 is not divisible by d, exactly one hash value per discriminator — 14413 for the default 64KB average chunk size — declared a false chunk boundary compared to the plain modulo. It's a one-in-2^32-per-byte event, so data integrity was never affected, but boundaries could deviate from casync/pre-#307 desync on large inputs, reducing cross-version dedup.

The new form was verified exhaustively against hValue % d == d-1 over all 2^32 hash values for a range of discriminators (including powers of two, which need the qBias term). TestChunkerBoundaryTest pins the equivalence for the wrap-around edge cases.

Benchmarks

i7-7600U, interleaved A/B runs against master under identical thermal conditions:

Benchmark master this PR
BenchmarkChunker (real data) ~369 MB/s ~499 MB/s (+35%)
BenchmarkChunkNull50M (no boundaries) ~255 MB/s ~433 MB/s (+70%)
desync make -n 1 on 512MB, end-to-end 2.74s 2.27s

desync make produces byte-identical indexes to master on a 512MB random test file.

Restructure the buzhash rolling-hash loop in Chunker.Next:

- Drop the ring-buffer window. The window bytes always live in the
  read buffer, so the byte leaving the window at position pos is
  simply buf[pos-ChunkerWindowSize]. This removes a store, an index
  increment and a wrap branch per byte, along with the hValue/hWindow/
  hIdx fields whose state never survived across chunks.

- Replace the two-part divisibility test with a branch-free
  multiply-and-rotate form (Hacker's Delight 10-17): hValue % d == d-1
  is evaluated as "hValue+1 divisible by d" via
  rotr32((hValue+1)*inv(odd), k) - qBias <= qMax. This avoids Go's
  variable-shift overflow guard and reduces register pressure that
  previously spilled two constants to the stack in the hot loop.

- Unroll the loop 2-way using h2 = rol2(h) ^ rol1(a0) ^ a1, halving
  the loop-carried rotate/xor dependency chain.

The new boundary test also fixes a subtle bug introduced in #307: the
old test computed hValue - (d-1), which wraps around for hValue < d-1,
and since 2^32 is not divisible by d, exactly one hash value per
discriminator (14413 for the default 64KB average chunk size) declared
a false chunk boundary compared to the plain modulo. The new form was
verified exhaustively against "hValue % d == d-1" over all 2^32 hash
values for a range of discriminators, including powers of two, and
TestChunkerBoundaryTest pins the equivalence for the wrap-around edge
cases.

Benchmarks (i7-7600U, interleaved runs against master):

  BenchmarkChunker        ~369 MB/s -> ~499 MB/s  (+35%)
  BenchmarkChunkNull50M   ~255 MB/s -> ~433 MB/s  (+70%)
  desync make -n 1 512MB  2.74s     -> 2.27s

Index output is byte-identical to master on a 512MB random test file.
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.

1 participant