Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ The current state is stable: `compare-codecs` supports six codecs (JPEG XL, AVIF

- **Rate-distortion curves by default in the HTML report.** Without `--rd`, the report's chart is still a coarse fixed-knob sweep. Making the equal-quality curves the default would drop the sweep, at the cost of one search per target instead of five fixed encodes per codec.
- **More codecs.** Extend beyond the current six as encoders mature or new ones become relevant. Low effort per codec, since the equal-quality search already generalises across quality knobs. A candidate is [jixel](https://github.com/awxkee/jixel), a second JPEG XL encoder (Rust, BSD-3-Clause, ported from libjxl-tiny) that [JPEG XL vs AVIF for web use-cases](https://jakearchibald.com/2026/jpeg-xl-vs-avif/) finds much closer to AVIF than libjxl on photographs; as an opt-in variant it would repeat the rav1e and SVT-AV1 lesson for JPEG XL. It has no CLI, so the image would build a small wrapper.
- **Encode memory.** Record each encoder's peak resident memory next to its time. Every encode already runs under `/usr/bin/time`, which reports it with `-v` (GNU) or `-l` (BSD/macOS), so it is a column in the table and the CSV rather than new machinery.
- **Tool versions in the results.** Write the encoder, decoder and metric versions into the CSV and the HTML report, so a result read outside the Docker image still says which builds produced it.
- **An independent second metric.** Optionally score every result with [ColorVideoVDP](https://github.com/gfxdisp/ColorVideoVDP), from a lab with no tie to any codec, as a cross-check on ssimulacra2 where JPEG XL and another format are close. It is a Python and PyTorch install of about 1 GB, so it would stay outside the default image.
- **Decode with platform decoders.** Measure what users actually see: decode with the platform's own decoder (Apple ImageIO through `sips`, browsers) as well as the reference decoders, and time the decode. The HEIC 4:2:0 lesson already shows a 1.5–8.4 point gap between libheif and ImageIO, and decode speed is part of the trade-off ([JPEG XL vs AVIF for web use-cases](https://jakearchibald.com/2026/jpeg-xl-vs-avif/) measures it in Firefox, Chrome and Safari).
Expand Down
22 changes: 13 additions & 9 deletions compare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,16 @@ sample images).
An ASCII table (and optional CSV), sorted by file size so the smallest wins:

```
+---------+--------------+---------+-------+-------------+---------+-------+--------+--------+-----------+-----------+
| Codec | Setting | Size | bpp | ssimulacra2 | dssim | iters | encode | cpu | enc total | cpu total |
+---------+--------------+---------+-------+-------------+---------+-------+--------+--------+-----------+-----------+
| JPEG XL | -d 1.08 | 15.5 KB | 0.826 | 90.38 | 0.00044 | 6 | 60 ms | 240 ms | 460 ms | 1.86 s |
| jpegli | -d 0.97 | 21.7 KB | 1.155 | 89.74 | 0.00050 | 7 | 48 ms | 120 ms | 390 ms | 980 ms |
| AVIF | --min/max 10 | 25.3 KB | 1.349 | 90.39 | 0.00030 | 5 | 50 ms | 150 ms | 280 ms | 840 ms |
| WebP | -q 97 | 29.8 KB | 1.589 | 89.63 | 0.00036 | 5 | 22 ms | 22 ms | 130 ms | 130 ms |
+---------+--------------+---------+-------+-------------+---------+-------+--------+--------+-----------+-----------+
+---------+-------------+---------+-------+-------------+---------+-------+--------+--------+---------+-----------+-----------+
| Codec | Setting | Size | bpp | ssimulacra2 | dssim | iters | encode | cpu | memory | enc total | cpu total |
+---------+-------------+---------+-------+-------------+---------+-------+--------+--------+---------+-----------+-----------+
| JPEG XL | -d 0.74 | 51.6 KB | 1.611 | 89.85 | 0.00027 | 7 | 60 ms | 260 ms | 48.9 MB | 760 ms | 2.95 s |
| AVIF | --min/max 8 | 53.0 KB | 1.657 | 89.99 | 0.00032 | 3 | 130 ms | 170 ms | 23.0 MB | 530 ms | 660 ms |
| jpegli | -d 0.74 | 62.9 KB | 1.966 | 89.80 | 0.00025 | 7 | 20 ms | 10 ms | 7.4 MB | 130 ms | 120 ms |
| HEIC | -q 75 | 77.4 KB | 2.420 | 90.01 | 0.00017 | 2 | 490 ms | 480 ms | 27.0 MB | 1.37 s | 1.36 s |
| WebP | -q 99 | 85.2 KB | 2.662 | 89.44 * | 0.00023 | 7 | 50 ms | 40 ms | 6.3 MB | 540 ms | 490 ms |
| mozjpeg | -quality 94 | 89.4 KB | 2.794 | 90.25 | 0.00024 | 4 | 100 ms | 100 ms | 5.6 MB | 340 ms | 330 ms |
+---------+-------------+---------+-------+-------------+---------+-------+--------+--------+---------+-----------+-----------+
```

`iters` is the number of binary-search steps taken to reach the equal-quality
Expand All @@ -159,6 +161,8 @@ the target falls in the codec's range. For codec speed compare `encode`/`cpu`; f
how much work the run spent, compare the totals. The CSV adds `total_encode_wall_ms`
and `total_encode_cpu_ms`.

`memory` is the encoder's peak resident memory during that single encode, as `/usr/bin/time` reports it (`-f %M` with GNU time, `-l` with BSD time on macOS), so the 512×512 photograph above (`cid22-friends.png` in the Docker image, `--time-runs 3`) needs 6–49 MB depending on the encoder. Memory grows with the image and with effort, and matters where many images are encoded in parallel, as on a build server. With `--time-runs`, it is the largest of the runs. The CSV adds `encode_peak_rss_bytes`, in bytes; it shows `n/a` (empty in the CSV) where `/usr/bin/time` is not available.

Timing is a **single encode** at the target by default, which is noisy. Pass
`--time-runs <n>` to encode n times and report the **median** of the wall and CPU
times, which smooths the run-to-run variation at the cost of n encodes per codec:
Expand All @@ -167,7 +171,7 @@ times, which smooths the run-to-run variation at the cost of n encodes per codec
compare-codecs reference.png --target 90 --time-runs 5
```

Unlike the size and quality figures, timing is **not deterministic**: it depends on
Unlike the size and quality figures, timing and memory are **not deterministic**: they depend on
the machine, its load, and the tool versions and build flags. CPU time is the
stabler of the two, but treat both as indicative, measure inside the Docker image on
an idle machine for the most comparable numbers, and don't gate CI on them.
Expand Down
1 change: 1 addition & 0 deletions compare/bin/compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ function main() {
dataUri: embed(op.decodedPng, dims),
encodeWallMs: op.encodeWallMs,
encodeCpuMs: op.encodeCpuMs,
encodePeakBytes: op.encodePeakBytes,
};
process.stderr.write(` ~ ${codec.name} sweep …\n`);
try {
Expand Down
48 changes: 34 additions & 14 deletions compare/src/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,27 @@ const TIME_BIN = "/usr/bin/time";
const timingAvailable = existsSync(TIME_BIN);

/**
* Parse the stderr of `/usr/bin/time -p` into wall time and CPU time (user + sys)
* in milliseconds. The POSIX `-p` format is a trailing block of `real`/`user`/`sys`
* lines and is emitted by both GNU and BSD time, so this reads the last such block
* and ignores any tool output printed before it. Returns nulls when it is absent.
* @returns {{ wallMs: number | null, cpuMs: number | null }}
* The `/usr/bin/time` arguments that print the POSIX real/user/sys block plus the
* peak resident memory. GNU time takes a format (`%M` is in kilobytes); BSD time
* (macOS, the BSDs) adds a rusage report with `-l`, in bytes. Elsewhere only `-p`.
*/
function timeArgs() {
if (!timingAvailable) return null;
const version = spawnSync(TIME_BIN, ["--version"], { encoding: "utf8" });
if (/GNU/.test(`${version.stdout}${version.stderr}`)) return ["-f", "real %e\nuser %U\nsys %S\nmaxrss %M"];
if (/darwin|bsd/.test(process.platform)) return ["-l", "-p"];
return ["-p"];
}
const TIME_ARGS = timeArgs();

/**
* Parse the stderr of `/usr/bin/time` into wall time and CPU time (user + sys) in
* milliseconds, and the peak resident memory in bytes. The POSIX `-p` format is a
* trailing block of `real`/`user`/`sys` lines, emitted by both GNU and BSD time, so
* this reads the last such block and ignores any tool output printed before it.
* Peak memory is GNU's `maxrss` line (kilobytes) or BSD's "maximum resident set
* size" (bytes). Returns nulls for what is absent.
* @returns {{ wallMs: number | null, cpuMs: number | null, peakBytes: number | null }}
*/
export function parseTimeOutput(stderr) {
const last = (key) => {
Expand All @@ -61,23 +77,27 @@ export function parseTimeOutput(stderr) {
const real = last("real");
const user = last("user");
const sys = last("sys");
if (real === null || user === null || sys === null) return { wallMs: null, cpuMs: null };
return { wallMs: real * 1000, cpuMs: (user + sys) * 1000 };
const gnuKb = last("maxrss");
const bsd = [...stderr.matchAll(/^\s*(\d+)\s+maximum resident set size/gm)].at(-1);
const peakBytes = gnuKb !== null ? gnuKb * 1024 : bsd ? Number(bsd[1]) : null;
if (real === null || user === null || sys === null) return { wallMs: null, cpuMs: null, peakBytes };
return { wallMs: real * 1000, cpuMs: (user + sys) * 1000, peakBytes };
}

/**
* Run a command under `/usr/bin/time -p` and return how long it took: wall time
* and CPU time (user + sys) in milliseconds. Where `/usr/bin/time` is unavailable
* the command still runs and timing is reported as null, so callers degrade
* gracefully. Throws with stderr on failure, like run().
* @returns {{ wallMs: number | null, cpuMs: number | null }}
* Run a command under `/usr/bin/time` and return how long it took, wall time and
* CPU time (user + sys) in milliseconds, and its peak resident memory in bytes.
* Where `/usr/bin/time` is unavailable the command still runs and these are
* reported as null, so callers degrade gracefully. Throws with stderr on failure,
* like run().
* @returns {{ wallMs: number | null, cpuMs: number | null, peakBytes: number | null }}
*/
export function runTimed(cmd, args) {
if (!timingAvailable) {
run(cmd, args);
return { wallMs: null, cpuMs: null };
return { wallMs: null, cpuMs: null, peakBytes: null };
}
const res = spawnSync(TIME_BIN, ["-p", cmd, ...args], {
const res = spawnSync(TIME_BIN, [...TIME_ARGS, cmd, ...args], {
stdio: ["ignore", "ignore", "pipe"],
encoding: "utf8",
});
Expand Down
21 changes: 14 additions & 7 deletions compare/src/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ export function encoderInput(codec, referencePng) {
* Encode the reference at a given knob, decode back to PNG, strip the decoded
* PNG to critical chunks (so ssimulacra2 can read every codec's output), and
* measure it. The reference is expected to be already normalized.
* Timing (wall and CPU) is measured for the encode step only, since that is the
* codec's own cost; decoding and metrics are the harness's measurement tools.
* @returns {{ score, bytes, decodedPng, encodeWallMs, encodeCpuMs }}
* Timing (wall and CPU) and peak memory are measured for the encode step only,
* since that is the codec's own cost; decoding and metrics are the harness's
* measurement tools.
* @returns {{ score, bytes, decodedPng, encodeWallMs, encodeCpuMs, encodePeakBytes }}
*/
export function encodeDecodeMeasure(codec, referencePng, knob, workdir, tag = "", effort = null) {
const encoded = join(workdir, `${codec.id}${tag}.${codec.ext}`);
Expand All @@ -41,6 +42,7 @@ export function encodeDecodeMeasure(codec, referencePng, knob, workdir, tag = ""
decodedPng,
encodeWallMs: timing.wallMs,
encodeCpuMs: timing.cpuMs,
encodePeakBytes: timing.peakBytes,
};
}

Expand All @@ -59,26 +61,30 @@ export function median(values) {
* Time the codec's encode at a knob `runs` times and return the median wall and
* CPU time. Repeating and taking the median damps the run-to-run noise of a single
* timing sample (scheduling, cache state); the median rejects the odd outlier.
* @returns {{ wallMs: number | null, cpuMs: number | null }}
* Peak memory is the largest of the runs, the most an encode needed.
* @returns {{ wallMs: number | null, cpuMs: number | null, peakBytes: number | null }}
*/
export function medianEncodeTiming(codec, referencePng, knob, workdir, runs, effort = null) {
const encoded = join(workdir, `${codec.id}.timing.${codec.ext}`);
const wall = [];
const cpu = [];
const peak = [];
for (let i = 0; i < runs; i++) {
const t = runTimed(resolveCommand(codec.encoder), buildEncodeArgs(codec, encoderInput(codec, referencePng), encoded, knob, effort));
wall.push(t.wallMs);
cpu.push(t.cpuMs);
peak.push(t.peakBytes);
}
return { wallMs: median(wall), cpuMs: median(cpu) };
const peakBytes = peak.some((p) => p === null) ? null : Math.max(...peak);
return { wallMs: median(wall), cpuMs: median(cpu), peakBytes };
}

/**
* Search a codec's quality knob until the decoded image reaches the target
* ssimulacra2 score, then report size and metrics at that operating point.
*
* @returns {{ id, name, label, knob, ssimulacra2, dssim, bytes, bpp, iterations, reached,
* decodedPng, encodeWallMs, encodeCpuMs, totalEncodeWallMs, totalEncodeCpuMs }}
* decodedPng, encodeWallMs, encodeCpuMs, encodePeakBytes, totalEncodeWallMs, totalEncodeCpuMs }}
*/
export function findQualityForTarget(codec, referencePng, target, workdir, opts = {}) {
// Normalize the reference once: strip ancillary chunks so ssimulacra2/dssim can
Expand Down Expand Up @@ -127,7 +133,7 @@ export function findQualityForTarget(codec, referencePng, target, workdir, opts
const timing =
timeRuns > 1
? medianEncodeTiming(codec, refNorm, search.value, workdir, timeRuns, effort)
: { wallMs: final.encodeWallMs, cpuMs: final.encodeCpuMs };
: { wallMs: final.encodeWallMs, cpuMs: final.encodeCpuMs, peakBytes: final.encodePeakBytes };

return {
id: codec.id,
Expand All @@ -143,6 +149,7 @@ export function findQualityForTarget(codec, referencePng, target, workdir, opts
decodedPng: final.decodedPng,
encodeWallMs: timing.wallMs,
encodeCpuMs: timing.cpuMs,
encodePeakBytes: timing.peakBytes,
totalEncodeWallMs: timingComplete ? totalWallMs : null,
totalEncodeCpuMs: timingComplete ? totalCpuMs : null,
};
Expand Down
4 changes: 3 additions & 1 deletion compare/src/report-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function toClientData(report) {
uri: c.preview.dataUri,
encMs: c.preview.encodeWallMs ?? null,
cpuMs: c.preview.encodeCpuMs ?? null,
peakBytes: c.preview.encodePeakBytes ?? null,
},
})),
})),
Expand Down Expand Up @@ -130,6 +131,7 @@ const SCRIPT = [
"const fmtKB = b => (b/1024).toFixed(1) + ' KB';",
"const fmtBd = r => r==null ? 'n/a' : (r>0?'+':'')+r.toFixed(1)+'%';",
"const bdCell = c => c.id===DATA.rd.baselineId ? 'baseline' : fmtBd(c.bd);",
"const fmtMB = b => b==null ? 'n/a' : (b/1048576).toFixed(1)+' MB';",
"const fmtMs = m => m==null ? 'n/a' : (m<1000 ? Math.round(m)+' ms' : (m/1000).toFixed(2)+' s');",
"function loadImg(src){ return new Promise(res=>{ const i=new Image(); i.onload=()=>res(i); i.src=src; }); }",
"async function computeDiff(origUri, codecUri, amp){",
Expand Down Expand Up @@ -165,7 +167,7 @@ const SCRIPT = [
" $('#before').src = im.original; showAfter();",
" $('#afterlabel').textContent = diffMode ? 'difference (amplified)' : 'codec';",
" $('#after').style.clipPath = 'inset(0 0 0 '+wipe+'%)'; $('#handle').style.left = wipe+'%';",
" $('#stats').innerHTML = 'format <b>'+c.name+'</b> &nbsp; setting <b>'+c.preview.label+'</b> &nbsp; size <b>'+fmtKB(c.preview.bytes)+'</b> &nbsp; bpp <b>'+c.preview.bpp.toFixed(3)+'</b> &nbsp; ss2 <b>'+c.preview.s.toFixed(2)+'</b> &nbsp; dssim <b>'+c.preview.dssim.toFixed(5)+'</b> &nbsp; encode <b>'+fmtMs(c.preview.encMs)+'</b> &nbsp; cpu <b>'+fmtMs(c.preview.cpuMs)+'</b>';",
" $('#stats').innerHTML = 'format <b>'+c.name+'</b> &nbsp; setting <b>'+c.preview.label+'</b> &nbsp; size <b>'+fmtKB(c.preview.bytes)+'</b> &nbsp; bpp <b>'+c.preview.bpp.toFixed(3)+'</b> &nbsp; ss2 <b>'+c.preview.s.toFixed(2)+'</b> &nbsp; dssim <b>'+c.preview.dssim.toFixed(5)+'</b> &nbsp; encode <b>'+fmtMs(c.preview.encMs)+'</b> &nbsp; cpu <b>'+fmtMs(c.preview.cpuMs)+'</b> &nbsp; memory <b>'+fmtMB(c.preview.peakBytes)+'</b>';",
" applyZoom();",
"}",
"function applyZoom(){ const t='translate('+panX+'px,'+panY+'px) scale('+zoom+')';",
Expand Down
13 changes: 11 additions & 2 deletions compare/src/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ function formatBytes(bytes) {
return `${(bytes / 1024).toFixed(1)} KB`;
}

/** Format a peak memory size in MB, or "n/a" when it was not measured. */
function formatMemory(bytes) {
if (bytes === null || bytes === undefined) return "n/a";
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/** Format a duration in ms for a human: "n/a" when unmeasured, ms under a second, else seconds. */
function formatDuration(ms) {
if (ms === null || ms === undefined) return "n/a";
Expand Down Expand Up @@ -71,6 +77,7 @@ export function toComparisonTable(results, meta) {
{ header: "iters", align: "right" },
{ header: "encode", align: "right" },
{ header: "cpu", align: "right" },
{ header: "memory", align: "right" },
{ header: "enc total", align: "right" },
{ header: "cpu total", align: "right" },
];
Expand All @@ -87,6 +94,7 @@ export function toComparisonTable(results, meta) {
String(r.iterations),
formatDuration(r.encodeWallMs),
formatDuration(r.encodeCpuMs),
formatMemory(r.encodePeakBytes),
formatDuration(r.totalEncodeWallMs),
formatDuration(r.totalEncodeCpuMs),
],
Expand Down Expand Up @@ -145,6 +153,7 @@ const SINGLE_FIELDS = [
(r) => csvMs(r.encodeCpuMs),
(r) => csvMs(r.totalEncodeWallMs),
(r) => csvMs(r.totalEncodeCpuMs),
(r) => r.encodePeakBytes ?? "",
];
const BATCH_FIELDS = [...SINGLE_FIELDS.slice(0, 5), (r) => r.reached, ...SINGLE_FIELDS.slice(5)];

Expand All @@ -158,7 +167,7 @@ function csvRow(r, fields) {
export function toBatchCsv(perImage) {
const head =
"image,codec,setting,bytes,bpp,ssimulacra2,dssim,reached,iterations," +
"encode_wall_ms,encode_cpu_ms,total_encode_wall_ms,total_encode_cpu_ms,error";
"encode_wall_ms,encode_cpu_ms,total_encode_wall_ms,total_encode_cpu_ms,encode_peak_rss_bytes,error";
const lines = [];
for (const { image, results } of perImage) {
for (const r of measuredFirst(results)) {
Expand All @@ -172,7 +181,7 @@ export function toBatchCsv(perImage) {
export function toCsv(results) {
const header =
"codec,setting,bytes,bpp,ssimulacra2,dssim,iterations," +
"encode_wall_ms,encode_cpu_ms,total_encode_wall_ms,total_encode_cpu_ms,error";
"encode_wall_ms,encode_cpu_ms,total_encode_wall_ms,total_encode_cpu_ms,encode_peak_rss_bytes,error";
return [header, ...measuredFirst(results).map((r) => csvRow(r, SINGLE_FIELDS))].join("\n");
}

Expand Down
Loading
Loading