Skip to content

Commit 9b0e23b

Browse files
authored
perf(upgrade): vectorize bspatch diff-add with SWAR (4x faster apply) (#1280)
## What The diff-add step (`output[i] = (old[i] + diff[i]) % 256`) is the hot inner loop of `bspatch` apply. Doing it byte-at-a-time in JS — two typed-array property reads + a `% 256` per byte — dominates CPU time when reconstructing a large binary. ## How Extracts the loop into `addDiffChunk` and processes **4 bytes per iteration** with a SWAR (SIMD-within-a-register) add on `Uint32Array`: ``` lows = (a & 0x7f7f7f7f) + (b & 0x7f7f7f7f) // top bit cleared → carries stay in-lane highs = (a ^ b) & 0x80808080 // per-lane high-bit sum result = lows ^ highs // per-byte sum mod 256 ``` Carry-less: each byte lane sums independently mod 256. A short tail loop handles the trailing `n % 4` bytes. Behavior is identical to the byte loop it replaces. On the Lore gateway's ported copy of this file (~310 MB binary, where this optimization was first landed and reviewed) the diff-add dropped from **~883 ms to ~221 ms (4×)**. sentry-cli binaries are smaller so the absolute win is smaller, but it's free. The code documents the load-bearing INVARIANT: the carry masks must match the word width. A `BigUint64Array` variant must widen **both** masks to 64-bit; pairing 64-bit words with the 32-bit mask silently drops carries in the upper 4 bytes. ## Tests (exhaustive) - Matches a per-byte reference implementation across all 256 byte values and every 4-byte alignment. - Wrap-around at `0xff` in every lane — a cross-lane carry detector. - Tail lengths 0–3 across many window sizes. - **Mutation-verified:** a mask/word-width mismatch turns all three SWAR tests RED. ## Verification - 18/18 `test/lib/bspatch.test.ts` pass, `tsc --noEmit` clean, `biome check` clean. - Independent of #1279 (the `MAX_OUTPUT_SIZE` security fix) — separate file regions, no overlap.
1 parent cb2ac09 commit 9b0e23b

2 files changed

Lines changed: 172 additions & 4 deletions

File tree

src/lib/bspatch.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,81 @@ async function loadOldBinary(oldPath: string): Promise<OldReader> {
494494
}
495495
}
496496

