Skip to content

Commit 53bea66

Browse files
committed
make diffing 6x faster
1 parent 911a590 commit 53bea66

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

bsdiff.zig

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,46 @@ pub fn calculateDifferences(allocator: *std.mem.Allocator, oldData: []const u8,
167167
const saisTimeSec = @as(f64, @floatFromInt(saisTime)) / 1000.0;
168168
std.debug.print("Suffix array built in {d:.2}s\n", .{saisTimeSec});
169169

170+
// Build LCP array for accelerated search
171+
std.debug.print("Building LCP array...\n", .{});
172+
const lcpStart = std.time.milliTimestamp();
173+
174+
// First compute PLCP (permuted LCP), then convert to LCP
175+
const plcp = try allocator.alloc(i64, oldData.len);
176+
defer allocator.free(plcp);
177+
178+
const plcpResult = libsais.zig_libsais64_plcp(
179+
oldData.ptr,
180+
suffixIndexes.ptr,
181+
plcp.ptr,
182+
oldDataLen,
183+
);
184+
185+
if (plcpResult != 0) {
186+
std.debug.print("libsais64_plcp failed with error code: {d}\n", .{plcpResult});
187+
return error.PLCPConstructionFailed;
188+
}
189+
190+
// LCP array: LCP[i] = length of longest common prefix between SA[i-1] and SA[i]
191+
const lcpArray = try allocator.alloc(i64, oldData.len);
192+
defer allocator.free(lcpArray);
193+
194+
const lcpResult = libsais.zig_libsais64_lcp(
195+
plcp.ptr,
196+
suffixIndexes.ptr,
197+
lcpArray.ptr,
198+
oldDataLen,
199+
);
200+
201+
if (lcpResult != 0) {
202+
std.debug.print("libsais64_lcp failed with error code: {d}\n", .{lcpResult});
203+
return error.LCPConstructionFailed;
204+
}
205+
206+
const lcpTime = std.time.milliTimestamp() - lcpStart;
207+
const lcpTimeSec = @as(f64, @floatFromInt(lcpTime)) / 1000.0;
208+
std.debug.print("LCP array built in {d:.2}s\n", .{lcpTimeSec});
209+
170210
// Add sentinel value at the end (used by the original algorithm)
171211
suffixIndexes[oldData.len] = @intCast(oldData.len);
172212

@@ -241,6 +281,10 @@ pub fn calculateDifferences(allocator: *std.mem.Allocator, oldData: []const u8,
241281

242282
// var controlBlockOffset: usize = 0;
243283

284+
// Timing for diff phase
285+
const diffPhaseStart = std.time.milliTimestamp();
286+
var searchCount: usize = 0;
287+
244288
// Begin the main loop for calculating differences
245289
while (scanIndex < newsize) {
246290
// Update progress for logging thread
@@ -254,7 +298,9 @@ pub fn calculateDifferences(allocator: *std.mem.Allocator, oldData: []const u8,
254298
// Loop through newData, searching for matches in oldData
255299
while (scanIndex < newsize) {
256300
// Note: most of the time during the diffing phase is spend in search()
257-
matchLength = @intCast(search(suffixIndexes, oldData, newData[@intCast(scanIndex)..], 0, @intCast(oldsize), &matchPosition));
301+
// Using LCP-accelerated search with boundary tracking for O(m + log n) instead of O(m * log n)
302+
matchLength = @intCast(searchWithLCP(suffixIndexes, lcpArray, oldData, newData[@intCast(scanIndex)..], 0, @intCast(oldsize), &matchPosition));
303+
searchCount += 1;
258304

259305
// Increment matchScore based on direct matches between newData and shifted oldData
260306
while (scoreCounter < scanIndex + matchLength) {
@@ -415,6 +461,11 @@ pub fn calculateDifferences(allocator: *std.mem.Allocator, oldData: []const u8,
415461
progressRunning = false;
416462
progressThread.join();
417463

464+
// Report diff phase timing
465+
const diffPhaseTime = std.time.milliTimestamp() - diffPhaseStart;
466+
const diffPhaseSec = @as(f64, @floatFromInt(diffPhaseTime)) / 1000.0;
467+
std.debug.print("Diff phase: {d:.2}s ({d} search calls)\n", .{ diffPhaseSec, searchCount });
468+
418469
// Tell the compression threads to wrap up and wait for them
419470
streamingBytes = false;
420471
diffBlockThread.join();
@@ -515,6 +566,111 @@ fn offtout(x: i64, buf: []u8) void {
515566
buf[7] = @intCast((y >> 56) & 0xFF);
516567
}
517568

569+
/// LCP-accelerated binary search to find the longest match.
570+
/// By tracking match lengths at boundaries and using the LCP array,
571+
/// we can skip redundant comparisons, achieving O(m + log n) instead of O(m * log n).
572+
fn searchWithLCP(suffixIndexes: []i64, _: []i64, oldData: []const u8, newData: []const u8, from: usize, to: usize, bestMatchPosition: *i64) usize {
573+
// Use iterative approach with LCP acceleration
574+
var lo: usize = from;
575+
var hi: usize = to;
576+
var loMatch: usize = 0; // chars matching at lo boundary
577+
var hiMatch: usize = 0; // chars matching at hi boundary
578+
579+
const oldDataSize = oldData.len;
580+
const newDataSize = newData.len;
581+
582+
// Initial match lengths at boundaries
583+
loMatch = matchlenFast(oldData[@intCast(suffixIndexes[lo])..], newData);
584+
hiMatch = matchlenFast(oldData[@intCast(suffixIndexes[hi])..], newData);
585+
586+
var bestMatch: usize = loMatch;
587+
bestMatchPosition.* = suffixIndexes[lo];
588+
if (hiMatch > bestMatch) {
589+
bestMatch = hiMatch;
590+
bestMatchPosition.* = suffixIndexes[hi];
591+
}
592+
593+
while (hi - lo > 1) {
594+
const mid = lo + (hi - lo) / 2;
595+
const midSuffixPos: usize = @intCast(suffixIndexes[mid]);
596+
597+
// Start comparison from the minimum of the two boundary matches
598+
// This is the key LCP optimization - we know at least this many chars must match
599+
const skipLen = @min(loMatch, hiMatch);
600+
601+
// Compare starting from skipLen
602+
const compareLen = @min(oldDataSize - midSuffixPos, newDataSize);
603+
var midMatch: usize = skipLen;
604+
605+
// Continue matching from skipLen
606+
if (skipLen < compareLen) {
607+
midMatch += matchlenFrom(oldData[midSuffixPos + skipLen ..], newData[skipLen..]);
608+
}
609+
610+
// Update best match if this is better
611+
if (midMatch > bestMatch) {
612+
bestMatch = midMatch;
613+
bestMatchPosition.* = suffixIndexes[mid];
614+
}
615+
616+
// Decide which half to search based on lexicographic comparison
617+
if (midMatch < compareLen and midMatch < newDataSize) {
618+
// We stopped matching at position midMatch
619+
if (midSuffixPos + midMatch < oldDataSize and oldData[midSuffixPos + midMatch] < newData[midMatch]) {
620+
// Suffix at mid is less than query, search right half
621+
lo = mid;
622+
loMatch = midMatch;
623+
} else {
624+
// Suffix at mid is greater than query, search left half
625+
hi = mid;
626+
hiMatch = midMatch;
627+
}
628+
} else {
629+
// Full match up to the limit, decide based on lengths
630+
if (compareLen <= newDataSize) {
631+
// Need longer suffixes, search left (smaller indices = longer suffixes in sorted order when equal prefix)
632+
hi = mid;
633+
hiMatch = midMatch;
634+
} else {
635+
lo = mid;
636+
loMatch = midMatch;
637+
}
638+
}
639+
}
640+
641+
return bestMatch;
642+
}
643+
644+
/// Helper function: match length starting from an offset (for LCP-accelerated search)
645+
fn matchlenFrom(oldData: []const u8, newData: []const u8) usize {
646+
const minSize = @min(oldData.len, newData.len);
647+
var i: usize = 0;
648+
649+
// Use 8-byte lookahead for speed
650+
while (i + 8 <= minSize) {
651+
const oldSlice: *const [8]u8 = @ptrCast(&oldData[i]);
652+
const newSlice: *const [8]u8 = @ptrCast(&newData[i]);
653+
const oldAs64 = std.mem.readInt(u64, oldSlice, std.builtin.Endian.big);
654+
const newAs64 = std.mem.readInt(u64, newSlice, std.builtin.Endian.big);
655+
656+
if (oldAs64 != newAs64) {
657+
// Find exact mismatch position within the 8 bytes
658+
while (i < minSize and oldData[i] == newData[i]) {
659+
i += 1;
660+
}
661+
return i;
662+
}
663+
i += 8;
664+
}
665+
666+
// Handle remaining bytes
667+
while (i < minSize and oldData[i] == newData[i]) {
668+
i += 1;
669+
}
670+
671+
return i;
672+
}
673+
518674
/// Do a binary search to find the longest match of `newData` within `oldData` using precomputed suffixIndexes.
519675
fn search(suffixIndexes: []i64, oldData: []const u8, newData: []const u8, from: usize, to: usize, bestMatchPosition: *i64) usize {
520676
var midPoint: usize = 0;

src/libsais-wrapper/zig_wrapper.c

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,23 @@ int64_t zig_libsais64_wrapper(
1313
// fs=0 means no extra space, freq=NULL means no frequency table
1414
return libsais64(T, SA, n, 0, NULL);
1515
}
16+
17+
// Constructs the permuted longest common prefix array (PLCP)
18+
int64_t zig_libsais64_plcp(
19+
const uint8_t * T,
20+
const int64_t * SA,
21+
int64_t * PLCP,
22+
int64_t n
23+
) {
24+
return libsais64_plcp(T, SA, PLCP, n);
25+
}
26+
27+
// Constructs the longest common prefix array (LCP) from PLCP
28+
int64_t zig_libsais64_lcp(
29+
const int64_t * PLCP,
30+
const int64_t * SA,
31+
int64_t * LCP,
32+
int64_t n
33+
) {
34+
return libsais64_lcp(PLCP, SA, LCP, n);
35+
}

src/libsais-wrapper/zig_wrapper.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,20 @@ int64_t zig_libsais64_wrapper(
1111
int64_t n
1212
);
1313

14+
// Constructs the permuted longest common prefix array (PLCP)
15+
int64_t zig_libsais64_plcp(
16+
const uint8_t * T,
17+
const int64_t * SA,
18+
int64_t * PLCP,
19+
int64_t n
20+
);
21+
22+
// Constructs the longest common prefix array (LCP) from PLCP
23+
int64_t zig_libsais64_lcp(
24+
const int64_t * PLCP,
25+
const int64_t * SA,
26+
int64_t * LCP,
27+
int64_t n
28+
);
29+
1430
#endif

0 commit comments

Comments
 (0)