jsColorEngine docs: ← Project README · Performance · Roadmap · Deep dive · Examples · API: Profile · Transform · Loader
Don't trust our numbers — run them yourself. Figures on this page are performance at the time of writing.
samples/bench/ is a fully self-contained, zero-
upload, in-browser benchmark. Clone the repo, start a tiny local
static server, open http://localhost:8080/samples/bench/, and the page runs every
lutMode × every direction × the real lcms-wasm library right there
on your hardware. This page and
BenchResults.md are the canonical numbers.
deepdive/Performance.md is the
measurement retrospective. You can reproduce (or disagree with) the
figures on your own CPU / browser in about a minute.
No telemetry, no upload — everything runs locally. Your browser is the only thing measured.
From the repo root:
# 1. Build the browser (UMD) bundle. Re-run after any src/ change.
npm run browser
# 2. Ensure the vendored lcms-wasm dist is present (npm package output):
# samples/lcms-wasm-dist/lcms.js + lcms.wasm
# and ICCs under samples/profiles/ (CoatedGRACoL2006.icc, AdobeRGB1998.icc).
# 3. Start the static server. Zero deps, just Node's built-in http.
npm run serve
# or: node samples/serve.js
# or: node samples/bench/serve.js
# or: node samples/serve.js --port=9000Open http://localhost:8080/samples/bench/ in your browser. The page boots, fetches the CMYK + AdobeRGB profiles, loads lcms-wasm, and lights up the run buttons.
Stop the server with Ctrl+C in the terminal.
| Tab | What it tells you | When to reach for it |
|---|---|---|
| Full comparison | Steady-state MPx/s for every mode × every direction, with summary cards for the Accuracy and Image use-cases | The headline test — this is the tab that produces the numbers quoted in Performance.md |
| Accuracy sweep | ΔE76 round-trip (Lab → device → Lab) through the full-float jsce pipeline, for both matrix (RGB) and 4D-tetra (CMYK) kernels | When you want to see how accurate the accuracy path really is, independent of speed |
| JIT warmup curve | Per-iter ms plotted across N iterations, showing the V8 Ignition → Sparkplug → TurboFan tier-up ramp | When you suspect your results are polluted by warmup, or just want to see the tier-up visually |
| Pixel-count sweep | Same direction × mode swept from 4 K to 4 M pixels per batch — shows throughput as the working set outgrows L1 / L2 / L3 | When the "headline MPx/s" feels suspiciously good and you want to see memory-bandwidth effects |
| Pool demo | Three photographs at different sizes through transformImages() — wall time, per-image compute, fragment counts, finish order, sequential baseline |
When you want to see the Web Worker pool actually run in this tab |
| Speed vs Noise | Why we add grain: a pinch of noise stabilises MPx/s so a clean photo and a solid stop swinging with cache locality | When you want to see why headline content is photo with 5 % noise added, on this machine (a Mac will differ) |
| About this bench | Methodology essay — modes, directions, LUT-column derivation, reproducibility notes | First stop if you want to understand why a number is what it is |
The headline test. Runs every direction × every mode and writes a row per cell.
| Direction | Profiles | Why this pair |
|---|---|---|
| RGB → RGB | sRGB → AdobeRGB1998 |
Matrix + 1D curves on both sides; pure linear algebra. Must not be sRGB → sRGB — see the identity gotcha below |
| RGB → CMYK | sRGB → GRACoL2006_Coated1v2 |
Matrix-shaper on input, 3D tetrahedral LUT on output. A real print-production workload |
| CMYK → RGB | GRACoL → sRGB |
4D tetrahedral on input, matrix-shaper on output |
| CMYK → CMYK | GRACoL → GRACoL |
Same profile both sides, but not an identity — AToB and BToA tags aren't mathematical inverses (intent, BPC, quantisation break the symmetry). Measured ~25 MPx/s on lcms-wasm vs ~165 MPx/s on an sRGB→sRGB passthrough, so we know it's real work |
Headline content is photo with 5 % noise added — the strawberries
frame (jacek-dylag-559115) tiled to the buffer, then 5 % high-bit
grain. Past ~3 % grain every starting content collapses onto the same
plateau; 5 % sits on it without walking as far into noise. The dropdown
also has photo with 15 % noise added (deep plateau),
solid (one colour — algorithm bound), photo (clean strawberries —
locality), noise (high-bit LCG), and legacy (do not quote).
CMYK workflows use a GRACoL separation of the same frame, not RGB
stuffed into four channels. The old generator (seed * k + c then
seed & 0xff) produced ~105 colours — L1-resident, labelled noise.
See benchmark.md.
Run the bench yourself — headline content is photo with 5 % noise added. The Speed vs Noise tab is why: a pinch of grain puts every starting picture on the same plateau. Photo with 15 % noise added sits on that plateau; legacy is L1-flattering and must not be quoted.
| Mode | What it is |
|---|---|
jsce no-LUT (f64) |
buildLut: false and wasmMatrixShaper: false. Full per-stage f64 pipeline. The accuracy configuration. For lcms-wasm NOOPTIMIZE, see the row below — it is not f64 in the wasm build we ship. |
jsce matrix-shaper |
RGB→RGB only. Same pair, no CLUT: Kernel3D yields the fused curve + 3×3 + curve kernel. Used to hide inside the no-LUT row when wasmMatrixShaper defaulted to 'auto'. |
jsce float |
buildLut: true + lutMode: 'float'. Float64 CLUT, tetrahedral interp in f64. Same interp math as no-LUT but the pipeline pre-collapses to a LUT so it's much faster |
jsce int |
u16 CLUT (Q0.16 weights), int32-specialised tetrahedral kernel via Math.imul. v1.1 default. Bit-exact vs the float path on 8-bit I/O |
jsce int-wasm-scalar |
Same int math compiled to WebAssembly. ~1.4× over int on 3D, ~1.2× on 4D |
jsce int-wasm-simd |
Channel-parallel WASM SIMD. ~3.0–3.5× over int on 3D, ~2.1–2.6× on 4D. Falls back to scalar if your browser lacks WASM SIMD |
lcms default |
LittleCMS 2.16 compiled to wasm32, flags = 0. What every real-world lcms app uses — lcms picks the precalc LUT grid from the input channel count. Pinned heap buffers |
lcms HIGHRES |
Same, with cmsFLAGS_HIGHRESPRECALC. Diagnostic only — per upstream (#6) this is a legacy lcms 1.x emulation flag, not an accuracy upgrade; lcms default is the representative number (the two measure within ±1.5 %) |
lcms NOOPT |
cmsFLAGS_NOOPTIMIZE — no precalc LUT; each call walks the full per-pixel pipeline. In the lcms-wasm binary vendored for samples, that path is u16, not f64 (native LittleCMS is typically f64). Use for throughput comparison; use jsce no-LUT as the f64 accuracy reference. |
Each row has a Type column plus five timing numbers:
-
Type —
f64,u8, oru16, summarising the hot path at a glance: -
f64= jsceno-LUT(full f64 per-pixel pipeline) or jscefloat(f64 CLUT); -
u16= 16-bit I/O (jsceint16*, lcmsTYPE_*_16rows). This separates mixed precision rows without re-parsing the Mode name every time.
Note: lcms-wasmNOOPTonly — the Emscripten build shipped insamplesexposes only the 16 bit pipeline on the no-precalc path, (not the same as native f64 lcms) -
u8= 8-bit I/O totransformArraywith a u16 integer CLUT (jsceint/int-wasm-*, lcms 8-bit default / HIGHRES)
The five numbers are:
- LUT build — wall-clock of
new Transform(...).create(...). Includes pipeline build, integer-mirror LUT bake (forint*modes), and WASM compile / instantiate (forint-wasm-*modes). One-shot, paid once per Transform. For lcms it'scmsCreateTransform(...). - Cold 1st — the very first
transformArray()(or_cmsDoTransform) call. Captures Ignition → Sparkplug → TurboFan tier-up cost on the JS path, and first-touch page faults / cache misses on the WASM linear-memory path. Always slower than steady-state hot; the gap is "JIT warmup tax", not "the kernel is slow". - Hot ms — 200+ warmup iters (V8 stabilised), median of 5 timed batches. The number to report when quoting MPx/s.
- MPx/s —
pixelCount / 1e6 / (hotMs / 1000). - vs
int— speedup vs jsColorEngineintfor the same direction. Apples-to-apples "what does the WASM / SIMD port buy us" number; matches the figures in Performance.md.
The rightmost column in each row is a bar normalised per direction so the fastest mode for that direction fills the bar — easy at-a-glance ranking.
Four cards call out the best-of-direction numbers for the two canonical use-cases:
- Accuracy · jsColorEngine — best
no-LUTMPx/s across the four directions - Accuracy · lcms-wasm — best
NOOPTIMIZEMPx/s - Image · jsColorEngine — best LUT mode (
float/int/int-wasm-*) per direction - Image · lcms-wasm — best LUT flag (
defaultorHIGHRES)
The cards pair up for direct use-case comparison: if you're doing colour-critical work, compare the two Accuracy cards; if you're processing pixels for display / export / conversion, compare the two Image cards. Raw speed is half the story — the use-case match determines whether you should care.
The "Copy markdown" button at the top right copies the full result table to the clipboard — pre-formatted for pasting into a GitHub issue, release note, or forum post. The markdown includes your browser / UA / core count / page-secure state, so the numbers are self-documenting when shared.
jsColorEngine only. Sweeps the full Lab colour space (L = 0…100,
a = −120…120, b = −120…120), converts every Lab point to a target
profile's native device space as a float object (no 8-bit / 16-bit
quantisation), then converts back and measures residual ΔE76. This is
the most accurate path the engine ships — full f64 pipeline, no LUT,
no dataFormat rounding.
Two round-trips run per click, one per math kernel:
- RGB — matrix + per-channel curves. Pure linear algebra
- CMYK — 4-input tetrahedral interpolation against the profile's BToA / AToB pipeline
In this path the per-object overhead (object allocation, whitepoint carry-over, Lab staging) dominates over the math, so both kernels typically land in the same 1–4 M round-trip/s ballpark. The raw math-kernel gap shows up much more starkly in the Full comparison tab's LUT-accelerated 8-bit image path, where per-pixel work drops low enough for the actual kernel cost to matter.
lcms-wasm is not included here. Its high-level binding exposes
8-bit and 16-bit buffer APIs but no per-object float path, so the
comparison wouldn't be like-for-like. For a pure-float lcms round-
trip you'd need cmsDoTransform with TYPE_Lab_DBL /
TYPE_CMYK_DBL buffers, not currently wired up.
Step granularity is user-selectable from 10 (~6 875 points) to 1 (~5.9 M points, slow). An "in-gamut subset only (ΔE < 2)" checkbox narrows the stats to the points that actually round-tripped cleanly, so gamut-clipping outliers don't drown the median.
One direction × one mode, run N times in a row, plot per-iter ms.
For JS modes you'll see the classic V8 ramp:
ms/iter
3 | ▒▒▒▒ Ignition (slow)
2 | ▒▒▒ Sparkplug
1 | ▒▒▒▒▒▒ TurboFan tier-up
0.5 | ─────────────── steady state
+-----------------------------------> iter
For WASM modes the curve is much flatter — compile happens once at
create() time, so by iter 1 you're already in steady state.
Useful for:
- Verifying the Cold / Hot gap reported in tab 1 is real (not noise)
- Spotting GC pauses (vertical spikes)
- Tuning warmup-iter counts for your own workload
Same direction × mode swept across pixel counts from 4 K to 4 M per batch.
The headline MPx/s in tab 1 uses 65 K (256×256) by default, which fits comfortably in L2 — that's flattering. This tab shows what happens as the buffer outgrows L2 and L3 and memory bandwidth becomes the bottleneck instead of compute. Throughput should be:
- Roughly flat for the WASM SIMD modes — memory-bandwidth-bound from the start
- Falls off gradually for the JS modes as cache misses dominate
If your numbers don't match this shape (e.g. SIMD mode falling off a cliff past 1 M), you've probably got a thermal or contention issue. Close other tabs, re-run.
Three photographs at different sizes (illustration 512×384, strawberries
1000×1000, beach 1920×1280), queued 1, 2, 3 × 5 (15 images) through
transformImages(). Headline is total MPx/s on the whole queue —
that is the parallelism. The table averages the five copies of each
photo. Also: workers, fragments, sequential baseline, cold vs hot.
Photographs live in samples/bench/images/ (copies — the release-matrix
originals are untouched). npm run browser copies the engine and worker
into samples/browser/. Without the worker the batch is still correct;
it just runs on one thread.
This tab is why headline content is photo with 5 % noise added. A clean photograph and a solid do not give the same MPx/s, and they do not travel: neighbours stay in cache on one machine and miss on another. A pinch of grain stabilises the numbers. After a few percent, every starting picture collapses onto the same plateau.
- Solid — one colour, one lookup; the machine flies.
- A photograph — neighbours are still similar, so most lookups reuse what just landed in cache.
- A pinch of grain — enough for that locality to fall away.
- Pure noise is the worst case — almost every pixel is a new colour.
The lines are how this machine treats three kernels as grain rises. A Mac will draw a different shape. After ~3 % this computer is already on the plateau — that is why the other tabs default to photo with 5 % noise added.
lcms’s extra 1-pixel memo only helps when neighbours match (the solid
cliff); NOCACHE has no cliff. Default direction is RGB → Lab; 262 K
is the interactive size, 1 M matches the published table. See
deepdive/benchmark.md.
This is the single most important methodology detail, and the one place a naïve setup lets one engine silently cheat.
When source and destination are the same profile (or any profile pair
that mathematically cancels), lcms's _cmsOptimizePipeline detects
the identity at cmsCreateTransform() time and collapses the hot
path to a byte copy. Measured impact: ~+80 % on lcms-wasm
RGB → RGB throughput, purely from the bogus passthrough. In Firefox
we saw 165 → 91 MPx/s moving from sRGB → sRGB to sRGB → AdobeRGB,
same machine, same session. That is the size of the cheat.
jsColorEngine doesn't have an equivalent identity-elision pass (it would win nothing for how people actually use it), so it was never affected by the wrong setup. But the matrix-shaper collapse is a legitimate optimisation — we just have to exercise it against a different RGB profile so the work is real.
samples/profiles/AdobeRGB1998.icc is the 560-byte reference Adobe RGB
(1998) profile. lcms-wasm doesn't export cmsCreateRGBProfile or
cmsBuildGamma, so the bench loads the ICC bytes directly rather
than synthesising the profile in memory.
The full journey on this one discovery — how we tripped over it, what the wrong number looked like, how we fixed it — lives in Performance.md § 3.1.
The Full comparison tab has an include identity checkbox (off by
default) that adds sRGB → sRGB and GRACoL → GRACoL as extra
blocks. Both engines now collapse those to a copy (jsCE as of v1.5,
lcms always). Use it to measure the memcpy ceiling on this machine;
those rows stay out of the summary cards.
pixel cache is also off by default (pixelCache: 0). Headline
photos with 5 % / 15 % noise added must not include the last-pixel
tax. Tick it for 'auto' — WASM 3–6 bind interp_*_cached; 4/5/6
also inject the accuracy-path stage. Solids and logos are the content
that wins.
Node off-vs-auto (int-wasm-simd array(), 0 vs 'auto') lives in
pixelCache.inKernel.*.
The Chrome pair stays in the README.
The LUT column in the results table is ground truth, not a guess.
For jsColorEngine we read it straight from the Transform
(xform.lut.g1 / xform.lut.inputChannels /
xform.lut.intLut.CLUT).
For lcms-wasm, which doesn't expose the precalc-LUT shape via its
public API, we mirror the exact rule from
lcms2-2.18/src/cmspcs.c :: _cmsReasonableGridpointsByColorspace:
| Input channels | default | HIGHRESPRECALC |
LOWRESPRECALC |
|---|---|---|---|
| 1 (mono) | 33 | 49 | 33 |
| 3 (RGB, Lab) | 33 | 49 | 17 |
| 4 (CMYK) | 17 | 23 | 17 |
| > 4 (HiFi) | 7 | 7 | 6 |
So the RGB → CMYK row with lcms default shows 33×33×33 u16 (grid
indexed by the 3-channel RGB input, default branch), and the
CMYK → RGB row with lcms HIGHRES shows 23×23×23×23 u16 (4-channel
CMYK input, HIGHRES branch). jsColorEngine's own grid picks happen
to line up with lcms's defaults — 33³ for RGB input, 17⁴ for
CMYK — which is convergent-evolution, not coordinated. Both engines
arrive at the same size / fidelity trade-off.
Yes. Verified by construction:
- Input and output buffers are
_malloc'd once atcreate()time and reused across every call (pinned heap buffers). - The hot path calls
_cmsDoTransform(xf, inPtr, outPtr, pixelCount)directly — the raw C export. We deliberately skiplcms.cmsDoTransform(...)(no underscore) because that high-level wrapper does_malloc+new Uint8Array(HEAPU8, …).set()+ccall+.slice()+_free ×2per call — an order-of- magnitude slowdown. We give lcms the same pinned-heap path a production app would use. - Three flag variants are tested:
flags=0,HIGHRESPRECALC, andNOOPTIMIZE. The best of the three is "lcms at its best". - Same seeded-PRNG input bytes as jsColorEngine, same
INTENT_RELATIVE_COLORIMETRIC, sameTYPE_*_8formats, same profiles, same pixel count.
Where does the speed gap come from, then? lcms-wasm is a stock
Emscripten build of LittleCMS 2.16 without -msimd128, so every
lcms kernel runs scalar regardless of host SIMD support.
jsColorEngine's int-wasm-simd path ships hand-tuned channel-
parallel v128 kernels for 3D and 4D tetrahedral interpolation.
That's a real capability gap, not a benchmark bias — the "lcms-wasm"
line in the engine-info panel says stock scalar build precisely to
flag this. (Informational, not a problem; the cell stays green when
lcms loads.)
A SIMD-compiled lcms-wasm (same source, built with
-msimd128 -O3) would close a lot of the gap. We'd welcome that
comparison if anyone wants to rebuild lcms with SIMD on and ship it
back to samples/lcms-wasm-dist/lcms.wasm
— open a PR.
- Same input bytes both sides. Seeded PRNG (seed =
0x13579bdf, LCG with the same constants asbench/mpx_summary.jsandbench/lcms-comparison/bench.js) produces identical pixel buffers for jsColorEngine and lcms-wasm. Cache effects are the same on both sides. - Same intent, same depth.
INTENT_RELATIVE_COLORIMETRIC,dataFormat: 'int8'for jsColorEngine,TYPE_*_8for lcms. - Pinned WASM heap buffers for lcms. Pre-
_malloc'd once, reused between calls — the production-realistic path (matches the design assumption that an app would allocate per-frame buffers up front). - Median of 5 timed batches after a 200-iter warmup. One GC pause in one batch doesn't poison the steady-state number.
requestAnimationFrameyielding between configs only, not inside the timed loops — keeps the browser responsive without breaking V8's hot-path optimisations.- No DOM mutation inside the timed regions. Progress and result updates happen between configs, after the timed batch closes.
- Run with the browser tab focused. Background tabs throttle
setTimeoutand (in some browsers) reduceperformance.now()precision. Both scramble the numbers. The in-page bench pauses and shows a warning if the tab loses focus; click Resume after you bring it back (hot batches that blurred mid-sample are discarded). - Close other heavy tabs / apps. CPU contention shows up directly in MPx/s.
- First run after page load includes WASM compile + profile decode. Hit "Run benchmark" twice for stable numbers — the second run hits warm caches everywhere.
- Numbers are highly reproducible (±5 %) within one browser, but can swing 30 %+ across browsers. Chrome, Firefox, and Safari each have very different V8 / SpiderMonkey / JavaScriptCore tier-up curves. Use a single browser when comparing modes.
- ARM64 / Apple Silicon runs ~1.4–1.6× faster than x86_64 at every
tier (JS
int, WASM scalar, WASM SIMD), with the biggest lift on the 4D CMYK paths that were register-saturated on x86. The prediction lived in the JIT inspection deep-dive for months before an M4 Mac mini measurement confirmed it; full numbers and analysis in Performance § 2.6. - Mobile CPUs throttle aggressively under sustained load. The "Hot iters" setting is conservative by default; bumping it on mobile can show throttling kick in (MPx/s sliding down across batches in the warmup-curve tab).
- WASM SIMD missing? The page detects via
WebAssembly.validate()of a minimal v128-using module. If your browser lacks SIMD, the engine-info panel showsWASM SIMD: NOT AVAILABLEandint-wasm-simdrows will run the scalar fallback (tagged with[int-wasm-scalar]in the Mode cell). - lcms-wasm not loaded? The bench still runs; only the lcms rows
are skipped. Add
lcms.js+lcms.wasmundersamples/lcms-wasm-dist/to enable them.
If your numbers are meaningfully different from the headline table in Performance.md or the README — faster, slower, or the ratios don't match — we want to see it.
- Run the Full comparison tab to completion on your machine.
- Click Copy markdown. The clipboard now has the full result table, pre-formatted with your browser / CPU cores / user-agent header.
- Open an issue on GitHub
with a title like
bench: <OS> / <browser> / <CPU> — <headline number>and paste the markdown into the body. If you've spotted a methodology issue rather than a perf one, note that in the title instead (bench-methodology: <what you noticed>).
We'll fold the numbers into Performance.md's § 1 caveats or open a follow-up issue to investigate. Critique of the methodology is equally welcome — if the test is broken we'd rather hear about it than keep quoting biased numbers.
- Performance.md — the numbers this bench produces, explained, with the journey + lessons around them
- Deep dive / WASM kernels — why the SIMD rows land where they do
- Deep dive / LUT modes — what each
lutModeactually does inside samples/bench/— the bench source; each file's role is documented in its header comment- Other benches shipping in the repo:
bench/mpx_summary.js(Node headline throughput),bench/jit_inspection.js(V8 op-count and deopt analysis),bench/wasm_poc/(WASM kernel matrix),bench/lcms-comparison/(Node head-to-head vs lcms-wasm),bench/lcms_c/(native-C lcms2 baseline — requires gcc / clang via WSL2 or MinGW-w64)