jsColorEngine docs: ← Project README · Bench · Performance · Roadmap · Deep dive · API: Transform
Figures on this page are from the date in the status/header. Performance at the time of writing — re-run on your machine: browser
samples/bench/(live: https://www.o2creative.co.nz/jscolorengine/samples/bench/) or Nodenode bench/mpx_summary.js. Methodology: Bench.md. Canonical tables: BenchResults.md.
LittleCMS is the goalpost. It is the 25-year reference implementation of ICC colour management, it ships inside Pillow, GIMP, Krita and most of the Linux desktop, and it is the engine jsColorEngine learned from. This page measures jsColorEngine against it on the only two axes that matter — does it produce the same colours, and how fast does it get there — and reports where v1.5 lands.
Three engines appear throughout:
| engine | what it is | why it is here |
|---|---|---|
| jsColorEngine | this library: pure-JS int kernels plus an inline WASM SIMD tier |
the thing being measured |
lcms-wasm |
LittleCMS 2.16 compiled to wasm32 via Emscripten | what a JavaScript project installs today |
| lcms native C | LittleCMS 2.18 built by gcc | the goalpost — compiled C on one core |
Every figure is one core against one core: same machine, same profiles, same input bytes, same session.
- Accuracy
- Where v1.5 sits
- Conditions
- Notes referenced from the tables
- Throughput by content
- The control bench
- Buffer size
- The pixel cache
- Not comparable — the rest of the landscape
- The two gaps, and what happened to them
- Why the numbers come out this way
- How these numbers moved
- Reproduce it
Speed means nothing if the colours differ, so this is the half to read first. The oracle is LittleCMS itself.
Image path (8-bit LUT kernels) vs the lcms oracle, at lcms's default optimisation:
| workflow | samples | bit-exact | within 1 LSB | max Δ | mean Δ |
|---|---|---|---|---|---|
| RGB → Lab | 743 | 98.4 % | 100 % | 1 LSB | 0.005 |
| RGB → CMYK | 743 | 97.9 % | 100 % | 1 LSB | 0.005 |
| CMYK → RGB | 6,571 | 99.2 % | 100 % | 1 LSB | 0.003 |
| CMYK → CMYK | 6,571 | 96.8 % | 100 % | 1 LSB | 0.008 |
Every sample on every workflow lands within one least-significant bit of LittleCMS, and the large majority are bit-identical. On named reference colours — paper white, solid black, rich black, 100 % C/M/Y, 50 % grey, skin tone, sky — agreement is exact.
Float pipeline vs an lcms native f64 oracle: worst case 0.06 ΔE76 in Lab, 1.24 LSB in 8-bit RGB, 0.04 % ink in CMYK, across 130 reference files and ~580 k samples. Accuracy deep dive.
jsCE is a faithful peer of the C implementation, not an approximation of it. Nothing in the speed section below is bought with accuracy.
(This result improved after upstream review. Our earlier oracle used
cmsFLAGS_HIGHRESPRECALC and showed a small out-of-gamut tail — 98.5 %,
max 14 LSB on CMYK→RGB. That flag is a legacy lcms 1.x emulation path,
not a higher-precision mode; against lcms's real default the
disagreement disappears.
#6.)
Measured on the photograph corpus — the realistic case. Synthetic content is reported further down as bounds, not as headlines, for reasons the content section makes plain.
1 M px per iteration, MPx/s, higher is better:
| workflow | jsCE int (pure JS) |
jsCE WASM SIMD | lcms-wasm |
lcms native C ⁽¹⁾⁽³⁾ | SIMD ÷ native |
|---|---|---|---|---|---|
| RGB → Lab | 53.7 | 119.6 | 35.9 | 65.6 | 1.82× |
| RGB → CMYK | 48.2 | 121.5 | 35.0 | 61.9 | 1.96× |
| CMYK → RGB | 43.1 | 82.0 | 25.4 | 40.0 | 2.05× |
| CMYK → CMYK | 36.8 | 81.3 | 22.9 | 35.6 | 2.28× |
| RGB → RGB (soft-proof) | 53.8 | 118.9 | 33.7 | 56.9 | 2.09× |
| RGB → RGB (matrix) ⁽⁴⁾ | 53.5 | 118.8 | 66.3 | 164.5 | 0.72× |
The four claims this page stands behind:
-
Against
lcms-wasm— the engine a JS project would otherwise install — jsColorEngine wins every workflow, by 1.8× on the matrix path and 3.2–3.6× on the LUT workflows. Even the pure-JSinttier beats it on all five LUT workflows, before any WASM is involved. -
Plain JavaScript runs in the same performance class as optimised native C. This is the result worth pausing on, because it involves no WASM at all — just V8 executing the
intkernels against lcms given its best build ⁽³⁾, on photographic content:workflow jsCE int(pure JS)lcms native C ratio CMYK → RGB 43.1 40.0 1.08× CMYK → CMYK 36.8 35.6 1.03× RGB → RGB (soft-proof) 53.8 56.9 0.95× RGB → Lab 53.7 65.6 0.82× RGB → CMYK 48.2 61.9 0.78× JavaScript lands between 0.78× and 1.08× of compiled C, and is ahead on both 4D CMYK workflows. Not "close for a scripting language" — the same class, with the gap in either direction under what a compiler-flag choice moves ⁽³⁾.
-
With the WASM SIMD tier, jsCE runs about twice single-threaded native C on LUT work — 1.8–2.3× on the photo corpus. That is width, not cleverness: four lanes at a time.
-
On matrix-shaper RGB→RGB, native C was 1.4× ahead of us at the time these runs were made, and we said so. lcms has a fused matrix path; jsCE baked the transform into a CLUT and interpolated it ⁽⁴⁾. That remedy has since shipped — a dedicated WASM SIMD kernel, measured at 331 MPx/s against 123 for the CLUT it replaces, on the same machine and on the same photo corpus. The head-to-head row above has not been re-run through this harness, so it stays as measured; treat the kernel as a change to the mechanism, not as a new ratio. See deepdive/MatrixShaperKernel.md.
Read every ratio against note ⁽⁶⁾: independent repeats of the same measurement vary by 1–2 %, so anything under about 1.1× is a tie. These figures are cross-checked against a minimal control bench that shares no measurement code.
| Date | 2026-08-19 |
| CPU | AMD Ryzen 7700X (8C/16T), single core, taskset -c 0 for native |
| jsCE / lcms-wasm host | Node v24.16.0, Windows 10 x64 |
| lcms native host ⁽¹⁾ | WSL2 Ubuntu 20.04, gcc 9.3.0 |
| lcms native CFLAGS ⁽³⁾ | -O2 for RGB-source workflows, -O3 for CMYK-source and soft-proof — lcms's best build per workflow |
| lcms2 version (native) | 2.18, MIT build, no plugins |
| lcms-wasm version | 1.0.5 (LittleCMS 2.16 → wasm32) |
| jsColorEngine version | 1.5.0 |
| Profiles | GRACoL2006_Coated1v2.icc, AdobeRGB1998.icc, virtual sRGB, virtual LabD50 |
| Intent | relative colorimetric, 8-bit in and out |
| Timing | auto-scaled to ~400 ms per batch, median of 5, one process per cell ⁽⁶⁾ |
| Photo corpus | 5 photographs, 3,939,000 px, mean adjacency 17.3 % RGB / 17.9 % CMYK |
⁽¹⁾ Host asymmetry. The JS figures run on Node under Windows; the native figures run under WSL2 on the same physical CPU. WSL2 is a virtual machine, so a few per cent of the native/JS gap could be the environment rather than the engines. Stated rather than corrected for — the alternative, a native Windows lcms build, swaps one variable for another.
⁽²⁾ jsCE has no pixel cache in any figure on this page. Its image
kernels hold no memo of the previous pixel, so nothing in the jsCE
columns benefits from repeated colours. The pixelCache option is
accuracy-path only, beta, and off by default; it is measured
separately in its own section and used nowhere
else here. Where an lcms column jumps on repetitive content and a jsCE
column does not, that is the reason.
⁽³⁾ lcms native gets its best build, per workflow. Seven CFLAGS sets
were swept before measuring. -march=native came at or near the
bottom on every workflow, and no single set wins everything: -O2
takes the RGB-source workflows, -O3 the CMYK-source ones and
soft-proof. Each row quotes whichever build was faster for that
workflow.
⁽⁴⁾ RGB → RGB (matrix) is not like the others. lcms detects a matrix-shaper pair and runs a fused matrix path with no interpolation at all — curves, a 3×3 matrix, curves. Pure arithmetic with no CLUT, which is why its throughput there is flat across every content type, why its memo cache never engages, and why it was the one row completely unmoved by the input-generator correction described in how these numbers moved — a useful control, since a path with no interpolation table has no working set to lose. jsCE bakes the same transform into a 33³ CLUT and interpolates it. This is the one workflow where that architectural choice cost us, and it is what the matrix-shaper kernel — now shipped — exists to fix: same shape as lcms's fused path, curves, a 3×3 and curves, with no CLUT and no interpolation. RGB→RGB here is sRGB→AdobeRGB1998, never sRGB→sRGB: both engines detect the identity and collapse it, which measures nothing.
⁽⁵⁾ cover = distinct input colours ÷ CLUT cells. Below 1× the
input carries fewer colours than the interpolation table has cells, so
most of the table is never read and stays resident in L1 — the
measurement then describes a working set no real image produces. This
column exists because adjacency alone missed exactly that case; see
how these numbers moved.
⁽⁶⁾ Run-to-run variance is 1–2 %, provided the warmup is long enough. Every cell is measured in its own process with its own warmup. A dedicated control bench — one image, one engine, one process, nothing else loaded — repeated five times per engine gives a spread of 0.4–2.0 % across independent processes, and lands within a few per cent of what the full matrix reports for the same image and workflow. That agreement is the evidence that the matrix harness is not itself distorting the figures; see the control below. Ratios below about 1.1× are still ties.
The content axis is really a coverage axis. This is the single most useful thing the release measurement campaign produced, so it goes before the tables. Here is what the five content generators actually contain, against a 33³ = 35,937-cell CLUT:
| content | adjacency | distinct colours | cover ⁽⁵⁾ | what it really tests |
|---|---|---|---|---|
noise |
0.0 % | 1,016,892 | 28.3× | full-table worst case — no real image does this |
photo |
13.2 % | 41,077 | 1.1× | realistic |
blocks16 |
93.8 % | 4,095 | 0.11× | memo-cache best case (Marti Maria's generator) |
gradient |
75.0 % | 256 | 0.01× | degenerate — all three channels ramp together |
solid |
100 % | 1 | 0.00× | the cache ceiling |
Only noise exercises the interpolation table, and only photo is
representative. The four synthetic rows are bounds, not results.
Random noise is the worst case and no photograph resembles it;
blocks16 is the opposite extreme. A real frame moves through colour
space in regions — sky, then foliage, then skin — so its working set is
both small and sliding, which is a property no generator here
reproduces.
That geometry, not adjacency, is what moves jsCE. Holding the pixel count and the exact 41,077-colour multiset fixed and only reordering the pixels:
| arrangement | jsCE WASM SIMD |
|---|---|
| sorted by colour (maximum locality) | 175.6 |
| natural photographic order | 111.9 |
| shuffled (locality destroyed) | 96.2 |
Reversing the measurement order reproduced this exactly, ruling out thermal drift or position in the run.
Per photograph, throughput tracks distinct-colour count. Each of the five corpus images measured on its own, RGB → Lab, 1 M px:
| image | adj % | distinct colours | cover ⁽⁵⁾ | jsCE SIMD |
|---|---|---|---|---|
| photo of a printed page | 10.3 | 6,429 | 0.18× | 120.9 |
| period illustration | 42.0 | 71,661 | 2.0× | 132.0 |
| sunflower | 14.6 | 116,552 | 3.2× | 111.7 |
| strawberries | 16.1 | 152,752 | 4.3× | 110.9 |
| beach landscape | 6.9 | 229,716 | 6.4× | 109.1 |
Ignoring the illustration — flat art at 42 % adjacency, which is really a graphic-content row — the ordering is monotonic in distinct colours and inverted against throughput: 6,429 colours → 120.9 MPx/s, 229,716 → 109.1. Adjacency predicts nothing here; the beach frame has the lowest adjacency and the most colours, and it is the slowest.
The mechanism is that a frame walks through colour space in regions — sky, then sand, then water — so the CLUT working set is small but sliding, and a frame carrying more distinct tones slides it further. Note also how narrow the real-image spread is: 109–132 MPx/s across five very different photographs, against 96–190 across the synthetic set. Real images cluster; generators do not — which is the argument for quoting a corpus rather than a generator.
The strongest result to come out of this measurement work, and the one that reframes every table above. Blending each content class toward the same noise buffer — 0 % is the untouched content, 100 % is pure noise:
| base, RGB → Lab, jsCE SIMD | 0 % noise | 2 % | 100 % |
|---|---|---|---|
from solid |
181.8 | 98.9 | 97.6 |
from gradient |
183.2 | 98.8 | 98.6 |
from photo |
119.1 | 96.0 | 97.7 |
Three starting points spanning 182 / 183 / 119 MPx/s agree to within a
few per cent once 2 % noise is added. Two per cent jitter is visually
negligible but takes solid from 1 distinct colour to ~216 and
gradient from 256 to ~23,000 — and once the working set stops fitting
in cache, it no longer matters how much worse it gets.
That inverts the obvious reading of the content tables: the clean
synthetic rows are the outliers, not the photo row. solid at 182 is
not measuring the transform, it is measuring how well one colour fits in
L1. It also explains lcms's memo cache as a cliff rather than a curve —
from solid, lcms-wasm drops 92.0 → 33.7 at one per cent noise, as
adjacency collapses from 100 % to 4.2 %.
Full analysis, and the benchmark-design rules that follow from it: deepdive/benchmark.md § 21 — Noise is the great equaliser.
Native C shows the same effect, which is what makes it a property of
CLUT interpolation rather than a jsCE quirk. In lcms's NOCACHE column,
where no memo is involved at all, RGB→Lab runs 63.3 MPx/s on noise
against 89.5 on low-coverage gradient — the same ~40 % locality spread.
Both engines are locality-sensitive; only lcms is additionally
adjacency-sensitive, through its one-pixel memo cache.
1 M px per iteration, MPx/s. lcms native is its best build ⁽³⁾;
NOCACHE columns run the identical transform with cmsFLAGS_NOCACHE,
so where a pair agrees the figure is real throughput and where it
diverges the gap is the memo cache and nothing else. jsCE carries no
memo at all ⁽²⁾.
RGB → Lab — CLUT 33³ = 35,937 cells
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 28.3× | 48.5 | 97.6 | 31.2 | 37.4 | 59.7 | 63.3 |
| photo | 13.2 | 1.1× | 53.7 | 119.6 | 35.9 | 41.2 | 65.6 | 72.5 |
| gradient | 75.0 | 0.01× | 61.5 | 185.9 | 64.3 | 48.9 | 129.8 | 89.5 |
| blocks16 | 93.8 | 0.11× | 61.0 | 188.5 | 75.8 | 48.4 | 161.1 | 90.8 |
| solid | 100 | 0.00× | 60.6 | 183.2 | 91.8 | 51.4 | 173.0 | 90.3 |
RGB → CMYK — CLUT 33³ = 35,937 cells
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 28.3× | 43.6 | 100.5 | 31.0 | 32.1 | 54.5 | 56.7 |
| photo | 13.2 | 1.1× | 48.2 | 121.5 | 35.0 | 39.0 | 61.9 | 66.5 |
| gradient | 75.0 | 0.01× | 54.2 | 181.8 | 63.6 | 48.5 | 127.5 | 79.4 |
| blocks16 | 93.8 | 0.11× | 53.4 | 184.4 | 76.3 | 46.3 | 159.1 | 79.2 |
| solid | 100 | 0.00× | 55.2 | 181.4 | 83.5 | 48.5 | 173.2 | 79.6 |
CMYK → RGB — CLUT 17⁴ = 83,521 cells
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 12.6× | 30.6 | 68.9 | 21.6 | 21.9 | 33.0 | 33.7 |
| photo | 13.3 | 0.42× | 43.1 | 82.0 | 25.4 | 24.3 | 40.0 | 39.9 |
| gradient | 75.0 | 0.00× | 36.9 | 97.0 | 50.3 | 25.4 | 96.5 | 42.4 |
| blocks16 | 93.8 | 0.05× | 38.1 | 96.8 | 69.7 | 25.0 | 139.8 | 43.3 |
| solid | 100 | 0.00× | 39.0 | 104.5 | 87.5 | 26.2 | 169.6 | 44.9 |
CMYK → CMYK — CLUT 17⁴ = 83,521 cells
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 12.6× | 27.2 | 68.1 | 19.1 | 20.3 | 28.8 | 29.6 |
| photo | 13.3 | 0.42× | 36.8 | 81.3 | 22.9 | 23.7 | 35.6 | 35.6 |
| gradient | 75.0 | 0.00× | 32.6 | 96.0 | 47.3 | 24.2 | 89.6 | 37.6 |
| blocks16 | 93.8 | 0.05× | 32.0 | 95.0 | 67.7 | 23.9 | 131.2 | 37.6 |
| solid | 100 | 0.00× | 32.1 | 102.8 | 85.0 | 24.9 | 165.1 | 39.5 |
RGB → RGB, soft-proof (sRGB→GRACoL→sRGB) — CLUT 33³ = 35,937 cells
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 28.3× | 48.2 | 97.8 | 32.3 | 36.6 | 53.2 | 56.2 |
| photo | 13.2 | 1.1× | 53.8 | 118.9 | 33.7 | 38.6 | 56.9 | 59.8 |
| gradient | 75.0 | 0.01× | 59.5 | 183.4 | 62.3 | 46.8 | 121.9 | 76.0 |
| blocks16 | 93.8 | 0.11× | 60.7 | 186.6 | 75.4 | 48.3 | 157.7 | 78.2 |
| solid | 100 | 0.00× | 61.3 | 182.6 | 91.1 | 47.4 | 174.5 | 78.9 |
RGB → RGB, matrix (sRGB→AdobeRGB1998) ⁽⁴⁾ — CLUT 33³ for jsCE; lcms uses a fused matrix path
| content | adj % | cover ⁽⁵⁾ | jsCE int |
jsCE SIMD | lcms-wasm | lcms-wasm NOCACHE | lcms native | lcms native NOCACHE |
|---|---|---|---|---|---|---|---|---|
| noise | 0.0 | 28.3× | 48.2 | 88.2 | 64.1 | 64.0 | 155.4 | 156.0 |
| photo | 13.2 | 1.1× | 53.5 | 118.8 | 66.3 | 66.2 | 164.5 | 165.4 |
| gradient | 75.0 | 0.01× | 60.1 | 184.8 | 65.8 | 65.0 | 164.9 | 164.5 |
| blocks16 | 93.8 | 0.11× | 60.5 | 189.0 | 65.8 | 65.9 | 166.4 | 165.8 |
| solid | 100 | 0.00× | 63.4 | 183.2 | 65.7 | 67.4 | 166.1 | 165.4 |
- lcms's memo cache is a bet on repetitive input, and on photographs
it loses. At 13 % adjacency it is a net loss on four of six
workflows — RGB→Lab runs 65.6 cached against 72.5 with
NOCACHE— and a tie on the other two. By 75 % adjacency it is winning decisively (129.8 against 89.5 on the same workflow), so the break-even sits somewhere between the two, which matches the ~40 % we measured independently for our own cache. Photographs sit at the wrong end of that range; flat graphic content sits at the right end. This is independent confirmation, from the reference implementation, of why ours ships off by default ⁽²⁾. - On flat graphic content lcms wins outright, and the tables say so: at 100 % adjacency native lcms reaches 165–174 MPx/s on every LUT workflow against jsCE SIMD's 96–105. If your workload is UI, charts or vector art rather than photographs, that is the honest number.
- The matrix row is flat across every content type in both lcms columns — proof that lcms's fused matrix path bypasses the memo cache entirely ⁽⁴⁾.
Correcting the input generator roughly halved our published throughput, which raises an obvious objection: how do we know the new harness is not depressing the numbers the way the old one inflated them? A harness that measures six workflows × five content classes × four engines has plenty of opportunity to disturb what it is measuring, and on this page one already has.
So the numbers are checked against a deliberately minimal control —
bench/solo_photo/: one photograph,
one engine, one process, nothing else loaded. No second engine is ever
constructed, no second content type exists, and lcms-wasm is not
imported at all, so no 300 KB WASM heap sits next to the thing being
timed. Each engine is measured in five independent processes, each
warmed for 3 s before a single sample is taken.
Strawberries frame, 1 M px, MPx/s:
| engine | RGB → Lab | RGB → CMYK | spread across processes |
|---|---|---|---|
jsCE int (pure JS) |
53.5 | 48.0 | 0.4–2.0 % |
| jsCE WASM scalar | 76.6 | 66.5 | 0.4–0.7 % |
| jsCE WASM SIMD | 115.2 | 113.7 | 0.9–1.1 % |
Two conclusions:
- The matrix harness is sound. It reports 52.7 / 110.9 for the same image and workflow where the control gives 53.5 / 115.2 — agreement to within a few per cent, from two harnesses sharing no measurement code. The halving after the generator fix was the input, not the harness.
- The variance is small once warmup is adequate. 0.4–2.0 % across fresh processes. An earlier ~10 % swing traced to a short (800 ms) warmup rather than to machine noise, which is why the control uses 3 s.
The control also fills in the tier the main tables skip: WASM scalar sits squarely between plain JS and SIMD (~1.4× over JS, ~0.6× of SIMD), which is roughly what four-lane vectorisation predicts and a useful sanity check that the SIMD figure is width rather than an artifact.
Buffer size is not really the variable — CLUT coverage is, and buffer size only matters because it controls how many distinct colours the input can carry. Noise content, so coverage rises with the buffer:
RGB → Lab, MPx/s (cover ⁽⁵⁾ in brackets):
| engine | 16 K px (0.46×) | 64 K px (1.8×) | 1 M px (28×) | 10 M px (217×) |
|---|---|---|---|---|
| jsCE WASM SIMD | 172.0 | 95.2 | 96.7 | 97.8 |
jsCE int |
58.9 | 56.1 | 48.1 | 48.5 |
| lcms-wasm NOCACHE | 49.6 | 37.1 | 37.2 | 36.7 |
| lcms native NOCACHE ⁽³⁾ | 72.6 | 55.9 | 55.5 | 55.7 |
CMYK → RGB, 17⁴ CLUT so coverage arrives later:
| engine | 16 K px (0.20×) | 64 K px (0.78×) | 1 M px (12.6×) | 10 M px (126×) |
|---|---|---|---|---|
| jsCE WASM SIMD | 82.7 | 67.3 | 68.9 | 68.3 |
jsCE int |
31.4 | 30.9 | 31.8 | 31.4 |
| lcms native NOCACHE ⁽³⁾ | 34.5 | 34.4 | 34.1 | 34.3 |
The cliff is at coverage ≈ 1×, and there is nothing after it. Below that the input cannot fill the table, part of it stays L1-resident, and every engine reads high — jsCE SIMD by 1.8× on RGB→Lab. Above it you pay full cold-table interpolation, and then a 160× range of buffer sizes (64 K → 10 M px) moves nothing at all. Both engines show the same shape, which is what makes it a property of CLUT interpolation rather than of either implementation.
Two consequences for anyone reproducing this:
- 1 M px is the right measurement size, and 64 K px is not: it under-samples a 4D CLUT at 0.78× coverage and would have flattered every CMYK workflow.
- A benchmark that quotes only a small buffer is quoting the L1 case. This also corrects an earlier version of this page, which reported buffer size as a non-issue — true only because the input then in use was degenerate enough that coverage never changed.
pixelCache memoises the accuracy path — buildLut: false, the
single-colour pipeline, not the image kernels ⁽²⁾. It is measured
against its own uncached baseline, because the accuracy path is an
order of magnitude slower than the LUT kernels and a ratio across the
two would be meaningless.
256 K px, 32-slot table, MPx/s against the same transform with the cache disabled:
| workflow | noise (0 % hits) | photo (~32 % hits) | gradient (75 %) | blocks16 (94 %) | solid (100 %) |
|---|---|---|---|---|---|
| RGB → RGB (matrix) | −18 % | +4 % | +75 % | +168 % | +235 % |
| RGB → Lab | −16 % | +8 % | +87 % | +189 % | +244 % |
| RGB → CMYK | −10 % | +12 % | +117 % | +274 % | +387 % |
| CMYK → RGB | −17 % | +3 % | +95 % | +201 % | +294 % |
| CMYK → CMYK | −18 % | +2 % | +97 % | +188 % | +274 % |
| RGB → RGB (soft-proof) | −9 % | +22 % | +155 % | +441 % | +820 % |
It is a bet on the content, and the tables say exactly what odds you are getting. When it never hits it costs 9–18 %. On the photo corpus — 32 % hit rate against only 8.6 % adjacency, so the table is catching colours that recur later, not just neighbours — it returns between +2 % and +22 %: real but small, and inside run-to-run noise for three of the six workflows. On flat graphic content it is transformative.
Two things worth pulling out:
- The longer the pipeline, the more a hit is worth. Soft-proof is the slowest workflow here (2.7–3.2 MPx/s uncached, two profile conversions deep) and it gains the most from caching at every hit rate — +22 % on photographs and +820 % on solid colour. The cache skips whatever the pipeline would have done, so it pays in proportion to what that is.
- A 32-slot table beats a single slot everywhere it matters. On the photo corpus 1 slot is a net loss on five of six workflows (it only catches immediate repeats, at 8.6 % adjacency) while 32 slots wins. On highly repetitive content 1 slot is marginally faster, having less bookkeeping — but that is the case that was already winning.
So: enable it for flat graphic and packaging work, leave it off for photography, and prefer a CMYK destination or a long chain if you are deciding on the margin. It ships opt-in, off by default, and marked beta because the kernel port is not done.
Full design, measurements and the three things building it proved wrong: deepdive/PixelCache.md.
These configurations exist and jsColorEngine has no equivalent. They are listed so the whole picture is visible here rather than discovered elsewhere, and so it is clear exactly which claim this page makes.
| configuration | jsColorEngine equivalent |
|---|---|
lcms + fast_float plugin (GPL3) — ~455 MPx/s on RGB→RGB matrix, unchanged on LUT workflows |
none — no SIMD-specialised float path |
lcms + threaded plugin (GPL3) — slices the buffer across worker threads |
none shipped — POC only, below |
| Closed-source commercial CMMs, including Marti Maria's own | none, and none possible — see below |
| GPU / other acceleration | none |
fast_float and threaded are two separate plugins, not a "plugin
pack". The distinction matters because they do unrelated things:
fast_float contains no threading code whatsoever, and threaded
contains no SIMD. Marti Maria's reported 1,200–1,600 MPx/s would need
both, so those figures are not single-core and do not belong against the
tables above.
fast_float's contribution is worth being precise about, because it is
narrower than its reputation: fused format-specific transform paths, a
float32 pipeline, and SSE2 — where the SSE2 applies to 8-bit
matrix-shaper only (fast_8_matsh_sse.c, gated behind
CMS_DONT_USE_SSE2 in the dispatch chain). That is exactly why
make fastfloat moved lcms's RGB→RGB result to ~455 MPx/s and changed
nothing on the LUT workflows. Not an anomaly in our harness — the
dispatch order in fast_float_sup.c.
lcms is measured here in the only configurations anyone can obtain and verify: the MIT core, plus optionally the two GPL3 plugins. The fastest colour engines in existence are closed-source commercial CMMs — including Marti Maria's own — and they cannot be benchmarked, audited or reproduced, by us or by a reader checking our work. They are listed above with no number attached, because quoting a figure nobody can verify would be worse than plainly admitting the gap exists.
Two structural limits belong next to that, since they bound what this project could ever claim:
- WASM SIMD is 128-bit and cannot go wider. lcms's SSE2 is also 128-bit, so on matrix-shaper the two are level. But native code can reach for AVX2 (256-bit), AVX-512 (512-bit) or a GPU, and WebAssembly has no equivalent — "relaxed SIMD" is still 128 bits. For the record, lcms2 2.18 itself uses no AVX at all, in core or plugins.
- So "faster than native C" is not a general claim and never will be. It holds against stock open-source lcms2, on LUT workflows, on one core. It says nothing about the fastest colour transform that can be written in C, and we do not intend it to.
Our ~2× on the LUT workflows is SIMD against scalar. That is not a rigged baseline — it is the fastest code lcms has for those paths, with both plugins loaded — but no SIMD-vs-SIMD comparison was possible, because no open-source SIMD tetrahedral LUT kernel exists to test against.
The two engines turn out to have vectorised exactly complementary paths:
| path | lcms SIMD | jsCE SIMD |
|---|---|---|
| 8-bit matrix-shaper | ✅ SSE2 | ❌ — baked into a CLUT ⁽⁴⁾ |
| 3D / 4D tetrahedral LUT | ❌ scalar, even with fast_float |
✅ WASM SIMD |
Each vectorised precisely the path the other left scalar — which is also why the one workflow lcms wins outright is the one where it has SIMD and we do not.
This section used to read "measured but not shipped". Both remedies have since landed, so it is kept as the record of what the comparison identified and what was done about it — but neither has been re-run through the lcms harness, so the ratios in the tables above still stand as measured. New mechanism, not a new head-to-head.
| gap measured above | remedy | result |
|---|---|---|
| matrix-shaper RGB→RGB at 0.72× native ⁽⁴⁾ — the one workflow lcms wins outright, and the one where it has SIMD and we do not | a fused matrix-shaper WASM kernel of our own | shipped — 331 MPx/s at int8, 225 at int16, on photos |
single-threaded only, where lcms has the threaded plugin |
our own multicore image path | shipped — 6.2× peak, 787 MPx/s |
Together those address the gap this comparison identifies: the first removes the mechanism that lost the only workflow where native C leads, and the second removes the axis where lcms had an option and we had none. Whether they close it is for the next head-to-head to say — the point of this page is that claims arrive with the harness that produced them, and these two have not been through it yet.
Multicore (shipped). transformImages() runs the parallel batch
path: 6.2× peak, and 787 MPx/s peak throughput on the SIMD
kernel — output byte-identical to single-threaded in every one of the 72
cells measured. Medians of 5 isolated runs; a single parallel figure on
this hardware varies by ~25%, so nothing here is a one-off reading.
| workers | MPx/s | speedup | efficiency |
|---|---|---|---|
| 1 | 43.2 | 0.95× | 95 % |
| 4 | 169.0 | 3.70× | 93 % |
| 6 | 249.8 | 5.47× | 91 % |
| 7 | 281.0 | 6.16× | 88 % |
| 8 | 246.6 | 5.40× | 68 % |
(noise content, int kernel, sRGB→GRACoL2006, 4 MPx — full table:
pool.scaling. One worker is slower
than sequential, which is the shape to expect: it pays the copies and the
messages and gets no parallelism back. Quote the MPx/s rather than the
speedup. The absolute figure repeats to within ~3 % across runs; the
speedup divides it by a single-threaded baseline that is itself the noisiest
measurement in the bench, so the ratio inherits that variance on top of its
own. The eighth worker falling back here is within that spread, not a
ceiling — other runs put the peak at 8.)
Two findings worth recording. The copies a SharedArrayBuffer design
exists to eliminate cost only 4–7 % of a pass, so the invasive model
is probably never worth building — the experiment meant to choose
between the two models chose the cheap one. And the crossover is slice
size, not image size: below roughly 64 K px per worker the split stops
paying. When workers are unavailable the same call falls back to running
images sequentially through array(), so multicore stays an
optimisation rather than a capability.
deepdive/multicore.md.
It is also not the same design as lcms's, which matters more than the
speedup number. lcms threaded divides one buffer evenly across N
threads (_cmsThrCountSlices), spawns them for that call and joins —
one image, one static split. We break images into fragments, around
ten per worker, into a shared queue that a persistent pool pulls from;
fragments complete out of order and are reassembled by position, the
pool is shared by every Transform in the process, and each task carries
its own transform signature so a worker switches between transforms task
by task.
That is deliberate rather than incidental. An even static split is only optimal if pixels cost the same, and they do not — content moves throughput by up to 2.7×, so equal slices take unequal time and every thread waits on the slowest. A split decided before any work runs cannot correct for that; a pull queue does, without predicting anything. It also means results arrive per image as they land rather than at a join, and that work from many images and many transforms can share the same workers.
But it is not free here the way it is in C, and the difference
is memory. lcms spins up threads that share one address space, so its
CLUT is one copy however many threads read it. A JS worker is closer to
a process than a thread: nothing is shared, so the same table is
resident once per worker. A 33-point CMYK LUT is ~1.4 MB — a
Float64Array CLUT plus its Uint16Array twin — which is ~11.5 MB
across eight workers for a table lcms holds once, and the WASM kernels
add a third copy inside each worker's linear memory.
This is a real architectural disadvantage and it belongs in a comparison
that claims to measure honestly. It is bounded rather than unbounded —
each worker caches a fixed number of transforms, evicted
least-recently-used, and forgetWorkers() releases one on demand — and
pool.memoryReport() will tell you exactly what is resident. But a
reader deciding between this engine and lcms on a memory-constrained
target should know that our 5.46× costs something lcms's does not.
Both halves belong in an honest comparison: lcms's approach is simpler
and cheaper in memory, ours is more flexible and does not assume
something we measured to be false. The full accounting is in
deepdive/multicore.md.
Matrix-shaper kernel (shipped). The remedy for the one row where
native C is ahead ⁽⁴⁾. dataFormat: 'int8' and 'int16', SIMD and
scalar builds, on by default wherever there is no CLUT to displace and
opt-in over one via wasmMatrixShaper: 'prefer'. On the photo corpus,
331 MPx/s at int8 against 123 for the CLUT and 225 against 125
at int16 — and within 1 LSB of the exact pipeline, where that CLUT
reaches 25 LSB at int8 and thousands of codes at int16. In the worker
pool it stays ahead at every count but scales worse, because a faster
kernel makes the pool's fixed per-fragment cost a larger share of the
job.
deepdive/MatrixShaperKernel.md.
Pixel cache in the image kernels. WASM 3–6 kernels bind
interp_*_cached when pixelCache !== 0. Solids 3–5×; photo tax
~2–3 % (5D photo 0.84×). deepdive/PixelCache.md.
The honest explanation, in both directions: throughput here comes from specialising the inner loop, not from the implementation language.
- jsCE specialises at LUT-build time. One unrolled kernel per
(dimension × channels × dataFormat), resolved once at
create()and monomorphic at the call site. V8 compiles that to machine code comparable togcc -O3output — the JIT inspection deep dive has the disassembly. The WASM SIMD tier then does four lanes at once, which is where the multiple over native comes from: not better scalar code, wider code. - Stock lcms2 stays general. One stage-walker handling every pixel format, profile class and precision. That is exactly what you want from a reference implementation, and exactly what a compiler cannot specialise away.
- Where lcms specialises, it wins. Its fused matrix-shaper path
beats our general pipeline on RGB→RGB ⁽⁴⁾, and its
fast_floatplugin takes that to ~455 MPx/s with SSE2 while changing nothing on the LUT workflows. Each engine wins exactly where it has specialised — and the two chose opposite paths, which is why the comparison has a clean split rather than a winner.
The same lesson explains the memo cache. lcms bets on the input being repetitive; jsCE bets on the kernel being fast. On flat graphic content lcms's bet pays and ours does not; on photographs neither bet pays, which is why lcms's own cache is a net loss there and why ours ships off.
Four measurement problems surfaced while this page was being built, and each one moved figures already published here. They are listed because a benchmark page whose numbers only ever improve is not one to trust — and because three of the four were running in our favour.
1. The accuracy oracle ran on a legacy flag.
cmsFLAGS_HIGHRESPRECALC is a legacy lcms 1.x emulation path, not a
higher-precision mode. Against lcms's real default, agreement improved to
100 % within 1 LSB. Effect: our accuracy result got better.
2. -march=native was handicapping lcms. Every native figure we
previously published used it. Sweeping seven flag sets puts it at or near
the bottom on all six workflows. We now build lcms both ways and
quote its best per workflow ⁽³⁾. Effect: native lcms is faster than we
said.
3. The "random noise" generator was not random. Two defects stacked in four lines:
- Transcribing the C generator's
seed * 1103515245into JavaScript runs it in f64, past 2⁵³, silently losing the low bits and collapsing the sequence into short cycles. Measured adjacency: 21.6 % — in the row whose whole purpose is to give lcms's memo cache nothing. - Taking the low 8 bits of a linear congruential generator is the classic trap: those bits have period 256. Adjacency still reads 0.0 %, so the metric the harness printed looked perfect — but the buffer held only 256 distinct colours. Against a 35,937-cell CLUT that touches a corner of the table and leaves it in L1. The row advertised as "the hardest case" was in fact the easiest.
Both fixed (Math.imul, and bits 23–30 — ~1,016,892 distinct colours per
megapixel at the same 0.0 % adjacency). Effect: noise-row throughput
fell for both engines — jsCE's SIMD tier by roughly 45 %, native lcms
by 10–27 %. Every previously published noise figure on this page was
measured on a degenerate input. This is also why the tables now carry a
cover column ⁽⁵⁾: adjacency cannot detect this class of error.
The correction has a built-in control. If the cause really is CLUT working set, then the matrix-shaper row — pure arithmetic, no interpolation table — must not move at all, and it does not: native lcms measured 156.3 / 163.5 / 166.8 / 165.7 MPx/s across the content classes before the fix and 156.0 / 164.5 / 165.8 / 165.4 after, while RGB→Lab fell 24 % on the same change. That rules out a general slowdown or a confounded harness, and it means figures for non-CLUT paths taken on the old generator are still valid.
4. A shared benchmark harness measured the call site, not the code. Running several content rows through one long-lived process gave 59.5 MPx/s where an isolated run of the identical workflow, content and buffer size gave 75.4 — a 27 % swing caused only by which rows had already passed through that call site. V8 specialises a call site to what it has seen; five different buffers means it specialises for none, and warmup inside that process does not undo it. Every cell is now measured in its own process ⁽⁶⁾. Effect: rows are now comparable to each other and to a standalone run; before, they were not.
Every number on this page comes from one command:
cd bench/release_matrix && npm install && cd ../..
node bench/reproduce.jsThat runs all seven phases — corpus, accuracy, native lcms at both
builds, the JS content matrix, the size sweep, the per-image rows, the
pixel cache and the control bench — and writes each phase's raw output
to bench/results/<timestamp>/, alongside a conditions.md capturing
the CPU, compiler, versions and corpus that produced them. Roughly an
hour; --quick cuts it to about fifteen minutes at reduced coverage.
Nothing else should be running. These are timing measurements on a pinned core — a background build will quietly corrupt them, which is not hypothetical: a stray test run corrupted a native block during the first release run and it had to be re-measured.
Useful subsets:
node bench/reproduce.js --only accuracy # the half that matters most, ~2 min
node bench/reproduce.js --only js,solo # no gcc/WSL needed
node bench/reproduce.js --with-flags # add the CFLAGS sweep (slow)
node bench/reproduce.js --skip-native # no Linux toolchain availableThe individual harnesses can still be driven directly when you are
chasing one figure — see
bench/release_matrix/ for the six
methodology rules it enforces, bench/solo_photo/
for the control, and bench/lcms_c/ for the
native side.
If your numbers disagree with ours, open an issue with the raw output. Methodology critiques are as welcome as results — this page is in its current shape because one of them made the comparison more honest.
A contest with LittleCMS. lcms is the reference implementation this engine learned from, its author has been generous with corrections, and several of the numbers above are worse for us than what we used to publish. The comparisons exist to answer one question honestly: how fast can colour management go in JavaScript, and does it stay correct?