From f892c2cb975eff4c9f65a6bf02a4c6a635270da5 Mon Sep 17 00:00:00 2001 From: infrost Date: Thu, 9 Jul 2026 02:11:32 +0100 Subject: [PATCH] fix ZXing wasm mem leak problem --- benchmark.soak.test.ts | 254 +++++++++++++++++++++ package.json | 1 + packages/raptorqr-core/src/qr/qr_decode.ts | 155 ++++++++++--- 3 files changed, 373 insertions(+), 37 deletions(-) create mode 100644 benchmark.soak.test.ts diff --git a/benchmark.soak.test.ts b/benchmark.soak.test.ts new file mode 100644 index 0000000..e2ffa64 --- /dev/null +++ b/benchmark.soak.test.ts @@ -0,0 +1,254 @@ +/** + * QR reader soak benchmark. + * + * Usage: + * pnpm build + * pnpm benchmark:soak + * + * This intentionally isolates the QR reader path from camera capture, UI + * progress, packet parsing, deduplication, and RaptorQ. It repeatedly decodes + * the same V30-L 2x2 composite image and reports throughput by window. + * + * Useful knobs: + * RAPTORQR_SOAK_ITERATIONS=10000 + * RAPTORQR_SOAK_WINDOW=250 + */ + +import './apps/web/src/tests/setup'; + +import { performance } from 'node:perf_hooks'; + +import { describe, expect, test } from 'vitest'; + +import { createQRTransferProfile } from '@raptorqr/core/protocol/profiles'; +import { decodeQRCodesFromCanvas } from '@raptorqr/core/qr/qr_decode'; +import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser'; + +const QR_VERSION = 30; +const ECC_LEVEL = 'L'; +const PARALLEL_QR_COUNT = 4; +const SCALE = 2; +const DEFAULT_ITERATIONS = 10_000; +const DEFAULT_WINDOW = 250; +const SOAK_TIMEOUT_MS = 600_000; + +interface SoakWindow { + start: number; + end: number; + calls: number; + decodedQrSymbols: number; + elapsedMs: number; + avgDecodeMs: number; + p95DecodeMs: number; + callsPerSecond: number; + qrSymbolsPerSecond: number; + heapUsedMb: number; + rssMb: number; + externalMb: number; +} + +function envInteger(name: string, fallback: number, min: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`${name} must be a finite number, got ${raw}`); + } + + return Math.max(min, Math.round(parsed)); +} + +function deterministicPayload(byteLength: number, seed: number): Uint8Array { + const out = new Uint8Array(byteLength); + let state = seed >>> 0; + + for (let i = 0; i < out.length; i++) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + out[i] = (state >>> 24) ^ (i & 0xff); + } + + return out; +} + +function blitImageData( + target: Uint8ClampedArray, + targetWidth: number, + source: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, +): void { + for (let row = 0; row < sourceHeight; row++) { + const sourceStart = row * sourceWidth * 4; + const sourceEnd = sourceStart + sourceWidth * 4; + const targetStart = ((y + row) * targetWidth + x) * 4; + target.set(source.subarray(sourceStart, sourceEnd), targetStart); + } +} + +function tileOffset(tileIndex: number, tileSize: number): { x: number; y: number } { + return { + x: (tileIndex % 2) * tileSize, + y: Math.floor(tileIndex / 2) * tileSize, + }; +} + +async function buildCompositeImage(): Promise { + const profile = createQRTransferProfile(QR_VERSION, ECC_LEVEL, 'fast-qr-wasm'); + const tileModules = QR_VERSION * 4 + 17 + 8; + const tileSize = tileModules * SCALE; + const width = tileSize * 2; + const height = tileSize * 2; + const composite = new Uint8ClampedArray(width * height * 4); + composite.fill(255); + + for (let tileIndex = 0; tileIndex < PARALLEL_QR_COUNT; tileIndex++) { + const payload = deterministicPayload(profile.maxPacketSize, 0x5eed_0000 + tileIndex); + const image = await renderQRCodeImageData( + payload, + QR_VERSION, + ECC_LEVEL, + SCALE, + 'fast-qr-wasm', + ); + const { x, y } = tileOffset(tileIndex, tileSize); + blitImageData(composite, width, image.data, image.width, image.height, x, y); + } + + return new ImageData(composite, width, height); +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1)); + return sorted[index]!; +} + +function mb(bytes: number): number { + return Math.round((bytes / 1024 / 1024) * 10) / 10; +} + +function round(value: number, digits = 2): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +function summarizeWindow( + start: number, + end: number, + callDurations: number[], + decodedQrSymbols: number, +): SoakWindow { + const elapsedMs = callDurations.reduce((sum, value) => sum + value, 0); + const memory = process.memoryUsage(); + + return { + start, + end, + calls: callDurations.length, + decodedQrSymbols, + elapsedMs: round(elapsedMs), + avgDecodeMs: round(elapsedMs / Math.max(1, callDurations.length)), + p95DecodeMs: round(percentile(callDurations, 0.95)), + callsPerSecond: round((callDurations.length / elapsedMs) * 1000), + qrSymbolsPerSecond: round((decodedQrSymbols / elapsedMs) * 1000), + heapUsedMb: mb(memory.heapUsed), + rssMb: mb(memory.rss), + externalMb: mb(memory.external), + }; +} + +function printableWindow(window: SoakWindow): Record { + return { + range: `${window.start}-${window.end}`, + calls: window.calls, + decodedQR: window.decodedQrSymbols, + elapsedMs: window.elapsedMs, + avgDecodeMs: window.avgDecodeMs, + p95DecodeMs: window.p95DecodeMs, + callsPerSecond: window.callsPerSecond, + qrPerSecond: window.qrSymbolsPerSecond, + heapMB: window.heapUsedMb, + rssMB: window.rssMb, + externalMB: window.externalMb, + }; +} + +describe('QR reader soak benchmark', () => { + test('repeatedly decodes the same V30-L 4-symbol frame', async () => { + const iterations = envInteger('RAPTORQR_SOAK_ITERATIONS', DEFAULT_ITERATIONS, 1); + const windowSize = envInteger('RAPTORQR_SOAK_WINDOW', DEFAULT_WINDOW, 1); + const imageData = await buildCompositeImage(); + + console.info('[bench:soak] config', { + profile: `V${QR_VERSION}-${ECC_LEVEL}`, + scale: SCALE, + image: `${imageData.width}x${imageData.height}`, + maxSymbols: PARALLEL_QR_COUNT, + iterations, + windowSize, + }); + + const windows: SoakWindow[] = []; + let currentDurations: number[] = []; + let currentDecodedSymbols = 0; + let totalDecodedSymbols = 0; + + for (let i = 0; i < iterations; i++) { + const startedAt = performance.now(); + const decoded = await decodeQRCodesFromCanvas(imageData, PARALLEL_QR_COUNT); + const elapsed = performance.now() - startedAt; + + expect(decoded, `decode call ${i + 1}`).toHaveLength(PARALLEL_QR_COUNT); + decoded.forEach((result) => { + expect(result.version).toBe(QR_VERSION); + }); + + currentDurations.push(elapsed); + currentDecodedSymbols += decoded.length; + totalDecodedSymbols += decoded.length; + + const isWindowEnd = currentDurations.length === windowSize || i === iterations - 1; + if (!isWindowEnd) continue; + + const window = summarizeWindow( + i + 2 - currentDurations.length, + i + 1, + currentDurations, + currentDecodedSymbols, + ); + windows.push(window); + console.info('[bench:soak] window', printableWindow(window)); + + currentDurations = []; + currentDecodedSymbols = 0; + } + + const first = windows[0]!; + const last = windows[windows.length - 1]!; + const totalElapsedMs = windows.reduce((sum, window) => sum + window.elapsedMs, 0); + const summary = { + iterations, + totalDecodedSymbols, + totalElapsedMs: round(totalElapsedMs), + avgCallsPerSecond: round((iterations / totalElapsedMs) * 1000), + avgQrPerSecond: round((totalDecodedSymbols / totalElapsedMs) * 1000), + firstQrPerSecond: first.qrSymbolsPerSecond, + lastQrPerSecond: last.qrSymbolsPerSecond, + lastVsFirstRatio: round(last.qrSymbolsPerSecond / first.qrSymbolsPerSecond, 3), + firstAvgDecodeMs: first.avgDecodeMs, + lastAvgDecodeMs: last.avgDecodeMs, + rssGrowthMb: round(last.rssMb - first.rssMb, 1), + externalGrowthMb: round(last.externalMb - first.externalMb, 1), + }; + + console.table(windows.map(printableWindow)); + console.info('[bench:soak] summary', summary); + + expect(totalDecodedSymbols).toBe(iterations * PARALLEL_QR_COUNT); + expect(summary.avgQrPerSecond).toBeGreaterThan(1); + }, SOAK_TIMEOUT_MS); +}); diff --git a/package.json b/package.json index 7927cb1..cc76b1b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:smoke": "vitest run smoke.test.ts --environment node", "benchmark": "vitest run benchmark.test.ts --environment happy-dom", "benchmark:final": "vitest run benchmark.final.test.ts --environment happy-dom", + "benchmark:soak": "vitest run benchmark.soak.test.ts --environment happy-dom", "test:watch": "pnpm --filter @raptorqr/web test:watch", "verify:raptorq-wasm": "pnpm --filter @raptorqr/raptorq-wasm verify:raptorq" }, diff --git a/packages/raptorqr-core/src/qr/qr_decode.ts b/packages/raptorqr-core/src/qr/qr_decode.ts index ad520d8..4097429 100644 --- a/packages/raptorqr-core/src/qr/qr_decode.ts +++ b/packages/raptorqr-core/src/qr/qr_decode.ts @@ -6,9 +6,16 @@ * package's default CDN path. */ import { + BINARIZERS, + CHARACTER_SETS, + EAN_ADD_ON_SYMBOLS, + TEXT_MODES, + encodeFormats, prepareZXingModule, - readBarcodes, - type ReaderOptions, + type ZXingReaderModule, + type ZXingReaderOptions, + type ZXingReadResult, + type ZXingVector, } from 'zxing-wasm/reader'; import { zxingReaderWasmUrl } from '@raptorqr/core/qr/zxing_assets'; import { @@ -27,17 +34,20 @@ export type QrDecodeOptions = Partial> & { maxSymbols?: number; }; -const READER_OPTIONS: ReaderOptions = { - formats: ['QRCode'], - binarizer: DEFAULT_DECODE_SETTINGS.binarizer, - tryHarder: DEFAULT_DECODE_SETTINGS.tryHarder, - tryRotate: DEFAULT_DECODE_SETTINGS.tryRotate, - tryInvert: DEFAULT_DECODE_SETTINGS.tryInvert, - tryDownscale: DEFAULT_DECODE_SETTINGS.tryDownscale, - downscaleFactor: DEFAULT_DECODE_SETTINGS.downscaleFactor, - textMode: 'Plain', +type DeletableZXingVector = ZXingVector & { + delete?: () => void; }; +type RuntimeZXingReaderModule = ZXingReaderModule & { + HEAPU8: Uint8Array; + _malloc: (size: number) => number; + _free: (ptr: number) => void; +}; + +const QR_ONLY_FORMATS = encodeFormats(['QRCode']); +const EAN_ADD_ON_IGNORE = EAN_ADD_ON_SYMBOLS.indexOf('Ignore'); +const TEXT_MODE_PLAIN = TEXT_MODES.indexOf('Plain'); +const CHARACTER_SET_UNKNOWN = CHARACTER_SETS.indexOf('Unknown'); const DEFAULT_MAX_QR_SYMBOLS = 4; const MAX_QR_SYMBOLS = 8; const SINGLE_QR_DECODE_OPTIONS: Required = { @@ -46,7 +56,8 @@ const SINGLE_QR_DECODE_OPTIONS: Required = { maxSymbols: 1, }; -let preparePromise: Promise | null = null; +let preparePromise: Promise | null = null; +let grayscaleScratch: Uint8Array | null = null; /** * Decode a QR code from an `ImageData` object (e.g. from a ``). @@ -115,39 +126,109 @@ async function decodeImageData( imageData: ImageData, options: Required, ): Promise { - await prepareReader(); - const results = await readBarcodes(imageData, { - ...READER_OPTIONS, - binarizer: options.binarizer, + const reader = await prepareReader(); + return readQRCodesWithManualRelease(reader, imageData, options); +} + +function prepareReader(): Promise { + if (!preparePromise) { + preparePromise = prepareZXingModule({ + overrides: { + locateFile: (path: string) => path.endsWith('.wasm') ? zxingReaderWasmUrl : path, + }, + equalityFn: Object.is, + fireImmediately: true, + }) as Promise; + } + return preparePromise; +} + +function readQRCodesWithManualRelease( + reader: RuntimeZXingReaderModule, + imageData: ImageData, + options: Required, +): QrDecodeResult[] { + const grayscale = rgbaToGrayscale(imageData); + const bufferPtr = reader._malloc(grayscale.byteLength); + if (!bufferPtr) { + throw new Error(`Failed to allocate ${grayscale.byteLength} bytes in WASM memory`); + } + + let results: DeletableZXingVector | null = null; + try { + reader.HEAPU8.set(grayscale, bufferPtr); + results = reader.readBarcodesFromPixmap( + bufferPtr, + imageData.width, + imageData.height, + toZXingReaderOptions(options), + ) as DeletableZXingVector; + + const decoded: QrDecodeResult[] = []; + for (let index = 0; index < results.size(); index++) { + const result = results.get(index); + if (!result?.isValid || result.symbology !== 'QRCode' || result.bytes.length === 0) { + continue; + } + decoded.push({ + bytes: new Uint8Array(result.bytes), + version: parseQRVersion(result.version, result.extra), + }); + } + return decoded; + } finally { + results?.delete?.(); + reader._free(bufferPtr); + } +} + +function rgbaToGrayscale(imageData: ImageData): Uint8Array { + const pixelCount = imageData.width * imageData.height; + const expectedLength = pixelCount * 4; + if (imageData.data.length !== expectedLength) { + throw new Error( + `ImageData size mismatch: expected ${expectedLength} RGBA bytes, got ${imageData.data.length}`, + ); + } + + if (!grayscaleScratch || grayscaleScratch.length < pixelCount) { + grayscaleScratch = new Uint8Array(pixelCount); + } + + const gray = grayscaleScratch.subarray(0, pixelCount); + const rgba = imageData.data; + for (let pixel = 0, offset = 0; pixel < pixelCount; pixel++, offset += 4) { + gray[pixel] = (306 * rgba[offset]! + 601 * rgba[offset + 1]! + 117 * rgba[offset + 2]! + 512) >> 10; + } + return gray; +} + +function toZXingReaderOptions(options: Required): ZXingReaderOptions { + return { + formats: QR_ONLY_FORMATS, tryHarder: options.tryHarder, tryRotate: options.tryRotate, tryInvert: options.tryInvert, tryDownscale: options.tryDownscale, + tryDenoise: false, + binarizer: encodeBinarizer(options.binarizer), + isPure: false, + downscaleThreshold: 500, downscaleFactor: options.downscaleFactor, + minLineCount: 2, maxNumberOfSymbols: clampMaxSymbols(options.maxSymbols), - }); - - return results - .filter((item) => item.isValid && item.symbology === 'QRCode' && item.bytes.length > 0) - .map((result) => ({ - bytes: new Uint8Array(result.bytes), - version: parseQRVersion(result.version, result.extra), - })); + validateOptionalChecksum: false, + returnErrors: false, + eanAddOnSymbol: EAN_ADD_ON_IGNORE, + textMode: TEXT_MODE_PLAIN, + characterSet: CHARACTER_SET_UNKNOWN, + tryCode39ExtendedMode: true, + }; } -function prepareReader(): Promise { - if (!preparePromise) { - preparePromise = Promise.resolve( - prepareZXingModule({ - overrides: { - locateFile: (path: string) => path.endsWith('.wasm') ? zxingReaderWasmUrl : path, - }, - equalityFn: Object.is, - fireImmediately: true, - }), - ); - } - return preparePromise; +function encodeBinarizer(binarizer: QrDecodeSettings['binarizer']): number { + const index = BINARIZERS.indexOf(binarizer); + return index >= 0 ? index : BINARIZERS.indexOf(DEFAULT_DECODE_SETTINGS.binarizer); } function parseQRVersion(version: string, extra: string): number {