fix ZXing wasm mem leak problem

This commit is contained in:
infrost
2026-07-09 02:11:32 +01:00
parent ca56109b05
commit f892c2cb97
3 changed files with 373 additions and 37 deletions
+254
View File
@@ -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<ImageData> {
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<string, number | string> {
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);
});
+1
View File
@@ -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"
},
+118 -37
View File
@@ -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<Omit<QrDecodeSettings, 'maxSymbols'>> & {
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<T> = ZXingVector<T> & {
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<QrDecodeOptions> = {
@@ -46,7 +56,8 @@ const SINGLE_QR_DECODE_OPTIONS: Required<QrDecodeOptions> = {
maxSymbols: 1,
};
let preparePromise: Promise<unknown> | null = null;
let preparePromise: Promise<RuntimeZXingReaderModule> | null = null;
let grayscaleScratch: Uint8Array | null = null;
/**
* Decode a QR code from an `ImageData` object (e.g. from a `<canvas>`).
@@ -115,39 +126,109 @@ async function decodeImageData(
imageData: ImageData,
options: Required<QrDecodeOptions>,
): Promise<QrDecodeResult[]> {
await prepareReader();
const results = await readBarcodes(imageData, {
...READER_OPTIONS,
binarizer: options.binarizer,
const reader = await prepareReader();
return readQRCodesWithManualRelease(reader, imageData, options);
}
function prepareReader(): Promise<RuntimeZXingReaderModule> {
if (!preparePromise) {
preparePromise = prepareZXingModule({
overrides: {
locateFile: (path: string) => path.endsWith('.wasm') ? zxingReaderWasmUrl : path,
},
equalityFn: Object.is,
fireImmediately: true,
}) as Promise<RuntimeZXingReaderModule>;
}
return preparePromise;
}
function readQRCodesWithManualRelease(
reader: RuntimeZXingReaderModule,
imageData: ImageData,
options: Required<QrDecodeOptions>,
): 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<ZXingReadResult> | null = null;
try {
reader.HEAPU8.set(grayscale, bufferPtr);
results = reader.readBarcodesFromPixmap(
bufferPtr,
imageData.width,
imageData.height,
toZXingReaderOptions(options),
) as DeletableZXingVector<ZXingReadResult>;
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<QrDecodeOptions>): 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<unknown> {
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 {