From ff08a167a6cad5fcd96b910e8eba3d8833cccb71 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 20 May 2026 20:30:35 -0400 Subject: [PATCH] Update to newer test benchmark --- .../dicom-codec/src/codecs/codecFactory.js | 18 +- packages/dicom-codec/src/codecs/htj2k.js | 4 +- packages/openjphjs/bench/decode.bench.js | 13 +- packages/openjphjs/extern/openjph | 2 +- packages/openjphjs/src/CMakeLists.txt | 9 +- packages/openjphjs/src/HTJ2KDecoder.hpp | 49 ++- packages/openjphjs/test/node/index.js | 172 ++++++----- packages/openjphjs/test/truncated.test.js | 287 ++++++++++++++++++ 8 files changed, 445 insertions(+), 109 deletions(-) create mode 100644 packages/openjphjs/test/truncated.test.js diff --git a/packages/dicom-codec/src/codecs/codecFactory.js b/packages/dicom-codec/src/codecs/codecFactory.js index 25fc2d5..7085a27 100644 --- a/packages/dicom-codec/src/codecs/codecFactory.js +++ b/packages/dicom-codec/src/codecs/codecFactory.js @@ -249,11 +249,20 @@ function encode(context, codecConfig, imageFrame, imageInfo, options = {}) { * @returns Object containing decoded image frame and imageInfo (current) data * */ -function decode(context, codecConfig, imageFrame, imageInfo) { +function decode(context, codecConfig, imageFrame, imageInfo, options = {}) { if (!imageFrame?.length) { throw new Error("Image frame not defined for decoding"); } - const decoderInstance = new codecConfig.Decoder(); + const reuseDecoder = options.reuseDecoder === true; + let decoderInstance; + if (reuseDecoder) { + if (!codecConfig.reusedDecoder) { + codecConfig.reusedDecoder = new codecConfig.Decoder(); + } + decoderInstance = codecConfig.reusedDecoder; + } else { + decoderInstance = new codecConfig.Decoder(); + } const { length } = imageFrame; // get pointer to the source/encoded bit stream buffer in WASM memory @@ -277,8 +286,9 @@ function decode(context, codecConfig, imageFrame, imageInfo) { // get information about the decoded image const decodedImageInfo = decoderInstance.getFrameInfo(); - // cleanup allocated memory - decoderInstance.delete(); + if (!reuseDecoder) { + decoderInstance.delete(); + } const processInfo = { duration: context.timer.getDuration(), diff --git a/packages/dicom-codec/src/codecs/htj2k.js b/packages/dicom-codec/src/codecs/htj2k.js index 8f830a0..d294c46 100644 --- a/packages/dicom-codec/src/codecs/htj2k.js +++ b/packages/dicom-codec/src/codecs/htj2k.js @@ -29,7 +29,9 @@ async function decode(imageFrame, imageInfo) { codecWasmModule, codecWrapper.decoderName, (context) => { - return codecFactory.decode(context, codecWrapper, imageFrame, imageInfo); + return codecFactory.decode(context, codecWrapper, imageFrame, imageInfo, { + reuseDecoder: true, + }); } ); } diff --git a/packages/openjphjs/bench/decode.bench.js b/packages/openjphjs/bench/decode.bench.js index 2c65bfb..101f6d5 100644 --- a/packages/openjphjs/bench/decode.bench.js +++ b/packages/openjphjs/bench/decode.bench.js @@ -8,16 +8,11 @@ // "warm" = a shared decoder/encoder that has already done 5 decode/encode // passes at module load (untimed). The bench body is the 6th+ call. // -// Important caveat for HTJ2K: cornerstone3D's decodeHTJ2K.ts:69 actually -// creates a fresh `new HTJ2KDecoder()` for every frame (a comment in -// that file notes reuse is "much slower for some reason"). So for -// HTJ2K specifically, the production-cost approximation is: +// HTJ2K production path (dicom-codec / cornerstone codecs) reuses a single +// HTJ2KDecoder across frames. Per-frame cost ≈ decode — warm. // -// per-frame cost ≈ instantiate+destroy HTJ2KDecoder + decode — cold -// -// The "warm" HTJ2K decode bench remains useful for regression detection -// on the openjph decoder kernel itself, but isn't what cornerstone3D -// actually pays per frame. +// "cold" benches still model a fresh decoder per frame for lifecycle regressions. +// "warm" benches model the reused-decoder production path. // // Bench bodies are symmetric between cold and warm — the only difference // is module-load state, so the cold/warm delta isolates first-call diff --git a/packages/openjphjs/extern/openjph b/packages/openjphjs/extern/openjph index e01c7b7..d964a6e 160000 --- a/packages/openjphjs/extern/openjph +++ b/packages/openjphjs/extern/openjph @@ -1 +1 @@ -Subproject commit e01c7b7f9e7ecbb15cf13bb45661c9a41ab7fec6 +Subproject commit d964a6e9e0f69c4dbd506171196c030ef927c28c diff --git a/packages/openjphjs/src/CMakeLists.txt b/packages/openjphjs/src/CMakeLists.txt index 83a9abe..4ab4f54 100644 --- a/packages/openjphjs/src/CMakeLists.txt +++ b/packages/openjphjs/src/CMakeLists.txt @@ -1,7 +1,12 @@ add_executable(openjphjs jslib.cpp) -target_link_libraries(openjphjs PRIVATE openjphsimd) +target_link_libraries(openjphjs PRIVATE openjph) +target_include_directories( + openjphjs + PRIVATE + ${PROJECT_SOURCE_DIR}/extern/openjph/src/core/openjph +) target_compile_options(openjphjs PRIVATE -DOJPH_ENABLE_WASM_SIMD -msimd128) target_compile_features(openjphjs PUBLIC cxx_std_11) set_target_properties( @@ -11,7 +16,7 @@ set_target_properties( -O3 \ -s WASM=1 \ --bind \ - -s DISABLE_EXCEPTION_CATCHING=1 \ + -s DISABLE_EXCEPTION_CATCHING=0 \ -s ASSERTIONS=0 \ -s MODULARIZE=1 \ -s NO_EXIT_RUNTIME=1 \ diff --git a/packages/openjphjs/src/HTJ2KDecoder.hpp b/packages/openjphjs/src/HTJ2KDecoder.hpp index dcce11a..fce92b1 100644 --- a/packages/openjphjs/src/HTJ2KDecoder.hpp +++ b/packages/openjphjs/src/HTJ2KDecoder.hpp @@ -118,10 +118,17 @@ class HTJ2KDecoder /// void readHeader() { - ojph::codestream codestream; - ojph::mem_infile mem_file; - mem_file.open(pEncoded_->data(), pEncoded_->size()); - readHeader_(codestream, mem_file); + try + { + ojph::codestream codestream; + ojph::mem_infile mem_file; + mem_file.open(pEncoded_->data(), pEncoded_->size()); + readHeader_(codestream, mem_file); + } + catch (const std::exception &e) + { + OJPH_INFO(0x00010020, "readHeader failed: %s", e.what()); + } } /// @@ -148,11 +155,18 @@ class HTJ2KDecoder /// void decode() { - ojph::codestream codestream; - ojph::mem_infile mem_file; - mem_file.open(pEncoded_->data(), pEncoded_->size()); - readHeader_(codestream, mem_file); - decode_(codestream, frameInfo_, 0); + try + { + ojph::codestream codestream; + ojph::mem_infile mem_file; + mem_file.open(pEncoded_->data(), pEncoded_->size()); + readHeader_(codestream, mem_file); + decode_(codestream, frameInfo_, 0); + } + catch (const std::exception &e) + { + OJPH_INFO(0x00010021, "decode failed (likely truncated stream): %s", e.what()); + } } /// @@ -163,11 +177,18 @@ class HTJ2KDecoder /// void decodeSubResolution(size_t decompositionLevel) { - ojph::codestream codestream; - ojph::mem_infile mem_file; - mem_file.open(pEncoded_->data(), pEncoded_->size()); - readHeader_(codestream, mem_file); - decode_(codestream, frameInfo_, decompositionLevel); + try + { + ojph::codestream codestream; + ojph::mem_infile mem_file; + mem_file.open(pEncoded_->data(), pEncoded_->size()); + readHeader_(codestream, mem_file); + decode_(codestream, frameInfo_, decompositionLevel); + } + catch (const std::exception &e) + { + OJPH_INFO(0x00010022, "decodeSubResolution failed: %s", e.what()); + } } /// diff --git a/packages/openjphjs/test/node/index.js b/packages/openjphjs/test/node/index.js index 25de3a5..da8a7c7 100644 --- a/packages/openjphjs/test/node/index.js +++ b/packages/openjphjs/test/node/index.js @@ -2,103 +2,119 @@ // SPDX-License-Identifier: MIT let openjphjs = require("../../dist/openjphjs.js") +const assert = require("assert") const fs = require("fs") +const path = require("path") -function decode(openjph, encodedImagePath, iterations = 100) { - const encodedBitStream = fs.readFileSync(encodedImagePath) - const decoder = new openjph.HTJ2KDecoder() - const encodedBuffer = decoder.getEncodedBuffer(encodedBitStream.length) - encodedBuffer.set(encodedBitStream) +const rawPath = path.resolve(__dirname, "../fixtures/raw/CT1.RAW") +const frameInfo = { + width: 512, + height: 512, + bitsPerSample: 16, + componentCount: 1, + isSigned: true, + isUsingColorTransform: false, +} + +function encodeFrame(openjph, rawBytes, imageFrame, options = {}) { + const encoder = new openjph.HTJ2KEncoder() + const decodedBytes = encoder.getDecodedBuffer(imageFrame) + decodedBytes.set(rawBytes) - // do the actual benchmark - const beginDecode = process.hrtime() - for (var i = 0; i < iterations; i++) { - decoder.decode() + if (typeof options.lossless === "boolean") { + encoder.setQuality(options.lossless, options.quantizationStep || 0) } - const decodeDuration = process.hrtime(beginDecode) // hrtime returns seconds/nanoseconds tuple - const decodeDurationInSeconds = - decodeDuration[0] + decodeDuration[1] / 1000000000 - // Print out information about the decode - console.log( - "Decode of " + - encodedImagePath + - " took " + - (decodeDurationInSeconds / iterations) * 1000 + - " ms" - ) - const frameInfo = decoder.getFrameInfo() - console.log(" frameInfo = ", frameInfo) - console.log(" imageOffset = ", decoder.getImageOffset()) - var decoded = decoder.getDecodedBuffer() - console.log(" decoded length = ", decoded.length) + encoder.encode() + const encoded = Uint8Array.from(encoder.getEncodedBuffer()) + encoder.delete() + return encoded +} +function decodeFrame(openjph, encodedBytes) { + const decoder = new openjph.HTJ2KDecoder() + const encodedBuffer = decoder.getEncodedBuffer(encodedBytes.length) + encodedBuffer.set(encodedBytes) + decoder.decode() + const decoded = Uint8Array.from(decoder.getDecodedBuffer()) + const decodedFrameInfo = decoder.getFrameInfo() decoder.delete() + return { decoded, decodedFrameInfo } } -function encode( - openjph, - pathToUncompressedImageFrame, - imageFrame, - pathToJ2CFile, - iterations = 100 -) { - const uncompressedImageFrame = fs.readFileSync(pathToUncompressedImageFrame) - console.log("uncompressedImageFrame.length:", uncompressedImageFrame.length) - const encoder = new openjph.HTJ2KEncoder() - const decodedBytes = encoder.getDecodedBuffer(imageFrame) - decodedBytes.set(uncompressedImageFrame) - //encoder.setQuality(false, 0.001); +function meanAbsoluteErrorI16(originalBytes, decodedBytes) { + assert.strictEqual( + decodedBytes.length, + originalBytes.length, + "Decoded byte length mismatch" + ) + + const original = new Int16Array( + originalBytes.buffer, + originalBytes.byteOffset, + originalBytes.byteLength / Int16Array.BYTES_PER_ELEMENT + ) + const decoded = new Int16Array( + decodedBytes.buffer, + decodedBytes.byteOffset, + decodedBytes.byteLength / Int16Array.BYTES_PER_ELEMENT + ) - const encodeBegin = process.hrtime() - for (var i = 0; i < iterations; i++) { - encoder.encode() + let absoluteErrorSum = 0 + for (let i = 0; i < original.length; i++) { + absoluteErrorSum += Math.abs(original[i] - decoded[i]) } - const encodeDuration = process.hrtime(encodeBegin) - const encodeDurationInSeconds = - encodeDuration[0] + encodeDuration[1] / 1000000000 - // print out information about the encode - console.log( - "Encode of " + - pathToUncompressedImageFrame + - " took " + - (encodeDurationInSeconds / iterations) * 1000 + - " ms" + return absoluteErrorSum / original.length +} + +function runLossyRoundTripTest(openjph, rawBytes) { + const encodedLossy = encodeFrame(openjph, rawBytes, frameInfo, { + lossless: false, + quantizationStep: 8, + }) + const { decoded, decodedFrameInfo } = decodeFrame(openjph, encodedLossy) + const mae = meanAbsoluteErrorI16(rawBytes, decoded) + + assert.strictEqual(decodedFrameInfo.width, frameInfo.width) + assert.strictEqual(decodedFrameInfo.height, frameInfo.height) + console.log(`Heavy lossy round-trip MAE: ${mae.toFixed(2)}`) + assert.ok(mae < 1500, `Heavy lossy MAE too large: ${mae}`) +} + +function runTruncatedLosslessDecodeTest(openjph, rawBytes) { + const encodedLossless = encodeFrame(openjph, rawBytes, frameInfo, { + lossless: true, + quantizationStep: 0, + }) + const truncatedSize = Math.min(10 * 1024, encodedLossless.length) + const truncatedBitstream = encodedLossless.slice(0, truncatedSize) + const { decoded, decodedFrameInfo } = decodeFrame(openjph, truncatedBitstream) + assert.ok( + decoded.length > 0, + `Expected a minimally decodable image from ${truncatedSize} bytes` ) - const encodedBytes = encoder.getEncodedBuffer() - console.log(" encoded length=", encodedBytes.length) + const mae = meanAbsoluteErrorI16(rawBytes, decoded) - if (pathToJ2CFile) { - //fs.writeFileSync(pathToJ2CFile, encodedBytes); - } - // cleanup allocated memory - encoder.delete() + assert.strictEqual(decodedFrameInfo.width, frameInfo.width) + assert.strictEqual(decodedFrameInfo.height, frameInfo.height) + console.log( + `Truncated lossless decode MAE (${truncatedSize} bytes kept): ${mae.toFixed(2)}` + ) + assert.ok(mae > 10, `Expected degradation with truncated stream, MAE: ${mae}`) + assert.ok(mae < 300, `Truncated lossless MAE too large: ${mae}`) } function main(openjph) { - decode(openjph, "../fixtures/j2c/CT2.j2c") - decode(openjph, "../../extern/OpenJPH/subprojects/js/html/test.j2c") - - encode( - openjph, - "../fixtures/raw/CT1.RAW", - { - width: 512, - height: 512, - bitsPerSample: 16, - componentCount: 1, - isSigned: true, - }, - "../fixtures/j2c/CT1.j2c" - ) + const rawBytes = fs.readFileSync(rawPath) + runLossyRoundTripTest(openjph, rawBytes) + runTruncatedLosslessDecodeTest(openjph, rawBytes) + console.log("openjphjs node tests passed") } if (typeof openjphjs !== "undefined") { - console.log("testing openjphjs...") - openjphjs().then(function (openjphwasm) { - main(openjphwasm) - }) + console.log("running openjphjs node tests...") + openjphjs().then(main) } else { - console.warn("openjphjs isn't defined"); + console.warn("openjphjs isn't defined") } diff --git a/packages/openjphjs/test/truncated.test.js b/packages/openjphjs/test/truncated.test.js new file mode 100644 index 0000000..7d4e07f --- /dev/null +++ b/packages/openjphjs/test/truncated.test.js @@ -0,0 +1,287 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "fixtures") + +const ct1Encoded = readFileSync(resolve(fixturesDir, "j2c/CT1.j2c")) +const ct1Raw = readFileSync(resolve(fixturesDir, "raw/CT1.RAW")) + +const frameInfo = { + width: 512, + height: 512, + bitsPerSample: 16, + componentCount: 1, + isSigned: true, + isUsingColorTransform: false, +} + +const TRUNCATED_BYTE_LIMIT = 10 * 1024 +const LOSSY_QUANTIZATION_STEP = 8 + +async function loadModule(modulePath) { + const mod = await import(modulePath) + const factory = mod.default ?? mod + return await factory() +} + +function meanAbsoluteErrorI16(originalBytes, decodedBytes) { + expect(decodedBytes.length).toBe(originalBytes.length) + + const original = new Int16Array( + originalBytes.buffer, + originalBytes.byteOffset, + originalBytes.byteLength / Int16Array.BYTES_PER_ELEMENT + ) + const decoded = new Int16Array( + decodedBytes.buffer, + decodedBytes.byteOffset, + decodedBytes.byteLength / Int16Array.BYTES_PER_ELEMENT + ) + + let absoluteErrorSum = 0 + for (let i = 0; i < original.length; i++) { + absoluteErrorSum += Math.abs(original[i] - decoded[i]) + } + + return absoluteErrorSum / original.length +} + +function encodeFrame(codec, rawBytes, imageFrame, options = {}) { + const encoder = new codec.HTJ2KEncoder() + encoder.getDecodedBuffer(imageFrame).set(rawBytes) + + if (typeof options.lossless === "boolean") { + encoder.setQuality(options.lossless, options.quantizationStep || 0) + } + + encoder.encode() + const encoded = Uint8Array.from(encoder.getEncodedBuffer()) + encoder.delete() + return encoded +} + +function decodeFrame(codec, encodedBytes) { + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encodedBytes.length).set(encodedBytes) + decoder.decode() + const decoded = Uint8Array.from(decoder.getDecodedBuffer()) + const decodedFrameInfo = decoder.getFrameInfo() + decoder.delete() + return { decoded, decodedFrameInfo } +} + +/** Median wall-clock ms over `samples` timed calls after `warmup` untimed iterations. */ +function medianDecodeMs(runDecode, { warmup = 2, samples = 7 } = {}) { + for (let i = 0; i < warmup; i++) runDecode() + + const times = [] + for (let i = 0; i < samples; i++) { + const t0 = performance.now() + runDecode() + times.push(performance.now() - t0) + } + + times.sort((a, b) => a - b) + return times[Math.floor(times.length / 2)] +} + +const modulePath = "../dist/openjphjs.js" +const isBuilt = existsSync(resolve(distDir, "openjphjs.js")) + +describe("openjphjs HTJ2K truncated and lossy decode", () => { + let codec + let encodedLossless + let encodedLossy + let truncatedBitstream + + beforeAll(async () => { + if (!isBuilt) return + codec = await loadModule(modulePath) + encodedLossless = encodeFrame(codec, ct1Raw, frameInfo, { + lossless: true, + quantizationStep: 0, + }) + encodedLossy = encodeFrame(codec, ct1Raw, frameInfo, { + lossless: false, + quantizationStep: LOSSY_QUANTIZATION_STEP, + }) + const truncatedSize = Math.min(TRUNCATED_BYTE_LIMIT, encodedLossless.length) + truncatedBitstream = encodedLossless.slice(0, truncatedSize) + }) + + it.skipIf(!isBuilt)( + "decodes a heavily truncated lossless bitstream with bounded error", + () => { + const truncatedSize = truncatedBitstream.length + const { decoded, decodedFrameInfo } = decodeFrame(codec, truncatedBitstream) + + expect(decoded.length).toBeGreaterThan(0) + expect(decodedFrameInfo.width).toBe(frameInfo.width) + expect(decodedFrameInfo.height).toBe(frameInfo.height) + + const mae = meanAbsoluteErrorI16(ct1Raw, decoded) + expect(mae).toBeGreaterThan(10) + expect(mae).toBeLessThan(300) + console.log( + `Truncated lossless decode MAE (${truncatedSize} bytes kept): ${mae.toFixed(2)}` + ) + } + ) + + it.skipIf(!isBuilt)("decodes a heavy lossy encode with bounded error", () => { + const { decoded, decodedFrameInfo } = decodeFrame(codec, encodedLossy) + + expect(decodedFrameInfo.width).toBe(frameInfo.width) + expect(decodedFrameInfo.height).toBe(frameInfo.height) + + const mae = meanAbsoluteErrorI16(ct1Raw, decoded) + expect(mae).toBeLessThan(1500) + console.log(`Heavy lossy round-trip MAE: ${mae.toFixed(2)}`) + }) +}) + +describe("openjphjs HTJ2K decode performance", () => { + let codec + let encodedLossless + let encodedLossy + let truncatedBitstream + + beforeAll(async () => { + if (!isBuilt) return + codec = await loadModule(modulePath) + encodedLossless = encodeFrame(codec, ct1Raw, frameInfo, { + lossless: true, + quantizationStep: 0, + }) + encodedLossy = encodeFrame(codec, ct1Raw, frameInfo, { + lossless: false, + quantizationStep: LOSSY_QUANTIZATION_STEP, + }) + const truncatedSize = Math.min(TRUNCATED_BYTE_LIMIT, encodedLossless.length) + truncatedBitstream = encodedLossless.slice(0, truncatedSize) + }) + + it.skipIf(!isBuilt)( + "full, truncated, and lossy decodes complete within expected wall-clock bounds (reused decoder)", + () => { + const fullDecoder = new codec.HTJ2KDecoder() + const truncatedDecoder = new codec.HTJ2KDecoder() + const lossyDecoder = new codec.HTJ2KDecoder() + + const fullMs = medianDecodeMs(() => { + fullDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded) + fullDecoder.decode() + fullDecoder.getDecodedBuffer() + }) + + const truncatedMs = medianDecodeMs(() => { + truncatedDecoder + .getEncodedBuffer(truncatedBitstream.length) + .set(truncatedBitstream) + truncatedDecoder.decode() + truncatedDecoder.getDecodedBuffer() + }) + + const lossyMs = medianDecodeMs(() => { + lossyDecoder.getEncodedBuffer(encodedLossy.length).set(encodedLossy) + lossyDecoder.decode() + lossyDecoder.getDecodedBuffer() + }) + + fullDecoder.delete() + truncatedDecoder.delete() + lossyDecoder.delete() + + console.log( + `Decode median ms — full CT1.j2c: ${fullMs.toFixed(2)}, truncated (${truncatedBitstream.length} B): ${truncatedMs.toFixed(2)}, lossy q=${LOSSY_QUANTIZATION_STEP}: ${lossyMs.toFixed(2)}` + ) + + // Sanity ceilings for CI runners (generous; catches hangs/regressions). + expect(fullMs).toBeLessThan(8000) + expect(truncatedMs).toBeLessThan(8000) + expect(lossyMs).toBeLessThan(8000) + + // Truncated streams carry far fewer bytes; decode should not be slower than full. + expect(truncatedMs).toBeLessThan(fullMs * 2.5) + } + ) +}) + +describe("openjphjs HTJ2K decoder reuse (memory release)", () => { + let codec + + beforeAll(async () => { + if (isBuilt) codec = await loadModule(modulePath) + }) + + it.skipIf(!isBuilt)( + "reuses one HTJ2KDecoder for 500 decodes with stable time at iterations 5, 50, and 500", + () => { + const decoder = new codec.HTJ2KDecoder() + const milestoneIterations = [5, 50, 500] + const timesAt = {} + + for (let i = 1; i <= 500; i++) { + const t0 = performance.now() + decoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded) + decoder.decode() + decoder.getDecodedBuffer() + const elapsed = performance.now() - t0 + + if (milestoneIterations.includes(i)) { + timesAt[i] = elapsed + } + } + + decoder.delete() + + console.log( + `Reused decoder decode ms — iteration 5: ${timesAt[5].toFixed(2)}, 50: ${timesAt[50].toFixed(2)}, 500: ${timesAt[500].toFixed(2)}` + ) + + const samples = [timesAt[5], timesAt[50], timesAt[500]] + const minMs = Math.min(...samples) + const maxMs = Math.max(...samples) + const ratio = maxMs / minMs + + console.log( + `Reused decoder min/max ratio at milestones: ${ratio.toFixed(2)} (min ${minMs.toFixed(2)} ms, max ${maxMs.toFixed(2)} ms)` + ) + + // Memory retained across reuse should not drive large slowdowns in this release. + expect(ratio).toBeLessThan(6) + expect(maxMs).toBeLessThan(8000) + } + ) + + it.skipIf(!isBuilt)( + "reused decoder is faster than instantiate+decode+destroy per frame", + () => { + const reusedDecoder = new codec.HTJ2KDecoder() + const t0 = performance.now() + reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded) + reusedDecoder.decode() + reusedDecoder.getDecodedBuffer() + const reusedMs = performance.now() - t0 + reusedDecoder.delete() + + const t1 = performance.now() + const fresh = new codec.HTJ2KDecoder() + fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded) + fresh.decode() + fresh.getDecodedBuffer() + fresh.delete() + const freshMs = performance.now() - t1 + + console.log( + `Single decode — reused decoder: ${reusedMs.toFixed(2)} ms, fresh decoder: ${freshMs.toFixed(2)} ms` + ) + + expect(reusedMs).toBeLessThan(freshMs) + } + ) +})