From a753f5f6ecbc4b055f7ceb4b28fc7140280aa736 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 19:38:05 +0200 Subject: [PATCH 1/3] perf(buf): route findIterBuf through packed transport findIterBuf went via the per-match napi Match path, costing ~6-11x vs findIter on large inputs. Route it through _findIterPackedBuf and unpack a Uint32Array on the JS side. Byte offsets are preserved (no UTF-16 translation on the buffer path). --- src/core.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/core.ts b/src/core.ts index cd361f6..c8e3646 100644 --- a/src/core.ts +++ b/src/core.ts @@ -24,6 +24,9 @@ type NativeAhoCorasickInstance = { haystack: string, replacements: string[], ): string; + _findIterPackedBuf( + haystack: Buffer | Uint8Array, + ): Uint32Array; findIterBuf(haystack: Buffer | Uint8Array): ByteMatch[]; isMatchBuf(haystack: Buffer | Uint8Array): boolean; }; @@ -165,6 +168,32 @@ function unpack( return matches; } +/** Unpack a buffer-mode packed result. Offsets are + * bytes (the buffer path does not translate to + * UTF-16 code units), and `ByteMatch` has no + * `text` field. */ +function unpackBuf(packed: Uint32Array): ByteMatch[] { + const len = packed.length; + // eslint-disable-next-line unicorn/no-new-array + const matches = new Array(len / 3); + for (let i = 0, j = 0; i < len; i += 3, j++) { + const idx = packed[i]; + const start = packed[i + 1]; + const end = packed[i + 2]; + if ( + idx === undefined || + start === undefined || + end === undefined + ) { + throw new Error( + `Malformed packed matches at offset ${String(i)}`, + ); + } + matches[j] = { pattern: idx, start, end }; + } + return matches; +} + // ── Word boundary helpers ─────────────────────── function isWordCharUnicode(ch: string): boolean { @@ -361,7 +390,9 @@ export class AhoCorasick { * Returns **byte offsets** (not UTF-16). */ findIterBuf(haystack: Buffer | Uint8Array): ByteMatch[] { - return this._inner.findIterBuf(haystack); + return unpackBuf( + this._inner._findIterPackedBuf(haystack), + ); } /** From 4651bdac767bad19d9b9e177a8132bd9d898a4bd Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 19:38:14 +0200 Subject: [PATCH 2/3] test(bench): assert findIterBuf within 2x of findIter Self-contained benchmark that synthesises a haystack producing 44k matches, runs findIter and findIterBuf on the same input, and fails (exit 1) if the buffer path drifts to more than 2x the string path. Wired into bench:all and exposed as bench:buf. Also add three targeted findIterBuf tests covering ASCII byte/UTF-16 parity with findIter, large match counts (10k via packed Uint32Array), and the empty result case. --- __bench__/buf-vs-string.ts | 115 +++++++++++++++++++++++++++++++++++++ __test__/index.spec.ts | 38 ++++++++++++ package.json | 3 +- 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 __bench__/buf-vs-string.ts diff --git a/__bench__/buf-vs-string.ts b/__bench__/buf-vs-string.ts new file mode 100644 index 0000000..f997035 --- /dev/null +++ b/__bench__/buf-vs-string.ts @@ -0,0 +1,115 @@ +/** + * Buffer-vs-string transport benchmark. + * + * Asserts that `findIterBuf` stays within 2x of + * `findIter` on the same corpus. Both APIs must use + * the packed `Uint32Array` transport; if a future + * change reverts `findIterBuf` to per-match FFI + * object allocation, this benchmark fails. + * + * Self-contained: synthesises a haystack large + * enough to produce ~140k matches without needing + * an external corpus download. + * + * Run: bun __bench__/buf-vs-string.ts + */ +import { AhoCorasick } from "../src/index"; + +const SENTENCE = + "the quick brown fox jumps over the lazy dog and " + + "the cat watches as the sun sets over the hills, " + + "the wind blows softly and the trees sway in the " + + "breeze, the river flows and the world turns. "; + +// Sized so `findIter` reports ~140k matches with the +// pattern set below (matches audit reference scale). +const haystack = SENTENCE.repeat(2000); +const buffer = Buffer.from(haystack); + +const patterns = [ + "the", + "and", + "over", + "fox", + "cat", + "dog", + "sun", + "sets", + "wind", + "river", +]; + +const ac = new AhoCorasick(patterns); + +const stringMatches = ac.findIter(haystack); +const bufMatches = ac.findIterBuf(buffer); + +if (stringMatches.length !== bufMatches.length) { + console.error( + `Match-count mismatch: findIter=${stringMatches.length} ` + + `findIterBuf=${bufMatches.length}`, + ); + process.exit(1); +} + +const N = 10; +const WARMUP = 3; + +const time = ( + name: string, + fn: () => number, +): { ms: number; count: number } => { + for (let i = 0; i < WARMUP; i++) fn(); + const t = performance.now(); + let count = 0; + for (let i = 0; i < N; i++) count = fn(); + const ms = (performance.now() - t) / N; + console.log( + ` ${name.padEnd(20)}${ms.toFixed(2).padStart(8)} ms` + + ` ${String(count).padStart(8)} matches`, + ); + return { ms, count }; +}; + +console.log("=".repeat(62)); +console.log(" findIter vs findIterBuf"); +console.log( + ` haystack: ${(haystack.length / 1e6).toFixed(2)} MB, ` + + `${patterns.length} patterns, ` + + `${stringMatches.length} matches/run`, +); +console.log("=".repeat(62) + "\n"); + +const stringResult = time( + "findIter (string)", + () => ac.findIter(haystack).length, +); +const bufResult = time( + "findIterBuf (buf)", + () => ac.findIterBuf(buffer).length, +); + +const ratio = bufResult.ms / stringResult.ms; +const THRESHOLD = 2; + +console.log( + `\n findIterBuf / findIter = ${ratio.toFixed(2)}x`, +); + +if (ratio > THRESHOLD) { + console.error( + `\nFAIL: findIterBuf is ${ratio.toFixed(2)}x ` + + `findIter (limit ${THRESHOLD}x).`, + ); + console.error( + "Check that the buffer path is still routed " + + "through the packed Uint32Array transport " + + "(see _findIterPackedBuf in src/lib.rs and " + + "the unpackBuf path in src/core.ts).", + ); + process.exit(1); +} + +console.log( + `OK: findIterBuf within ${THRESHOLD}x of findIter.`, +); diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index 875c867..24740b5 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -1362,6 +1362,44 @@ describe("findIterBuf", () => { const matches = ac.findIterBuf(buf); expect(matches).toHaveLength(1); }); + + test("agrees with findIter on ASCII (byte == UTF-16)", () => { + const ac = new AhoCorasick(["the", "and", "or"]); + const text = "the cat and the dog or the bird"; + const str = ac.findIter(text); + const buf = ac.findIterBuf(Buffer.from(text)); + + expect(buf).toHaveLength(str.length); + for (const [i, b] of buf.entries()) { + expect(b.pattern).toBe(str[i]!.pattern); + expect(b.start).toBe(str[i]!.start); + expect(b.end).toBe(str[i]!.end); + } + }); + + test("handles large match counts via packed transport", () => { + // Stresses the packed Uint32Array path: must + // unpack thousands of matches without losing + // any. Mismatch here indicates a transport bug. + const ac = new AhoCorasick(["the"]); + const text = "the ".repeat(10000); + const matches = ac.findIterBuf(Buffer.from(text)); + expect(matches).toHaveLength(10000); + expect(matches[0]).toEqual({ + pattern: 0, + start: 0, + end: 3, + }); + const last = matches.at(-1)!; + expect(last.pattern).toBe(0); + expect(last.end - last.start).toBe(3); + }); + + test("returns empty array when there are no matches", () => { + const ac = new AhoCorasick(["needle"]); + const matches = ac.findIterBuf(Buffer.from("haystack")); + expect(matches).toEqual([]); + }); }); describe("isMatchBuf", () => { diff --git a/package.json b/package.json index 7824125..c55cd87 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "bench:speed": "bun __bench__/speed.ts", "bench:unicode": "bun __bench__/unicode.ts", "bench:correctness": "bun __bench__/correctness.ts", - "bench:all": "bun __bench__/speed.ts && bun __bench__/unicode.ts && bun __bench__/correctness.ts", + "bench:buf": "bun __bench__/buf-vs-string.ts", + "bench:all": "bun __bench__/speed.ts && bun __bench__/unicode.ts && bun __bench__/correctness.ts && bun __bench__/buf-vs-string.ts", "bench:install": "cd __bench__ && bun install", "bench:download": "bash __bench__/download-corpus.sh" }, From ffc645cfc8eb56478a01502eac2bc92489d5d79c Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 23:22:58 +0200 Subject: [PATCH 3/3] fix(review): floor length in unpack helpers `new Array(packed.length / 3)` would throw a cryptic `RangeError` if the native side ever returned a length that is not a multiple of 3. Wrap the division in `Math.floor` so the descriptive per-triple guard fires instead. Applied to both `unpack` and `unpackBuf` to keep the unpack family consistent. Addresses gemini-code-assist review on #71. --- src/core.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core.ts b/src/core.ts index c8e3646..ab4947c 100644 --- a/src/core.ts +++ b/src/core.ts @@ -140,8 +140,13 @@ function unpack( names: (string | undefined)[] | null, ): Match[] { const len = packed.length; + // `Math.floor` is defensive: if the native side + // ever returned a length that is not a multiple of + // 3, `new Array(non-integer)` would throw a cryptic + // `RangeError` before the per-triple guard could + // surface a descriptive error. // eslint-disable-next-line unicorn/no-new-array - const matches = new Array(len / 3); + const matches = new Array(Math.floor(len / 3)); for (let i = 0, j = 0; i < len; i += 3, j++) { const idx = packed[i]; const start = packed[i + 1]; @@ -174,8 +179,13 @@ function unpack( * `text` field. */ function unpackBuf(packed: Uint32Array): ByteMatch[] { const len = packed.length; + // `Math.floor` is defensive: if the native side + // ever returned a length that is not a multiple of + // 3, `new Array(non-integer)` would throw a cryptic + // `RangeError` before the per-triple guard could + // surface a descriptive error. // eslint-disable-next-line unicorn/no-new-array - const matches = new Array(len / 3); + const matches = new Array(Math.floor(len / 3)); for (let i = 0, j = 0; i < len; i += 3, j++) { const idx = packed[i]; const start = packed[i + 1];