mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-04 00:48:13 +08:00
fix ZXing wasm mem leak problem
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user