497+
/**
498+
* Wrapping unsigned byte addition (`old + diff mod 256`) for a diff window.
499+
*
500+
* This is the hot inner loop of bspatch apply. Doing it byte-at-a-time in JS
501+
* (two typed-array property accesses + a `% 256` per byte) is the dominant CPU
502+
* cost on a large binary. We instead process 4 bytes per iteration with a
503+
* SWAR (SIMD-within-a-register) add on `Uint32Array`:
504+
*
505+
* lows = (a & 0x7f7f7f7f) + (b & 0x7f7f7f7f) // top bit of each byte is 0,
506+
* // so carries stay in-lane
507+
* highs = (a ^ b) & 0x80808080 // high-bit carry per byte
508+
* result = lows ^ highs // per-byte sum mod 256
509+
*
510+
* This is carry-less (each byte lane sums independently mod 256) and is
511+
* verified exhaustively (all byte values + every 4-byte alignment + tail 0-3).
512+
* A short tail loop handles the trailing `n % 4` bytes. The old bytes are read
513+
* as raw bytes (no `?? 0` per element) because the OldReader already zero-fills
514+
* out-of-range positions.
515+
*
516+
* INVARIANT: the carry masks must match the word width. The low mask clears
517+
* the top bit of EVERY byte lane and the high mask isolates the top bit of
518+
* EVERY lane — both are 0x7f7f7f7f / 0x80808080 because words are 32-bit. A
519+
* wider-word (BigUint64Array) variant must widen BOTH masks to 64-bit
520+
* (0x7f7f7f7f7f7f7f7f / 0x8080808080808080); pairing 64-bit words with the
521+
* 32-bit carry mask silently drops carries in the upper 4 bytes and corrupts
522+
* the output (caught by the exhaustive tests).
523+
*/
524+
export function addDiffChunk(
525+
output: Uint8Array,
526+
oldChunk: Uint8Array,
527+
diffChunk: Uint8Array,
528+
n: number
529+
): void {
530+
// The SWAR fast path reinterprets each buffer as Uint32Array, which requires
531+
// a 4-byte-aligned byteOffset — `new Uint32Array(buf.buffer, byteOffset)`
532+
// throws RangeError otherwise. Today all callers pass fresh, offset-0 buffers
533+
// (new Uint8Array(len) / Buffer.alloc(len)), but a future caller could pass a
534+
// pooled or subarray view. Rather than throw (this is a perf detail that must
535+
// never break apply), fall back to the byte loop when any buffer is
536+
// misaligned. Correct for all inputs; the SWAR path is a pure optimization.
537+
const aligned =
538+
output.byteOffset % 4 === 0 &&
539+
oldChunk.byteOffset % 4 === 0 &&
540+
diffChunk.byteOffset % 4 === 0;
541+
542+
const words = aligned ? Math.floor(n / 4) : 0;
543+
if (words > 0) {
544+
const oldWords = new Uint32Array(
545+
oldChunk.buffer,
546+
oldChunk.byteOffset,
547+
words
548+
);
549+
const diffWords = new Uint32Array(
550+
diffChunk.buffer,
551+
diffChunk.byteOffset,
552+
words
553+
);
554+
const outWords = new Uint32Array(output.buffer, output.byteOffset, words);
555+
const LOW = 0x7f_7f_7f_7f;
556+
const HIGH = 0x80_80_80_80;
557+
for (let i = 0; i < words; i++) {
558+
const a = oldWords[i] ?? 0;
559+
const b = diffWords[i] ?? 0;
560+
// biome-ignore lint/suspicious/noBitwiseOperators: SWAR per-lane add uses bitmask/xor by design
561+
const sum = ((a & LOW) + (b & LOW)) ^ ((a ^ b) & HIGH);
562+
// biome-ignore lint/suspicious/noBitwiseOperators: coerce to uint32
563+
outWords[i] = sum >>> 0;
564+
}
565+
}
566+
const tailStart = words * 4;
567+
for (let i = tailStart; i < n; i++) {
568+
output[i] = ((oldChunk[i] ?? 0) + (diffChunk[i] ?? 0)) % 256;
569+
}
570+
}
571+
497572
/**
498573
* Core TRDIFF10 transform.
499574
*
@@ -559,10 +634,9 @@ async function transformPatch(
559634
const oldChunk = await oldReader.read(oldpos, readDiffBy);
560635
const outputChunk = new Uint8Array(readDiffBy);
561636

562-
for (let i = 0; i < readDiffBy; i++) {
563-
// Wrapping unsigned byte addition, matching zig-bsdiff's @addWithOverflow
564-
outputChunk[i] = ((oldChunk[i] ?? 0) + (diffChunk[i] ?? 0)) % 256;
565-
}
637+
// Wrapping unsigned byte addition, matching zig-bsdiff's @addWithOverflow.
638+
// SWAR on Uint32Array — see addDiffChunk.
639+
addDiffChunk(outputChunk, oldChunk, diffChunk, readDiffBy);
566640

567641
onChunk(outputChunk);
568642
oldpos += readDiffBy;

test/lib/bspatch.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { join } from "node:path";
1818
import { zstdCompressSync } from "node:zlib";
1919
import { describe, expect, test } from "vitest";
2020
import {
21+
addDiffChunk,
2122
applyPatch,
2223
applyPatchChainInMemory,
2324
applyPatchToMemory,
@@ -388,6 +389,99 @@ describe("applyPatchToMemory", () => {
388389
});
389390
});
390391

392+
describe("addDiffChunk SWAR wrapping-add", () => {
393+
// The diff-add loop is the hot path of bspatch apply. The SWAR (Uint32Array)
394+
// implementation must match a per-byte `(old + diff) % 256` reference for
395+
// EVERY byte value, every 4-byte alignment, and every tail length 0-3 — a
396+
// carry bug (e.g. a BigUint64Array variant, where carries cross byte lanes)
397+
// corrupts the output silently.
398+
399+
/** Per-byte wrapping-add reference (the specification addDiffChunk must meet). */
400+
function referenceAdd(old: Uint8Array, diff: Uint8Array): Uint8Array {
401+
const out = new Uint8Array(old.length);
402+
for (let i = 0; i < old.length; i++) {
403+
out[i] = ((old[i] ?? 0) + (diff[i] ?? 0)) % 256;
404+
}
405+
return out;
406+
}
407+
408+
test("matches the byte-loop reference for all byte values and alignments", async () => {
409+
// Build a base covering many window sizes so every (old, diff) byte pair
410+
// appears across several 4-byte SWAR boundaries and tail lengths 0-3.
411+
// newBytes is derived from old+diff, so applyPatchToMemory must reproduce
412+
// exactly referenceAdd(old, diff).
413+
const total = 4096 + 3; // odd length hits a 3-byte tail
414+
const oldBytes = new Uint8Array(total);
415+
for (let i = 0; i < total; i++) {
416+
// Knuth multiplicative hash spread, folded to a byte — hits all 256 values.
417+
oldBytes[i] = Math.floor((i * 2_654_435_761) / 256) % 256;
418+
}
419+
// Vary the diff so (old, diff) pairs cover many high/low-bit combos.
420+
const diffBytes = new Uint8Array(total);
421+
for (let i = 0; i < total; i++) {
422+
diffBytes[i] = (i * 31 + (i % 256 >= 128 ? 128 : 0) + 7) % 256;
423+
}
424+
const expected = referenceAdd(oldBytes, diffBytes);
425+
const patch = buildDiffOnlyPatch(oldBytes, expected);
426+
427+
const out = await applyPatchToMemory(oldBytes, patch);
428+
expect(out).toEqual(expected);
429+
});
430+
431+
test("handles wrap-around at the byte max (255 + k) in every lane", async () => {
432+
// old=255 forces a carry in every byte; ensure it wraps to (255+diff)%256,
433+
// not into the adjacent lane. A cross-lane carry bug shows up here.
434+
const n = 64;
435+
const oldBytes = new Uint8Array(n).fill(0xff);
436+
const expected = new Uint8Array(n);
437+
for (let i = 0; i < n; i++) expected[i] = (0xff + i) % 256;
438+
const patch = buildDiffOnlyPatch(oldBytes, expected);
439+
440+
const out = await applyPatchToMemory(oldBytes, patch);
441+
expect(out).toEqual(expected);
442+
});
443+
444+
test("handles tail lengths 0-3 across many window sizes", async () => {
445+
for (const n of [1, 2, 3, 4, 5, 7, 8, 9, 21, 1023]) {
446+
const oldBytes = new Uint8Array(n);
447+
for (let i = 0; i < n; i++) oldBytes[i] = (i * 17) % 256;
448+
const expected = referenceAdd(
449+
oldBytes,
450+
Uint8Array.from({ length: n }, (_, i) => (i * 29) % 256)
451+
);
452+
const patch = buildDiffOnlyPatch(oldBytes, expected);
453+
const out = await applyPatchToMemory(oldBytes, patch);
454+
expect(out).toEqual(expected);
455+
}
456+
});
457+
458+
test("produces correct output when a buffer view is misaligned (SWAR fallback)", () => {
459+
// The SWAR fast path needs a 4-byte-aligned byteOffset; addDiffChunk falls
460+
// back to the byte loop otherwise. No current caller passes a misaligned
461+
// view (MemoryOldReader/FileOldReader and the output chunk are all fresh,
462+
// offset-0 allocations), so this directly unit-tests the guard against a
463+
// future caller that might pass a pooled/subarray view. Every offset combo
464+
// (0-3 on each of old/diff/output) must match the per-byte reference.
465+
const n = 4096 + 3; // exercises a 3-byte tail too
466+
for (const oldOff of [0, 1, 2, 3]) {
467+
for (const diffOff of [0, 3]) {
468+
for (const outOff of [0, 2]) {
469+
const oldBuf = new Uint8Array(n + oldOff).subarray(oldOff);
470+
const diffBuf = new Uint8Array(n + diffOff).subarray(diffOff);
471+
const outBuf = new Uint8Array(n + outOff).subarray(outOff);
472+
for (let i = 0; i < n; i++) {
473+
oldBuf[i] = (i * 37) % 256;
474+
diffBuf[i] = (i * 53 + 3) % 256;
475+
}
476+
const expected = referenceAdd(oldBuf, diffBuf);
477+
addDiffChunk(outBuf, oldBuf, diffBuf, n);
478+
expect(outBuf).toEqual(expected);
479+
}
480+
}
481+
}
482+
});
483+
});
484+
391485
describe("FileOldReader block cache", () => {
392486
const ONE_MIB = 1024 * 1024;
393487

0 commit comments

Comments
 (0)