From 2e44f7e54f0b95fbdbc94ab1f8daa667a86b6936 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Mon, 19 Oct 2026 23:54:08 +0200 Subject: [PATCH] feat: report each encoder's peak memory Every encode already runs under /usr/bin/time, which also reports the peak resident memory: GNU time through a format with %M (kilobytes), BSD time on macOS with -l (bytes). The comparison table gains a memory column, both CSVs an encode_peak_rss_bytes column before error, and the HTML report's stats panel the same figure. It is the peak of the single encode at the target, or the largest of the --time-runs encodes, and n/a where /usr/bin/time is missing. --- ROADMAP.md | 1 - compare/README.md | 22 +++++++++------ compare/bin/compare.js | 1 + compare/src/exec.js | 48 ++++++++++++++++++++++---------- compare/src/pipeline.js | 21 +++++++++----- compare/src/report-html.js | 4 ++- compare/src/report.js | 13 +++++++-- compare/test/exec.test.js | 21 +++++++++++--- compare/test/integration.test.js | 7 ++++- compare/test/report-html.test.js | 2 ++ compare/test/report.test.js | 22 ++++++++++----- 11 files changed, 116 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6b18c1d..d00c776 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/compare/README.md b/compare/README.md index 9cdfaa6..01cc08e 100644 --- a/compare/README.md +++ b/compare/README.md @@ -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 @@ -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 ` 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: @@ -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. diff --git a/compare/bin/compare.js b/compare/bin/compare.js index 8387993..2e689a1 100755 --- a/compare/bin/compare.js +++ b/compare/bin/compare.js @@ -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 { diff --git a/compare/src/exec.js b/compare/src/exec.js index aaddfb8..45b7800 100644 --- a/compare/src/exec.js +++ b/compare/src/exec.js @@ -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) => { @@ -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", }); diff --git a/compare/src/pipeline.js b/compare/src/pipeline.js index c143151..3a5ffc7 100644 --- a/compare/src/pipeline.js +++ b/compare/src/pipeline.js @@ -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}`); @@ -41,6 +42,7 @@ export function encodeDecodeMeasure(codec, referencePng, knob, workdir, tag = "" decodedPng, encodeWallMs: timing.wallMs, encodeCpuMs: timing.cpuMs, + encodePeakBytes: timing.peakBytes, }; } @@ -59,18 +61,22 @@ 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 }; } /** @@ -78,7 +84,7 @@ export function medianEncodeTiming(codec, referencePng, knob, workdir, runs, eff * 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 @@ -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, @@ -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, }; diff --git a/compare/src/report-html.js b/compare/src/report-html.js index 1de6960..3534fd8 100644 --- a/compare/src/report-html.js +++ b/compare/src/report-html.js @@ -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, }, })), })), @@ -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){", @@ -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 '+c.name+'   setting '+c.preview.label+'   size '+fmtKB(c.preview.bytes)+'   bpp '+c.preview.bpp.toFixed(3)+'   ss2 '+c.preview.s.toFixed(2)+'   dssim '+c.preview.dssim.toFixed(5)+'   encode '+fmtMs(c.preview.encMs)+'   cpu '+fmtMs(c.preview.cpuMs)+'';", + " $('#stats').innerHTML = 'format '+c.name+'   setting '+c.preview.label+'   size '+fmtKB(c.preview.bytes)+'   bpp '+c.preview.bpp.toFixed(3)+'   ss2 '+c.preview.s.toFixed(2)+'   dssim '+c.preview.dssim.toFixed(5)+'   encode '+fmtMs(c.preview.encMs)+'   cpu '+fmtMs(c.preview.cpuMs)+'   memory '+fmtMB(c.preview.peakBytes)+'';", " applyZoom();", "}", "function applyZoom(){ const t='translate('+panX+'px,'+panY+'px) scale('+zoom+')';", diff --git a/compare/src/report.js b/compare/src/report.js index ad397d7..184bb87 100644 --- a/compare/src/report.js +++ b/compare/src/report.js @@ -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"; @@ -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" }, ]; @@ -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), ], @@ -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)]; @@ -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)) { @@ -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"); } diff --git a/compare/test/exec.test.js b/compare/test/exec.test.js index 2ff64da..36043c2 100644 --- a/compare/test/exec.test.js +++ b/compare/test/exec.test.js @@ -4,21 +4,34 @@ import { parseTimeOutput, failureReason } from "../src/exec.js"; test("parses POSIX `time -p` output into wall and cpu milliseconds", () => { const out = "real 0.50\nuser 0.45\nsys 0.03\n"; - assert.deepEqual(parseTimeOutput(out), { wallMs: 500, cpuMs: 480 }); + assert.deepEqual(parseTimeOutput(out), { wallMs: 500, cpuMs: 480, peakBytes: null }); }); test("cpu time is user + sys, so it can exceed wall time when multithreaded", () => { const out = "real 1.20\nuser 2.00\nsys 0.30\n"; - assert.deepEqual(parseTimeOutput(out), { wallMs: 1200, cpuMs: 2300 }); + assert.deepEqual(parseTimeOutput(out), { wallMs: 1200, cpuMs: 2300, peakBytes: null }); }); test("reads the final timing block, ignoring earlier tool output", () => { const out = "encoder: reticulating splines\nreal 0.10\nuser 0.08\nsys 0.01\n"; - assert.deepEqual(parseTimeOutput(out), { wallMs: 100, cpuMs: 90 }); + assert.deepEqual(parseTimeOutput(out), { wallMs: 100, cpuMs: 90, peakBytes: null }); }); test("returns nulls when timing is absent", () => { - assert.deepEqual(parseTimeOutput("no timing here\n"), { wallMs: null, cpuMs: null }); + assert.deepEqual(parseTimeOutput("no timing here\n"), { wallMs: null, cpuMs: null, peakBytes: null }); +}); + +test("reads GNU time's peak resident memory, in kilobytes, as bytes", () => { + const out = "real 1.00\nuser 0.50\nsys 0.25\nmaxrss 51200\n"; + assert.deepEqual(parseTimeOutput(out), { wallMs: 1000, cpuMs: 750, peakBytes: 51200 * 1024 }); +}); + +test("reads BSD time's peak resident memory, already in bytes", () => { + const out = + "real 1.00\nuser 0.50\nsys 0.25\n" + + " 52428800 maximum resident set size\n" + + " 0 average shared memory size\n"; + assert.deepEqual(parseTimeOutput(out), { wallMs: 1000, cpuMs: 750, peakBytes: 52428800 }); }); test("failureReason picks the encoder's error line out of a failed command", () => { diff --git a/compare/test/integration.test.js b/compare/test/integration.test.js index e8367c1..59da471 100644 --- a/compare/test/integration.test.js +++ b/compare/test/integration.test.js @@ -4,7 +4,7 @@ // and run for real inside the Docker image or on a full local setup. import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; @@ -167,6 +167,11 @@ test("one run writes the table, CSV and HTML report from the same search", { ski const rows = parseResultsCsv(readFileSync(csvPath, "utf8")); assert.equal(rows.length, 4, "one CSV row per image and codec"); + if (existsSync("/usr/bin/time")) { + const [header, ...lines] = readFileSync(csvPath, "utf8").trim().split("\n"); + const at = header.split(",").indexOf("encode_peak_rss_bytes"); + assert.ok(lines.every((l) => Number(l.split(",")[at]) > 0), "each row carries the encoder's peak memory"); + } const json = readFileSync(htmlPath, "utf8").match(/