From 5fed0888aa17029c858e9d4ecd268e48ecc1b79f Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 5 Sep 2026 12:21:26 +0200 Subject: [PATCH] perf: #259 delta_compute hash index over signature blocks --- src/shared/delta.c | 184 ++++++++++++++++++++++++---- tests/test_delta.c | 293 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 451 insertions(+), 26 deletions(-) diff --git a/src/shared/delta.c b/src/shared/delta.c index 776ef90..be6cdd3 100644 --- a/src/shared/delta.c +++ b/src/shared/delta.c @@ -216,6 +216,119 @@ static void free_instructions(DeltaInstruction* instrs, uint32_t count) { free(instrs); } +/* Sentinel meaning "no signature block" in the lookup index chains. Block + * counts are bounded well below UINT32_MAX, so it doubles as a null link. */ +#define DELTA_NO_BLOCK UINT32_MAX + +/* Avalanche mix for the rolling checksum so blocks do not cluster in the + * bucket table when the weak checksum has little entropy (e.g. all-zero or + * patterned files). */ +static uint32_t delta_adler_mix(uint32_t h) { + h ^= h >> 16; + h *= 0x7feb352dU; + h ^= h >> 15; + h *= 0x846ca68bU; + h ^= h >> 16; + return h; +} + +/* Smallest power of two >= v. v must be non-zero. */ +static uint32_t delta_next_pow2(uint32_t v) { + v--; + v |= v >> 1; + v |= v >> 2; + v |= v >> 4; + v |= v >> 8; + v |= v >> 16; + return v + 1; +} + +/* Build a hash index over sig->blocks keyed by the (mixed) rolling checksum. + * All blocks sharing an Adler-32 value land in the same bucket; collisions + * are chained through a single contiguous allocation: + * + * [0, bucket_count) heads (first block per bucket) + * [bucket_count, 2*bucket_count) tails (last block per bucket) + * [2*bucket_count, ...) per-block chain links + * + * Blocks are inserted in ascending index order so every bucket chain is + * ordered exactly like the historical linear scan. Returns the base pointer + * (also the heads array) or NULL when no index could be allocated; callers + * then fall back to the linear scan. */ +static uint32_t* delta_build_index(const DeltaSignature* sig, uint32_t bucket_count) { + if (sig->block_count == 0 || bucket_count == 0) + return NULL; + + size_t entries = (size_t)2 * bucket_count + sig->block_count; + if (entries > SIZE_MAX / sizeof(uint32_t)) + return NULL; + + uint32_t* index = protocol_alloc(entries * sizeof(uint32_t)); + if (!index) + return NULL; + + uint32_t* heads = index; + uint32_t* tails = index + bucket_count; + uint32_t* next = index + 2 * bucket_count; + uint32_t mask = bucket_count - 1; + + memset(heads, 0xFF, (size_t)bucket_count * sizeof(uint32_t)); + memset(tails, 0xFF, (size_t)bucket_count * sizeof(uint32_t)); + + for (uint32_t j = 0; j < sig->block_count; j++) { + uint32_t b = delta_adler_mix(sig->blocks[j].adler32) & mask; + if (heads[b] == DELTA_NO_BLOCK) + heads[b] = j; + else + next[tails[b]] = j; + tails[b] = j; + next[j] = DELTA_NO_BLOCK; + } + return index; +} + +/* Locate the signature block matching the byte window at new_data[i]. + * + * Mirrors the original per-window behaviour exactly: only a full block_size + * window can match, candidates are accepted only when the weak (Adler-32) and + * strong (xxHash32) checksums both agree, and the lowest block index wins so + * the emitted op stream is byte-identical to the linear scan. When heads is + * non-NULL the candidate set is reached through the bucket index (expected + * O(1) per window); otherwise an exact linear scan is used. */ +static uint32_t delta_find_match(const uint8_t* window, uint32_t window_len, uint32_t adler, + bool full_window, const DeltaSignature* sig, const uint32_t* heads, + const uint32_t* next, uint32_t mask) { + if (!full_window || sig->block_count == 0) + return DELTA_NO_BLOCK; + + if (heads) { + uint32_t b = delta_adler_mix(adler) & mask; + uint32_t window_xxh = 0; + bool have_xxh = false; + for (uint32_t j = heads[b]; j != DELTA_NO_BLOCK; j = next[j]) { + if (sig->blocks[j].adler32 != adler) + continue; + if (!have_xxh) { + window_xxh = delta_xxhash32(window, window_len); + have_xxh = true; + } + if (window_xxh == sig->blocks[j].xxhash) + return j; + } + return DELTA_NO_BLOCK; + } + + /* Fallback used when the index could not be allocated. */ + for (uint32_t j = 0; j < sig->block_count; j++) { + if (sig->blocks[j].adler32 == adler) { + uint32_t window_xxh = delta_xxhash32(window, window_len); + if (window_xxh == sig->blocks[j].xxhash) + return j; + } + } + return DELTA_NO_BLOCK; +} + Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const DeltaSignature* sig, uint32_t block_size) { if (!new_file_data || !sig || !sig->blocks || new_file_size == 0 || block_size == 0 || @@ -230,6 +343,25 @@ Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const De if (!instrs) return NULL; + /* Build a one-time bucket index over the signature blocks keyed by the weak + * checksum. This turns the per-byte-window candidate lookup from an + * O(block_count) linear scan into an expected O(1) probe, which dominates + * the cost for large mostly-matching files (the diff steps one byte at a + * time through changed regions). On allocation failure the probe falls back + * to the original linear scan, so behaviour is unchanged under memory + * pressure. */ + uint32_t* index = NULL; + const uint32_t* chain_next = NULL; + uint32_t mask = 0; + if (sig->block_count > 0) { + uint32_t bucket_count = delta_next_pow2(sig->block_count); + index = delta_build_index(sig, bucket_count); + if (index) { + chain_next = index + 2 * bucket_count; + mask = bucket_count - 1; + } + } + uint64_t literal_start = 0; bool has_literal = false; @@ -264,34 +396,32 @@ Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const De } bool matched = false; - for (uint32_t j = 0; j < sig->block_count; j++) { - if (adler == sig->blocks[j].adler32 && full_window) { - uint32_t xxh = delta_xxhash32(new_data + i, window_len); - if (xxh == sig->blocks[j].xxhash) { - if (has_literal) { - if (!flush_literal(&instrs, &capacity, &count, new_data, literal_start, i)) { - free_instructions(instrs, count); - return NULL; - } - has_literal = false; - } - - if (!ensure_capacity(&instrs, &capacity, count)) { - free_instructions(instrs, count); - return NULL; - } - instrs[count].type = DELTA_INSTR_BLOCK_MATCH; - instrs[count].match.block_index = j; - instrs[count].match.block_offset = 0; - instrs[count].match.length = window_len; - count++; - - i += window_len; - rolling_valid = false; - matched = true; - break; + uint32_t match_block = delta_find_match(new_data + i, window_len, adler, full_window, sig, + index, chain_next, mask); + if (match_block != DELTA_NO_BLOCK) { + if (has_literal) { + if (!flush_literal(&instrs, &capacity, &count, new_data, literal_start, i)) { + free_instructions(instrs, count); + free(index); + return NULL; } + has_literal = false; } + + if (!ensure_capacity(&instrs, &capacity, count)) { + free_instructions(instrs, count); + free(index); + return NULL; + } + instrs[count].type = DELTA_INSTR_BLOCK_MATCH; + instrs[count].match.block_index = match_block; + instrs[count].match.block_offset = 0; + instrs[count].match.length = window_len; + count++; + + i += window_len; + rolling_valid = false; + matched = true; } if (!matched) { @@ -303,6 +433,8 @@ Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const De } } + free(index); + if (has_literal) { if (!flush_literal(&instrs, &capacity, &count, new_data, literal_start, new_file_size)) { free_instructions(instrs, count); diff --git a/tests/test_delta.c b/tests/test_delta.c index 6e166e8..ac522f8 100644 --- a/tests/test_delta.c +++ b/tests/test_delta.c @@ -343,6 +343,297 @@ static void test_delta_apply_rejects_output_overflow() { EXPECT_TRUE(delta_apply(old_data, sizeof(old_data), &delta, 1) == NULL); } +/* --------------------------------------------------------------------------- + * Hash-index lookup differential tests. + * + * delta_compute buckets signature blocks by their weak checksum. These tests + * prove the bucket-indexed candidate lookup is behaviour-identical to the + * original per-window linear scan: the emitted instruction stream (types, + * lengths, literal bytes and chosen block indices) must match a naive linear + * reference exactly, and the delta must reconstruct the new buffer. + * ------------------------------------------------------------------------- */ + +#define REF_NO_MATCH UINT32_MAX + +typedef struct { + DeltaInstruction* items; + uint32_t count; + uint32_t cap; +} RefDelta; + +static void ref_delta_free(RefDelta* ref) { + if (!ref->items) + return; + for (uint32_t i = 0; i < ref->count; i++) + if (ref->items[i].type == DELTA_INSTR_LITERAL) + free(ref->items[i].literal.data); + free(ref->items); + ref->items = NULL; + ref->count = 0; + ref->cap = 0; +} + +static bool ref_delta_push(RefDelta* ref, DeltaInstruction instr) { + if (ref->count == ref->cap) { + uint32_t new_cap = ref->cap ? ref->cap * 2 : 16; + DeltaInstruction* tmp = realloc(ref->items, (size_t)new_cap * sizeof(DeltaInstruction)); + if (!tmp) + return false; + ref->items = tmp; + ref->cap = new_cap; + } + ref->items[ref->count++] = instr; + return true; +} + +static bool ref_delta_flush_literal(RefDelta* ref, const uint8_t* data, uint64_t start, + uint64_t end) { + if (start >= end) + return true; + uint8_t* lit = malloc((size_t)(end - start)); + if (!lit) + return false; + memcpy(lit, data + start, (size_t)(end - start)); + DeltaInstruction instr = { + .type = DELTA_INSTR_LITERAL, + .literal = {.data = lit, .length = (uint32_t)(end - start)}, + }; + return ref_delta_push(ref, instr); +} + +/* Naive O(windows x blocks) re-implementation of the historical delta_compute + * candidate scan: only full windows may match, a candidate needs both the weak + * (Adler-32) and strong (xxHash32) checksums to agree, and the lowest matching + * block index is selected. */ +static bool ref_delta_build(RefDelta* ref, const uint8_t* new_data, uint64_t new_size, + const DeltaSignature* sig) { + uint32_t block_size = sig->block_size; + uint64_t i = 0; + uint64_t literal_start = 0; + bool has_literal = false; + + while (i < new_size) { + uint32_t window_len = (uint32_t)((new_size - i < block_size) ? (new_size - i) : block_size); + bool full_window = (window_len == block_size); + + uint32_t matched = REF_NO_MATCH; + if (full_window) { + uint32_t adler = delta_adler32(new_data + i, window_len); + for (uint32_t j = 0; j < sig->block_count; j++) { + if (sig->blocks[j].adler32 == adler && + delta_xxhash32(new_data + i, window_len) == sig->blocks[j].xxhash) { + matched = j; + break; + } + } + } + + if (matched != REF_NO_MATCH) { + if (has_literal) { + if (!ref_delta_flush_literal(ref, new_data, literal_start, i)) + return false; + has_literal = false; + } + DeltaInstruction instr = { + .type = DELTA_INSTR_BLOCK_MATCH, + .match = {.block_index = matched, .block_offset = 0, .length = window_len}, + }; + if (!ref_delta_push(ref, instr)) + return false; + i += window_len; + } else { + if (!has_literal) { + literal_start = i; + has_literal = true; + } + i++; + } + } + + if (has_literal && !ref_delta_flush_literal(ref, new_data, literal_start, new_size)) + return false; + return true; +} + +static bool ref_delta_matches(const RefDelta* ref, const Delta* delta) { + if (ref->count != delta->instruction_count) + return false; + for (uint32_t i = 0; i < ref->count; i++) { + const DeltaInstruction* a = &ref->items[i]; + const DeltaInstruction* b = &delta->instructions[i]; + if (a->type != b->type) + return false; + if (a->type == DELTA_INSTR_BLOCK_MATCH) { + if (a->match.block_index != b->match.block_index || + a->match.block_offset != b->match.block_offset || a->match.length != b->match.length) + return false; + } else { + if (a->literal.length != b->literal.length || + memcmp(a->literal.data, b->literal.data, a->literal.length) != 0) + return false; + } + } + return true; +} + +static void expect_linear_reference_match(const uint8_t* old_data, uint64_t old_size, + const uint8_t* new_data, uint64_t new_size, + uint32_t block_size, const char* label) { + DeltaSignature* sig = delta_signature_create(old_data, old_size, block_size); + if (!sig) { + printf(" [FAIL] %s: signature creation failed\n", label); + EXPECT_NOT_NULL(sig); + return; + } + Delta* delta = delta_compute(new_data, new_size, sig, block_size); + if (!delta) { + printf(" [FAIL] %s: delta_compute returned NULL\n", label); + delta_signature_destroy(sig); + EXPECT_NOT_NULL(delta); + return; + } + RefDelta ref = {0}; + bool ok = ref_delta_build(&ref, new_data, new_size, sig); + if (ok) + ok = ref_delta_matches(&ref, delta); + if (!ok) { + printf(" [FAIL] %s: instruction stream differs from linear reference " + "(linear=%u indexed=%u)\n", + label, ref.count, delta->instruction_count); + } + ref_delta_free(&ref); + delta_destroy(delta); + delta_signature_destroy(sig); + EXPECT_TRUE(ok); +} + +static void fill_delta_pattern(uint8_t* buf, uint64_t size, uint32_t seed) { + uint32_t x = seed ? seed : 1; + for (uint64_t i = 0; i < size; i++) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + buf[i] = (uint8_t)(x >> 24); + } +} + +static void test_delta_hash_index_matches_linear_reference() { + /* Identical file (full block alignment). */ + uint8_t old_a[32768]; + uint8_t new_a[32768]; + fill_delta_pattern(old_a, sizeof(old_a), 42); + memcpy(new_a, old_a, sizeof(old_a)); + expect_linear_reference_match(old_a, sizeof(old_a), new_a, sizeof(new_a), 2048, + "identical 32KiB @ 2KiB"); + + /* Scattered single-byte edits in the middle of each block. */ + uint8_t new_b[32768]; + memcpy(new_b, old_a, sizeof(old_a)); + for (size_t p = 100; p < sizeof(new_b); p += 4096) + new_b[p] ^= 0x5A; + expect_linear_reference_match(old_a, sizeof(old_a), new_b, sizeof(new_b), 2048, + "32KiB scattered single-byte edits @ 2KiB"); + + /* Non-aligned old file (partial final block) with a single edit. */ + uint8_t old_c[30000]; + uint8_t new_c[30000]; + fill_delta_pattern(old_c, sizeof(old_c), 7); + memcpy(new_c, old_c, sizeof(old_c)); + new_c[15000] ^= 0x3C; + expect_linear_reference_match(old_c, sizeof(old_c), new_c, sizeof(new_c), 2048, + "30KiB partial-tail single edit @ 2KiB"); + + /* Growth: appended data after an identical prefix. */ + uint8_t old_d[24576]; + uint8_t new_d[34576]; + fill_delta_pattern(old_d, sizeof(old_d), 11); + memcpy(new_d, old_d, sizeof(old_d)); + fill_delta_pattern(new_d + sizeof(old_d), sizeof(new_d) - sizeof(old_d), 23); + expect_linear_reference_match(old_d, sizeof(old_d), new_d, sizeof(new_d), 2048, + "24KiB -> 34KiB appended @ 2KiB"); + + /* Insertion shifting everything after the edit point (rsync re-sync). */ + uint8_t old_e[65536]; + uint8_t new_e[65536 + 3000]; + fill_delta_pattern(old_e, sizeof(old_e), 99); + memcpy(new_e, old_e, 20000); + fill_delta_pattern(new_e + 20000, 3000, 101); + memcpy(new_e + 23000, old_e + 20000, sizeof(old_e) - 20000); + expect_linear_reference_match(old_e, sizeof(old_e), new_e, sizeof(new_e), 2048, + "64KiB + 3KiB insertion @ 2KiB"); + + /* Deletion shrinking the file. */ + uint8_t new_f[sizeof(old_e) - 5000]; + memcpy(new_f, old_e, 30000); + memcpy(new_f + 30000, old_e + 35000, sizeof(old_e) - 35000); + expect_linear_reference_match(old_e, sizeof(old_e), new_f, sizeof(new_f), 2048, + "64KiB - 5KiB deletion @ 2KiB"); + + /* Repeated identical blocks must resolve to the lowest block index. */ + uint8_t old_g[4 * 4096]; + uint8_t new_g[4 * 4096]; + for (uint32_t b = 0; b < 4; b++) + fill_delta_pattern(old_g + b * 4096, 4096, b % 2 == 0 ? 500 : 501); /* block0==block2 */ + memcpy(new_g, old_g, sizeof(old_g)); + new_g[4096 + 5] ^= 0x11; /* edit inside the second (duplicated) chunk */ + expect_linear_reference_match(old_g, sizeof(old_g), new_g, sizeof(new_g), 4096, + "duplicated chunks @ 4KiB"); + + /* Block larger than the file: nothing can match, all literal. */ + uint8_t old_h[1000]; + uint8_t new_h[1000]; + fill_delta_pattern(old_h, sizeof(old_h), 3); + memcpy(new_h, old_h, sizeof(old_h)); + expect_linear_reference_match(old_h, sizeof(old_h), new_h, sizeof(new_h), 4096, + "1KiB file @ 4KiB block"); +} + +static void test_delta_hash_index_large_mostly_matching() { + const uint64_t size = 4ULL * 1024 * 1024; + const uint32_t block_size = 8192; + + uint8_t* old_data = malloc((size_t)size); + uint8_t* new_data = malloc((size_t)size); + EXPECT_TRUE(old_data != NULL && new_data != NULL); + + fill_delta_pattern(old_data, size, 1234); + memcpy(new_data, old_data, (size_t)size); + + /* Scattered single-byte changes across the whole buffer. Each change forces + * the diff to re-synchronise by walking one byte at a time through the + * affected block, which is exactly the case that used to cost O(bytes x + * blocks) with the linear scan. */ + const uint64_t nchanges = 64; + for (uint64_t c = 0; c < nchanges; c++) { + uint64_t pos = (c * (size / nchanges)) + (c % 17); + new_data[pos] ^= (uint8_t)(0xA0 + (c % 16)); + } + + DeltaSignature* sig = delta_signature_create(old_data, size, block_size); + EXPECT_NOT_NULL(sig); + EXPECT_EQ_INT((int)sig->block_count, (int)(size / block_size)); + + Delta* delta = delta_compute(new_data, size, sig, block_size); + EXPECT_NOT_NULL(delta); + EXPECT_EQ_INT((int)delta->new_file_size, (int)size); + + uint32_t match_count = 0; + for (uint32_t i = 0; i < delta->instruction_count; i++) + if (delta->instructions[i].type == DELTA_INSTR_BLOCK_MATCH) + match_count++; + EXPECT_TRUE(match_count > 0); + + void* result = delta_apply(old_data, size, delta, block_size); + EXPECT_NOT_NULL(result); + EXPECT_EQ_INT(memcmp(result, new_data, (size_t)size), 0); + + free(result); + delta_destroy(delta); + delta_signature_destroy(sig); + free(old_data); + free(new_data); +} + void test_delta() { test_adler32_basic(); test_adler32_different_data(); @@ -360,4 +651,6 @@ void test_delta() { test_is_worthwhile(); test_large_file_delta(); test_delta_apply_rejects_output_overflow(); + test_delta_hash_index_matches_linear_reference(); + test_delta_hash_index_large_mostly_matching(); }