Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3cfbdd9
test: gate libjpeg-turbo-8bit suite on built dist so build-less runs …
sedghi Jul 7, 2026
fff5057
fix(dicom-codec): free wasm decoder/encoder instances on error path
sedghi Jul 7, 2026
1b72d13
fix(openjpeg): bounds-check BufferStream skip/write/seek callbacks
sedghi Jul 7, 2026
c293e39
fix(openjpeg): reject unsupported component counts, short buffers, an…
sedghi Jul 7, 2026
eab8b32
fix(codecs): overflow-check decoded-buffer sizing on wasm32 (openjph,…
sedghi Jul 7, 2026
0f609bd
fix(libjpeg-turbo-12bit): correct decode buffer sizing, wire dispatch…
sedghi Jul 7, 2026
ace5cf9
test(libjpeg-turbo-12bit): accept graceful recovery on truncated input
sedghi Jul 7, 2026
ffd155e
ci: pin codspeed bench runner and action version for stable baseline …
sedghi Jul 7, 2026
6943d76
fix(libjpeg-turbo-12bit): return 12-bit samples as Uint16Array, not c…
sedghi Jul 7, 2026
3179998
fix(openjpeg): free codec and stream handles on component-count rejec…
sedghi Jul 7, 2026
c9dc902
test: verify exact pixel output for every codec + fail CI on silently…
sedghi Jul 7, 2026
6b04753
tools: from-scratch decoders that verify all RAW pixel references
sedghi Jul 7, 2026
110fa1f
fix: fail closed on multi-component 12-bit JPEGs and failed J2K encodes
sedghi Jul 7, 2026
3280dcc
ci: single run per PR commit + CPU logging to tame CodSpeed env warnings
sedghi Jul 7, 2026
30d160a
ci: add walltime instrument on CodSpeed macro runners alongside simul…
sedghi Jul 7, 2026
80fbe1d
ci: provision node 22 in the emsdk build container
sedghi Jul 7, 2026
d93b80f
ci: serialize simulation benches; gate walltime job behind repo variable
sedghi Jul 7, 2026
c20d46f
ci: dist-size regression gate against committed baseline
sedghi Jul 7, 2026
d03c65c
ci: suppress vitest RPC-timeout exit noise in simulation benches
sedghi Jul 7, 2026
35e8074
ci: move simulation RPC-noise suppression into vitest configs
sedghi Jul 7, 2026
1b00da1
ci: match legacy 'instrumentation' runner-mode string for RPC-noise s…
sedghi Jul 8, 2026
c267e8c
test: color and bit-depth decode matrix across all codecs (plans 034/…
sedghi Jul 8, 2026
67a8e5b
ci: toolchain-only changes trigger the full pipeline (plan 033)
sedghi Jul 8, 2026
2e7b8d7
test: exercise the untested wasm and dispatcher API surface (plan 036)
sedghi Jul 8, 2026
e8d29cb
test: encoder quality pinning + bench coverage gaps (plan 037, absorb…
sedghi Jul 8, 2026
8794920
test: wasm heap-stability assertions per codec (plan 039)
sedghi Jul 8, 2026
d9ba7b9
test: browser smoke-decode for every wasm build variant (plan 038)
sedghi Jul 8, 2026
6769ccc
test: silence wasm stdout/stderr in decoder benches to improve measur…
sedghi Jul 8, 2026
9b968ea
ci(dist-size): print every tracked artifact, marking unchanged files …
sedghi Jul 8, 2026
6c46189
bench: batch microsecond-scale bench bodies to the millisecond range
sedghi Jul 8, 2026
538e335
fix: decode 32-bit pixel data as int per pixelRepresentation, float o…
sedghi Jul 8, 2026
ff46061
chore: remove accidentally committed profiling artifacts
sedghi Jul 8, 2026
472089f
test: keep this PR to tests that pass against unmodified sources
sedghi Jul 8, 2026
a53a394
test: measure the tests-only split against this PR's actual base (fix…
sedghi Jul 8, 2026
a8fbc9d
test: classify against main — all fix PRs consolidate into one follow-up
sedghi Jul 8, 2026
aaa5154
ci: gate CI on browser-smoke and fixture-verification tooling changes
sedghi Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 312 additions & 40 deletions .github/workflows/pr-checks.yml

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions BENCHMARKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ what the numbers mean, why they don't match real wall-clock time, and how
to read warnings from the CodSpeed dashboard.

Bench files live under `packages/*/bench/*.bench.js` and are driven by
`vitest bench` + `@codspeed/vitest-plugin@^4`. The full pipeline is in
`.github/workflows/pr-checks.yml` (job: `codspeed-bench`).
`vitest bench` + `@codspeed/vitest-plugin@^5`. The full pipeline is in
`.github/workflows/pr-checks.yml` (jobs: `codspeed-bench` and
`codspeed-walltime`).

## TL;DR

Expand All @@ -21,10 +22,10 @@ Bench files live under `packages/*/bench/*.bench.js` and are driven by
inflates 30–100× vs production V8; for wasm decode kernels it's ~5–15×;
for pure compute it's roughly 1×.

## Why simulation, not walltime
## Two instruments: simulation is the gate, walltime is the reality check

CodSpeed has two instruments: `simulation` (Cachegrind) and `walltime`
(real CPU, statistical sampling on macro-runners).
(real CPU, statistical sampling on macro-runners). We run **both**:

| | Simulation | Walltime |
|---|---|---|
Expand All @@ -36,9 +37,22 @@ CodSpeed has two instruments: `simulation` (Cachegrind) and `walltime`
| Honest about JIT tier-up | No (depends on tier at bench time) | Yes |
| Good for | Regression detection on a PR | Absolute production-like timing |

We chose simulation because regression detection is the primary goal —
catching "this PR slowed openjpeg by 5%" matters more than knowing the
exact ms a user's browser will take.
Simulation is the **blocking gate** because regression detection is the
primary goal — catching "this PR slowed openjpeg by 5%" matters more than
knowing the exact ms a user's browser will take, and simulation's <1%
determinism flags small algorithmic slips that walltime noise would hide.

Walltime runs as a **second, advisory instrument** (`codspeed-walltime`
job, `continue-on-error: true`) on CodSpeed macro runners — 16-core ARM64
bare-metal machines. It covers simulation's two blind spots: real-time
effects (branch prediction, actual caches) that instruction counting
models away, and the pure-JS packages (`little-endian`/`big-endian`)
where the no-JIT simulation model is furthest from production V8.
Walltime benches run packages sequentially (`--concurrency 1`) because
parallel processes contend for cores and add noise; simulation is immune
to contention so it keeps `--parallel`. Note the macro runners are ARM64:
walltime numbers are real milliseconds, but on different silicon than
most x86 production traffic.

## How the numbers get inflated

Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
"packages/*"
],
"devDependencies": {
"@codspeed/vitest-plugin": "^4.0.1",
"@vitest/coverage-v8": "^2.1.8",
"@codspeed/vitest-plugin": "^5.7.1",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^14.1.0",
"lerna": "^8.0.0",
"vitest": "^2.1.8"
"vitest": "^3.2.4",
"playwright-core": "^1.49.0"
},
"engines": {
"node": ">=18"
},
"scripts": {
"build:all": "lerna run build:ci --parallel --stream",
"test": "vitest run",
"test:all": "lerna run test:ci --parallel --stream",
"bench": "lerna run bench --parallel --stream",
"bench:ci": "lerna run bench --parallel --stream",
Expand Down
10 changes: 8 additions & 2 deletions packages/big-endian/bench/decode.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ describe("big-endian decode (byte-swap)", () => {
decode({ bitsAllocated: 16, pixelRepresentation: 1 }, data16)
})

// The 16-bit benches above do real per-pixel swap work and are left
// unbatched. The 8-bit path is a bare property assignment, so a single
// call is swamped by fixed harness overhead and cache-model variation
// across runner CPUs; batch x100 so the body is measurable.
const data8 = makeBuffer(SIZE_512x512)
bench("8-bit passthrough, 512x512", () => {
decode({ bitsAllocated: 8 }, data8)
bench("8-bit passthrough, 512x512 x100", () => {
for (let i = 0; i < 100; i++) {
decode({ bitsAllocated: 8 }, data8)
}
})
})
12 changes: 12 additions & 0 deletions packages/big-endian/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import codspeedPlugin from "@codspeed/vitest-plugin"
export default defineConfig({
plugins: [codspeedPlugin()],
test: {
// Under the CodSpeed simulation instrument the entire process runs ~60x
// slower under valgrind while vitest's hard-coded 60s worker-RPC timer
// counts real seconds, so large bench suites structurally hit "Timeout
// calling onTaskUpdate" AFTER their benches complete and upload. Ignore
// that exit-code noise in simulation only; walltime and test runs stay
// strict.
// (CODSPEED_ENV is set whenever the CodSpeed runner is active; the
// mode string is "instrumentation" on older runners and "simulation"
// on newer ones, so match anything except walltime.)
dangerouslyIgnoreUnhandledErrors:
process.env.CODSPEED_ENV !== undefined &&
process.env.CODSPEED_RUNNER_MODE !== "walltime",
name: "big-endian",
include: ["test/**/*.test.js"],
benchmark: {
Expand Down
23 changes: 16 additions & 7 deletions packages/charls/bench/decode.bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ let coldEnc
let warmEnc
if (!skip) {
const factory = (await import(distPath)).default ?? (await import(distPath))
codec = await factory()
// Silence wasm stdout/stderr so vitest's console interception never runs
// inside a measured bench body (see openjphjs bench for details).
codec = await factory({ print: () => {}, printErr: () => {} })

// Cold instances: one per fixture, constructed but never decoded.
// The bench body will be the first decode call on this instance.
Expand All @@ -86,14 +88,21 @@ if (!skip) {
}

describe.skipIf(skip)("charls JPEG-LS (wasm)", () => {
bench("instantiate+destroy JpegLSDecoder", () => {
const d = new codec.JpegLSDecoder()
d.delete()
// Batched x50: a single instantiate+destroy is ~60 µs, where fixed harness
// overhead and cache-model variation across runner CPUs dominate; the loop
// puts the body in the ms range so the codec work is the signal.
bench("instantiate+destroy JpegLSDecoder x50", () => {
for (let i = 0; i < 50; i++) {
const d = new codec.JpegLSDecoder()
d.delete()
}
})

bench("instantiate+destroy JpegLSEncoder", () => {
const e = new codec.JpegLSEncoder()
e.delete()
bench("instantiate+destroy JpegLSEncoder x50", () => {
for (let i = 0; i < 50; i++) {
const e = new codec.JpegLSEncoder()
e.delete()
}
})

bench("decode CT1.JLS (.80 lossless, 512x512x16bit) — cold", () => {
Expand Down
33 changes: 27 additions & 6 deletions packages/charls/test/decode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,24 @@ const distDir = resolve(__dirname, "../dist")
const fixturesDir = resolve(__dirname, "fixtures")

const ct1Encoded = readFileSync(resolve(fixturesDir, "CT1.JLS"))
// CT1.RAW is the lossless reference for CT1.JLS. The same slice ships as
// openjpeg's CT1.RAW and openjphjs's CT1.RAW; charls, openjpeg and openjph
// all decode their CT1 fixtures to these exact bytes, so the three codecs
// cross-validate each other.
const ct1Raw = readFileSync(resolve(fixturesDir, "CT1.RAW"))
const ct2Encoded = readFileSync(resolve(fixturesDir, "CT2.JLS"))
const ct2Raw = readFileSync(resolve(fixturesDir, "CT2.RAW"))
// CT-512x512-near-lossless.JLS is a real .81 (JPEG-LS Lossy / Near-Lossless)
// payload extracted from a Cornerstone3D test DICOM. Decoding exercises the
// same charls codec but verifies the near-lossless code path (NEAR > 0).
// Near-lossless decoding is deterministic, so the decoder's own output is
// pinned as a regression golden (identical across all three build variants).
const ctNearLossless = readFileSync(
resolve(fixturesDir, "CT-512x512-near-lossless.JLS")
)
const ctNearLosslessRaw = readFileSync(
resolve(fixturesDir, "CT-512x512-near-lossless.RAW")
)

async function loadModule(modulePath) {
const mod = await import(modulePath)
Expand All @@ -37,11 +47,17 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist })
if (isBuilt) codec = await loadModule(path)
})

// In CI a missing dist means the build/artifact pipeline broke; fail loudly
// instead of letting every skipIf() below silently skip the suite.
it.runIf(process.env.CI)("dist is present in CI", () => {
expect(isBuilt, `${dist} missing — build artifact was not replayed`).toBe(true)
})

it.skipIf(!isBuilt)("exposes a version string", () => {
expect(typeof codec.getVersion()).toBe("string")
})

it.skipIf(!isBuilt)("decodes CT1.JLS to a 512x512 16-bit monochrome frame", () => {
it.skipIf(!isBuilt)("decodes CT1.JLS to bytes matching CT1.RAW (lossless)", () => {
const decoder = new codec.JpegLSDecoder()
decoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
decoder.decode()
Expand All @@ -53,7 +69,8 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist })
expect(frameInfo.componentCount).toBe(1)

const decoded = decoder.getDecodedBuffer()
expect(decoded.length).toBe(512 * 512 * 2)
expect(decoded.length).toBe(ct1Raw.length)
expect(Buffer.from(decoded).equals(ct1Raw)).toBe(true)

decoder.delete()
})
Expand All @@ -71,7 +88,7 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist })
})

it.skipIf(!isBuilt)(
"decodes a near-lossless CT JLS (transfer syntax .81)",
"decodes a near-lossless CT JLS (transfer syntax .81) matching the pinned golden output",
() => {
const decoder = new codec.JpegLSDecoder()
decoder.getEncodedBuffer(ctNearLossless.length).set(ctNearLossless)
Expand All @@ -84,7 +101,8 @@ describe.each(buildVariants)("charls JPEG-LS decode — $name", ({ path, dist })
expect(frameInfo.componentCount).toBe(1)

const decoded = decoder.getDecodedBuffer()
expect(decoded.length).toBe(512 * 512 * 2)
expect(decoded.length).toBe(ctNearLosslessRaw.length)
expect(Buffer.from(decoded).equals(ctNearLosslessRaw)).toBe(true)
decoder.delete()
}
)
Expand Down Expand Up @@ -114,8 +132,11 @@ describe.each(encoderVariants)(
encoder.setNearLossless(0)
encoder.encode()
const encoded = encoder.getEncodedBuffer()
expect(encoded.length).toBeGreaterThan(0)
expect(encoded.length).toBeLessThan(ct2Raw.length)
// Compression-quality regression bounds. Measured 115504 bytes on
// 2026-07-07 (emsdk 3.1.74): the ceiling catches an encoder that
// regresses compression ratio; the floor catches silent truncation.
expect(encoded.length).toBeGreaterThan(115504 * 0.5)
expect(encoded.length).toBeLessThan(115504 * 1.10)

const decoder = new codec.JpegLSDecoder()
decoder.getEncodedBuffer(encoded.length).set(encoded)
Expand Down
Binary file not shown.
Binary file added packages/charls/test/fixtures/CT1.RAW
Binary file not shown.
Binary file added packages/charls/test/fixtures/CT2-gray16u.jls
Binary file not shown.
Binary file added packages/charls/test/fixtures/CT2-gray8.jls
Binary file not shown.
Binary file not shown.
87 changes: 87 additions & 0 deletions packages/charls/test/heap-stability.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 isBuilt = existsSync(resolve(distDir, "charlswasm.js"))

async function loadModule(path) {
const mod = await import(path)
const factory = mod.default ?? mod
return await factory()
}

// Emscripten heaps grow but never shrink, so a native-side leak shows up as
// monotonic HEAP8 growth across repeated use — invisible to single-decode
// tests, an OOM after thousands of frames in a viewer. This repo has a
// history of exactly that class of bug (error-path instance leaks, encoder
// handle leaks).
//
// Loop sizing matters: the arena starts at 50 MiB (INITIAL_MEMORY) with
// ALLOW_MEMORY_GROWTH, so a leak only becomes visible once the leaked
// instances exceed the arena's free slack. Measured: 100 leaked decoder
// instances grow HEAP8 by ~22 MiB — comfortably detectable — while 100
// correct decode/delete cycles leave capacity byte-identical. (Verified by
// deleting the delete() call: the assertion fails.)
describe("charls wasm heap stability", { timeout: 120000 }, () => {
let codec
const encoded = readFileSync(resolve(fixturesDir, "CT1.JLS"))
const raw = readFileSync(resolve(fixturesDir, "CT2.RAW"))

beforeAll(async () => {
if (isBuilt) codec = await loadModule("../dist/charlswasm.js")
})

it.skipIf(!isBuilt)("repeated decode/delete cycles do not grow the heap", () => {
const decodeOnce = () => {
const decoder = new codec.JpegLSDecoder()
decoder.getEncodedBuffer(encoded.length).set(encoded)
decoder.decode()
decoder.delete()
}
for (let i = 0; i < 10; i++) decodeOnce()
const settled = codec.HEAP8.length
for (let i = 0; i < 100; i++) decodeOnce()
expect(codec.HEAP8.length).toBe(settled)
})

it.skipIf(!isBuilt)("repeated encode/delete cycles do not grow the heap", () => {
const encodeOnce = () => {
const encoder = new codec.JpegLSEncoder()
encoder.getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 16, componentCount: 1 }).set(raw)
encoder.setNearLossless(0)
encoder.encode()
encoder.delete()
}
for (let i = 0; i < 10; i++) encodeOnce()
const settled = codec.HEAP8.length
for (let i = 0; i < 60; i++) encodeOnce()
expect(codec.HEAP8.length).toBe(settled)
})

it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => {
// Garbage (not truncated-after-valid-header) input: fails fast at
// header parse instead of spending seconds in sample recovery, so the
// loop can be large enough for a leak to exceed the arena slack.
const garbage = new Uint8Array(64)
for (let i = 0; i < garbage.length; i++) garbage[i] = (i * 37 + 11) % 256
const failOnce = () => {
const decoder = new codec.JpegLSDecoder()
decoder.getEncodedBuffer(garbage.length).set(garbage)
try {
decoder.decode()
} catch {
// expected for malformed input
}
decoder.delete()
}
for (let i = 0; i < 10; i++) failOnce()
const settled = codec.HEAP8.length
for (let i = 0; i < 100; i++) failOnce()
expect(codec.HEAP8.length).toBe(settled)
})
})
Loading
Loading