From c4973fd1f63889b8a3898b351ad3f7b88eab6688 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:56:12 -0400 Subject: [PATCH 01/24] test(dng): add a memory-stream decode entry to the DNG oracle `read_raw_dng` writes the bytes to a temporary file and exports the decoded samples across the FFI boundary. Both are fine for a conformance check and wrong for a timed one: they charge the reference implementation for file I/O and for a full-image `malloc` + `memcpy` that gamut's in-memory `DngDecoder` never pays. `decode_dng_in_memory` runs the same parse -> build-negative -> `ReadStage1Image` flow over a `dng_stream` on the caller's bytes and reports only the extent of the image it produced. Pinned against the file-stream path on an Adobe sample DNG, so a benchmark cannot be timing a cheaper, different decode. Refs #163 --- tooling/gamut-dng-oracle/src/lib.rs | 93 ++++++++++++++++++++ tooling/gamut-dng-oracle/src/oracle_shim.cpp | 56 ++++++++++++ 2 files changed, 149 insertions(+) diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index e117ab30..1dc42596 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -53,6 +53,18 @@ unsafe extern "C" { out_len: *mut usize, ) -> c_int; + /// Decodes the DNG in `data`/`len` from memory and reports the stage-1 image's geometry and + /// sample count without exporting the samples; `0` on success, else the SDK error code. + /// Nothing is allocated for the caller, so there is nothing to free. + fn gdng_decode_dng_in_memory( + data: *const u8, + len: usize, + out_w: *mut u32, + out_h: *mut u32, + out_planes: *mut u32, + out_len: *mut usize, + ) -> c_int; + /// Computes the SDK's `NewRawImageDigest` for the DNG at `path` into `out_digest` (16 bytes); /// `0` on success, else the SDK error code. fn gdng_new_raw_image_digest(path: *const c_char, out_digest: *mut u8) -> c_int; @@ -274,6 +286,69 @@ pub fn read_linear_dng(bytes: &[u8]) -> Result { read_image(bytes, gdng_read_linear, "stage-2 linear") } +/// The extent of an image the Adobe DNG SDK decoded: its geometry and how many samples it holds. +/// +/// Deliberately carries no pixels. It is what [`decode_dng_in_memory`] reports, and the point of +/// that entry is to *not* pay for an export copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodedExtent { + /// Image width in pixels. + pub width: u32, + /// Image height in pixels. + pub height: u32, + /// Colour planes per pixel. + pub planes: u32, + /// How many samples the decoded image holds: `width * height * planes`. + pub samples: usize, +} + +/// Decodes `bytes` as a DNG with the Adobe DNG SDK **from memory**, returning only the extent of +/// the stage-1 raw image it produced. +/// +/// This is the reference implementation's decode reduced to the work gamut's +/// `DngDecoder::decode` also does, so the two can be timed against each other +/// (`cargo bench -p gamut-dng --bench codec`): +/// +/// - no temporary file is written and no `dng_file_stream` is opened — the SDK parses the same +/// in-memory bytes the Rust caller holds; and +/// - the decoded image is not exported into a caller-owned buffer, so the extra full-image +/// `malloc` + `memcpy` that [`read_raw_dng`] must perform to cross the FFI boundary is not +/// charged to the codec. +/// +/// Use [`read_raw_dng`] when you want the samples; this one when you want the time. +/// +/// # Errors +/// +/// Returns an error message (with the SDK's numeric error code) if the SDK cannot parse the bytes +/// or read the raw image. +pub fn decode_dng_in_memory(bytes: &[u8]) -> Result { + let (mut width, mut height, mut planes): (u32, u32, u32) = (0, 0, 0); + let mut samples: usize = 0; + // SAFETY: `bytes` outlives the call and the shim only reads `bytes.len()` bytes from it; the + // four out-parameters are distinct live locals and the shim allocates nothing for us. + let code = unsafe { + gdng_decode_dng_in_memory( + bytes.as_ptr(), + bytes.len(), + &mut width, + &mut height, + &mut planes, + &mut samples, + ) + }; + if code != 0 { + return Err(format!( + "Adobe DNG SDK could not decode the DNG from memory (code {code})" + )); + } + Ok(DecodedExtent { + width, + height, + planes, + samples, + }) +} + /// Decodes a **bare lossless-JPEG (SOF3) stream** with the Adobe DNG SDK's own codec — the /// reference for gamut-dng's T.81 process-14 decoder (predictors 1–7, point transform, /// row-aligned restart intervals). @@ -337,6 +412,24 @@ mod tests { ); } + /// The memory-stream decode reaches the same stage-1 image as the file-stream one, so the + /// entry point a benchmark times is not a cheaper, different decode. + #[test] + fn in_memory_decode_reaches_the_same_image_as_the_file_decode() { + let bytes = sample_file("05_PGTM2_unsigned8.dng").expect("sample DNG present"); + let exported = read_raw_dng(&bytes).expect("file-stream decode"); + let extent = decode_dng_in_memory(&bytes).expect("memory-stream decode"); + assert_eq!( + (extent.width, extent.height, extent.planes, extent.samples), + ( + exported.width, + exported.height, + exported.planes, + exported.samples.len() + ) + ); + } + /// The digest entry point computes a stable, non-trivial MD5 for a real file. #[test] fn computes_new_raw_image_digest_for_sample() { diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 9b8f5638..d8761259 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -141,6 +141,62 @@ extern "C" int gdng_read_raw(const char *path, uint32_t *out_w, uint32_t *out_h, } } +// Decodes the DNG held in `data`/`len` and reports the extent of the stage-1 (raw) image it +// produced, without exporting the samples. This is the *timed* decode entry point (issue #163): +// it exists so a throughput benchmark can compare the reference implementation against gamut's +// `DngDecoder` on the same terms, and it differs from `gdng_read_raw` in exactly two ways, both +// of which remove work gamut's decoder does not do either: +// +// * it reads from a memory stream rather than a `dng_file_stream`, so no temporary file is +// written and no filesystem is touched inside the measured region, and +// * it stops once `ReadStage1Image` has materialised the image, skipping the +// `copy_short_image` export pass — an extra full-image `malloc` + `memcpy` that only the FFI +// boundary needs. +// +// Everything else is the same parse → build-negative → read-stage-1 flow as `gdng_read_raw`. +// Returns `dng_error_none` on success, or the SDK error code. +extern "C" int gdng_decode_dng_in_memory(const uint8_t *data, size_t len, uint32_t *out_w, + uint32_t *out_h, uint32_t *out_planes, size_t *out_len) { + *out_w = 0; + *out_h = 0; + *out_planes = 0; + *out_len = 0; + if (len > 0xFFFFFFFFu) { + return dng_error_bad_format; + } + try { + dng_host host; + dng_info info; + dng_stream stream(data, static_cast(len)); + info.Parse(host, stream); + info.PostParse(host); + if (!info.IsValidDNG()) { + return dng_error_bad_format; + } + AutoPtr negative(host.Make_dng_negative()); + negative->Parse(host, stream, info); + negative->PostParse(host, stream, info); + negative->ReadStage1Image(host, stream, info); + const dng_image *image = negative->Stage1Image(); + if (image == nullptr) { + return dng_error_unknown; + } + dng_rect bounds = image->Bounds(); + uint32 w = static_cast(bounds.r - bounds.l); + uint32 h = static_cast(bounds.b - bounds.t); + uint32 planes = image->Planes(); + *out_w = w; + *out_h = h; + *out_planes = planes; + *out_len = static_cast(w) * static_cast(h) * static_cast(planes); + } catch (const dng_exception &except) { + return except.ErrorCode(); + } catch (...) { + return dng_error_unknown; + } + return dng_error_none; +} + // Reads the DNG at `path` and returns its stage-2 (linearized) image — the SDK's application of // the spec's Chapter-5 "Mapping Raw Values to Linear Reference Values": linearization table, // black subtraction (pattern + deltas), rescale, clip. The buffer is active-area-sized, From 8956ad00c3f20c1fc42f212d3e1376f719570fd1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:56:20 -0400 Subject: [PATCH 02/24] perf(dng): benchmark encode and decode against the Adobe DNG SDK `--bench compression` answers the #196 question on packed payloads only. This adds `--bench codec`: encode and decode throughput across the whole shipped matrix -- uncompressed, Deflate and lossless JPEG, for CFA and LinearRaw -- with gamut's decode next to the reference implementation's. Fixtures are synthesised in-process, so the harness needs no sample corpus and runs by default. The codec call, the buffer it produces and that buffer's teardown are inside the timed region; fixture synthesis and the encode a decode benchmark reads are outside it. The teardown is placed explicitly because divan otherwise defers a returned value's drop past the timed region, which would charge gamut nothing for freeing a decoded image while the SDK's negative destructor runs inside its call. Two gamut-versus-SDK comparisons are published because their biases point in opposite directions and neither can be normalised away: the whole-file decode favours the SDK (gamut also unpacks IFD 0's preview and rebuilds the metadata, a margin the fixture table prints), the lossless-JPEG codestream decode favours gamut (the oracle's export path costs the SDK two extra passes). No encode comparison: the shim wraps the SDK's reader, not its writer, so no reference number exists to compare against. Refs #163 --- crates/gamut-dng/Cargo.toml | 4 + crates/gamut-dng/benches/codec.rs | 410 ++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 crates/gamut-dng/benches/codec.rs diff --git a/crates/gamut-dng/Cargo.toml b/crates/gamut-dng/Cargo.toml index ceaabc9a..af314955 100644 --- a/crates/gamut-dng/Cargo.toml +++ b/crates/gamut-dng/Cargo.toml @@ -64,3 +64,7 @@ divan.workspace = true [[bench]] name = "compression" harness = false + +[[bench]] +name = "codec" +harness = false diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs new file mode 100644 index 00000000..3c9a2926 --- /dev/null +++ b/crates/gamut-dng/benches/codec.rs @@ -0,0 +1,410 @@ +//! DNG encode + decode throughput, against the Adobe DNG SDK's decode (issue #163). +//! +//! `cargo bench -p gamut-dng --bench codec` first prints a fixture table — the byte volumes each +//! measured region moves — then runs divan throughput benchmarks over the codec matrix the crate +//! ships: **uncompressed**, **Deflate** and **lossless JPEG**, each for **CFA** and **LinearRaw** +//! photometry. The counter is always the *raw sample volume* (`samples × 2` bytes), so encode, +//! decode and the reference implementation are all quoted against the same denominator and are +//! directly comparable. +//! +//! # What is inside the timed region, and what is not +//! +//! **Inside**, for every benchmark: the codec call itself, the allocation and growth of the buffer +//! it produces, and that buffer's teardown. Every closure below returns `()` and drops its result +//! explicitly, because divan otherwise defers a returned value's drop until after timing — which +//! would charge gamut nothing for freeing a decoded image while the SDK, whose `dng_negative` +//! destructor runs inside its own call, pays in full. +//! +//! **Outside**, for every benchmark: synthesising the sensor samples, building the [`RawImage`] +//! and [`CameraProfile`], and encoding the DNG (or the bare lossless-JPEG stream) that the decode +//! benchmarks read. Those are fixtures; timing them would measure this file rather than the codec. +//! No benchmark here touches the filesystem. +//! +//! # Is the gamut-versus-SDK comparison fair? +//! +//! Two comparisons are published, and their biases point in *opposite* directions, so together +//! they bracket the truth rather than flattering one side. +//! +//! `decode_dng_*` — **the SDK is favoured, by a stated and computable margin.** Both sides parse +//! the same in-memory bytes: [`gamut_dng_oracle::decode_dng_in_memory`] exists precisely so the +//! reference implementation is not charged for a temporary file or for the export `memcpy` that +//! crossing the FFI boundary would otherwise need (see its docs). What remains is that +//! `DngDecoder::decode` is a *whole-file* decode and `ReadStage1Image` is not: gamut additionally +//! decodes IFD 0's uncompressed RGB preview and reconstructs the metadata, work the SDK's stage-1 +//! read skips entirely. The preview's size is exact and not a guess — `⌊w/2⌋ × ⌊h/2⌋ × 3` bytes +//! against the raw's `w × h × planes × 2` — so the fixture table prints it per case and the +//! handicap can be read off directly. It is not normalised away because gamut exposes no +//! raw-image-only decode entry point, and inventing one to make a benchmark look better would be +//! the wrong direction of causation. +//! +//! `decode_lossless_jpeg_*` — **gamut is favoured, by a smaller margin.** Here the subjects match +//! exactly: the same bare SOF3 stream in, the same interleaved `Vec` out, no container work +//! on either side. The residual bias is the FFI export path +//! ([`gamut_dng_oracle::decode_lossless_jpeg`] spools into a `std::vector`, copies that into a +//! `malloc`d buffer, and copies *that* into a `Vec`), which charges the SDK two extra passes over +//! the sample volume that gamut's single `Vec` does not pay. Those are memory-bandwidth passes, +//! not entropy decoding, so this is the tighter of the two comparisons — but it is a bias, and it +//! runs the other way. +//! +//! There is no `encode_adobe_sdk`: the oracle shim wraps the SDK's reader, not its writer, so no +//! reference encode number exists to compare against and none is fabricated. Encode throughput is +//! reported for gamut alone, across the same matrix. + +use divan::counter::BytesCount; +use divan::{Bencher, black_box}; +use gamut_core::Dimensions; +use gamut_dng::raw::cfa_color; +use gamut_dng::{ + CalibrationIlluminant, CameraProfile, Compression, DngDecoder, DngEncoder, RawImage, + lossless_jpeg, +}; + +fn main() { + print_fixture_table(); + divan::main(); +} + +/// Fixture frame size, in pixels. Large enough that the codecs dominate per-call overhead, small +/// enough that `cargo bench --workspace` stays affordable: a `LinearRaw` frame at this size is +/// 1.1 MiB of samples and a CFA frame 384 KiB. +const WIDTH: u32 = 512; +const HEIGHT: u32 = 384; + +/// Fixture sample depth. 16-bit for every case, so the axis the matrix varies is the compression +/// scheme and the photometry — not the packing. (DNG's Deflate path is restricted to whole-byte +/// depths anyway; see `DngEncoder::encode`.) +const BITS: u16 = 16; + +/// Which photometry a fixture carries — the two the encoder writes. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Photometry { + /// A single-plane RGGB Bayer mosaic. + Cfa, + /// A demosaiced three-plane linear image. + LinearRaw, +} + +impl Photometry { + /// Colour planes per pixel. + fn planes(self) -> u32 { + match self { + Photometry::Cfa => 1, + Photometry::LinearRaw => 3, + } + } + + /// The fixture raw image for this photometry. + fn raw(self) -> RawImage { + let dims = Dimensions::new(WIDTH, HEIGHT).expect("non-empty fixture dimensions"); + let samples = sensor_samples(self.planes()); + let max = f64::from((1u32 << BITS) - 1); + match self { + Photometry::Cfa => { + let pattern = vec![ + cfa_color::RED, + cfa_color::GREEN, + cfa_color::GREEN, + cfa_color::BLUE, + ]; + RawImage::new_cfa(dims, BITS, (2, 2), pattern, samples).expect("valid CFA fixture") + } + Photometry::LinearRaw => RawImage::new_linear_raw(dims, BITS, 3, samples) + .expect("valid LinearRaw fixture"), + } + .with_black_level(0.0) + .expect("valid black level") + .with_white_level(max) + .expect("valid white level") + .with_active_area([0, 0, HEIGHT, WIDTH]) + .with_default_crop([0, 0], [WIDTH, HEIGHT]) + } +} + +impl std::fmt::Display for Photometry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Photometry::Cfa => "cfa", + Photometry::LinearRaw => "linear-raw", + }) + } +} + +/// One cell of the codec matrix: a photometry crossed with a compression scheme. +#[derive(Clone, Copy)] +struct Case { + photometry: Photometry, + compression: Compression, +} + +impl Case { + /// The fixture raw image. + fn raw(self) -> RawImage { + self.photometry.raw() + } + + /// The fixture DNG the decode benchmarks read: this case's raw image, encoded. + fn encoded(self) -> Vec { + let mut out = Vec::new(); + encoder(self.compression) + .encode(&self.raw(), &profile(), &mut out) + .expect("fixture DNG encodes"); + out + } + + /// Raw sample volume in bytes — the throughput denominator for every benchmark. + fn raw_bytes(self) -> usize { + raw_bytes(self.photometry) + } +} + +impl std::fmt::Display for Case { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let scheme = match self.compression { + Compression::Uncompressed => "uncompressed", + Compression::Deflate => "deflate", + Compression::LosslessJpeg => "lossless-jpeg", + other => return write!(f, "{}/{other:?}", self.photometry), + }; + write!(f, "{}/{scheme}", self.photometry) + } +} + +/// The two photometries, as a benchmark argument list. +const PHOTOMETRIES: [Photometry; 2] = [Photometry::Cfa, Photometry::LinearRaw]; + +/// The codec matrix: every compression this crate encodes without an optional feature, crossed +/// with both photometries. JPEG XL is absent deliberately — encoding it needs the `jxl-encode` +/// feature (and a C++ toolchain), so a default `cargo bench` could not produce its fixture. +const CASES: [Case; 6] = [ + Case { + photometry: Photometry::Cfa, + compression: Compression::Uncompressed, + }, + Case { + photometry: Photometry::Cfa, + compression: Compression::Deflate, + }, + Case { + photometry: Photometry::Cfa, + compression: Compression::LosslessJpeg, + }, + Case { + photometry: Photometry::LinearRaw, + compression: Compression::Uncompressed, + }, + Case { + photometry: Photometry::LinearRaw, + compression: Compression::Deflate, + }, + Case { + photometry: Photometry::LinearRaw, + compression: Compression::LosslessJpeg, + }, +]; + +/// A sensor-like frame: a smooth illumination falloff, a per-CFA-channel gain, and deterministic +/// per-photosite noise, interleaved across `planes`. +/// +/// The noise is the point. Raw sensor data is hard to compress *because* of it, so a clean +/// synthetic gradient would flatter every compressor equally and measure nothing a real file +/// would see. This follows the same model `benches/compression.rs` uses for its packed payloads; +/// the two cannot share one generator because they produce different things — that bench needs +/// packed bytes for `gamut-deflate`, this one needs `u16` samples for a [`RawImage`]. +fn sensor_samples(planes: u32) -> Vec { + let max = f64::from((1u32 << BITS) - 1); + let mut samples = Vec::with_capacity((WIDTH * HEIGHT * planes) as usize); + for y in 0..HEIGHT { + for x in 0..WIDTH { + for plane in 0..planes { + // Cosine-fourth-law-ish falloff from the frame centre. + let dx = f64::from(x) / f64::from(WIDTH) - 0.5; + let dy = f64::from(y) / f64::from(HEIGHT) - 0.5; + let falloff = 1.0 - 1.4 * (dx * dx + dy * dy); + // A green photosite collects roughly twice what red and blue do; for a linear + // image the same gains index the interleaved planes. + let channel = if planes == 1 { + (x % 2, y % 2) + } else { + (plane % 2, plane / 2) + }; + let gain = match channel { + (0, 0) => 0.42, // R + (1, 1) => 0.31, // B + _ => 0.70, // G + }; + // Deterministic shot-noise stand-in, a few percent of full scale. + let hash = ((y * WIDTH + x) * planes + plane).wrapping_mul(2_654_435_761) >> 11; + let noise = f64::from(hash % 2048) / 2048.0 - 0.5; + let value = (falloff * gain + noise * 0.05).clamp(0.0, 1.0) * max; + samples.push(value as u16); + } + } + } + samples +} + +/// A plausible camera colour profile (an illustrative XYZ→camera matrix under D65). The encoder +/// needs one; nothing here measures it. +fn profile() -> CameraProfile { + CameraProfile::new( + "gamut BenchCam", + [ + 0.6722, -0.0635, -0.0963, -0.4287, 1.2460, 0.2028, -0.0908, 0.2162, 0.5668, + ], + CalibrationIlluminant::D65, + [0.5128, 1.0, 0.7059], + ) + .expect("valid profile") +} + +/// The encoder under test, at one compression scheme. Everything else is the encoder's default. +fn encoder(compression: Compression) -> DngEncoder { + DngEncoder::new().with_compression(compression) +} + +/// Raw sample volume in bytes for a photometry: `w × h × planes × 2`. +fn raw_bytes(photometry: Photometry) -> usize { + (WIDTH * HEIGHT * photometry.planes()) as usize * size_of::() +} + +/// Bytes of IFD-0 preview a decode of one of these fixtures additionally unpacks: +/// `⌊w/2⌋ × ⌊h/2⌋ × 3`, uncompressed RGB8 (the encoder always writes the preview uncompressed). +/// +/// This is the whole of the `decode_dng_gamut` / `decode_dng_adobe_sdk` asymmetry that is +/// attributable to pixels; the rest is IFD and metadata reconstruction, which does not scale with +/// the frame. +fn preview_bytes() -> usize { + (WIDTH / 2 * (HEIGHT / 2) * 3) as usize +} + +/// A bare lossless-JPEG (SOF3) stream over one photometry's fixture samples — one component for +/// CFA, three interleaved for `LinearRaw`. +fn lossless_jpeg_stream(photometry: Photometry) -> Vec { + let planes = photometry.planes() as usize; + lossless_jpeg::encode( + &sensor_samples(photometry.planes()), + WIDTH as usize, + HEIGHT as usize, + planes, + BITS, + ) + .expect("fixture lossless-JPEG stream encodes") +} + +/// Prints the byte volumes each measured region moves, so a throughput number can be read against +/// what it is a throughput *of* — including the preview volume that separates gamut's whole-file +/// decode from the SDK's stage-1 read. +fn print_fixture_table() { + println!( + "\nDNG codec fixtures, {WIDTH}x{HEIGHT} at {BITS}-bit (bytes):\n\n\ + {:<26} {:>12} {:>12} {:>8} {:>12} {:>10}", + "case", "raw samples", "encoded DNG", "of raw", "IFD0 preview", "of raw" + ); + for case in CASES { + let raw = case.raw_bytes(); + let encoded = case.encoded().len(); + let preview = preview_bytes(); + println!( + "{case:<26} {raw:>12} {encoded:>12} {:>7.1}% {preview:>12} {:>9.1}%", + encoded as f64 / raw as f64 * 100.0, + preview as f64 / raw as f64 * 100.0, + ); + } + println!( + "\n`decode_dng_gamut` decodes the whole file — raw image, that IFD-0 preview and the\n\ + metadata; `decode_dng_adobe_sdk` reads the raw image only. The \"IFD0 preview / of raw\"\n\ + column is the pixel volume of that difference. `decode_lossless_jpeg_*` has no such gap:\n\ + same stream in, same samples out.\n" + ); +} + +/// Encode: `DngEncoder::encode` over a prepared raw image and profile. +/// +/// Timed: preview derivation, sample packing and compression, IFD-tree layout, and the growth and +/// teardown of the output buffer. Not timed: building the raw image and the profile. +#[divan::bench(args = CASES)] +fn encode_gamut(bencher: Bencher, case: Case) { + let raw = case.raw(); + let profile = profile(); + let encoder = encoder(case.compression); + bencher + .counter(BytesCount::new(case.raw_bytes())) + .bench_local(|| { + let mut out = Vec::new(); + encoder + .encode(black_box(&raw), black_box(&profile), &mut out) + .expect("encode"); + drop(black_box(out)); + }); +} + +/// Decode, gamut: `DngDecoder::decode` over a prepared DNG. +/// +/// Timed: container parse, raw-image decode, IFD-0 preview decode, metadata reconstruction, and +/// the teardown of everything decoded. Not timed: producing the DNG bytes. +#[divan::bench(args = CASES)] +fn decode_dng_gamut(bencher: Bencher, case: Case) { + let bytes = case.encoded(); + let decoder = DngDecoder::new(); + bencher + .counter(BytesCount::new(case.raw_bytes())) + .bench_local(|| { + drop(black_box(decoder.decode(black_box(&bytes)).expect("decode"))); + }); +} + +/// Decode, Adobe DNG SDK: parse → build negative → `ReadStage1Image`, over the *same* bytes, from +/// memory. +/// +/// Timed: everything the reference implementation does to materialise the raw image, including +/// the negative's teardown. Not timed: producing the DNG bytes — and, by construction of +/// [`gamut_dng_oracle::decode_dng_in_memory`], no temporary file and no FFI export copy. See this +/// file's header for the residual asymmetry against `decode_dng_gamut`. +#[divan::bench(args = CASES)] +fn decode_dng_adobe_sdk(bencher: Bencher, case: Case) { + let bytes = case.encoded(); + bencher + .counter(BytesCount::new(case.raw_bytes())) + .bench_local(|| { + // No `drop` to place: `DecodedExtent` is plain `Copy` data, because the SDK's own + // teardown already ran — inside the call, and so inside this timed region. + black_box( + gamut_dng_oracle::decode_dng_in_memory(black_box(&bytes)).expect("SDK decode"), + ); + }); +} + +/// Lossless-JPEG codestream decode, gamut: `lossless_jpeg::decode` over a bare SOF3 stream. +/// +/// Timed: marker parse, Huffman + predictor decode, and the teardown of the sample buffer. Not +/// timed: encoding the stream. +#[divan::bench(args = PHOTOMETRIES)] +fn decode_lossless_jpeg_gamut(bencher: Bencher, photometry: Photometry) { + let stream = lossless_jpeg_stream(photometry); + bencher + .counter(BytesCount::new(raw_bytes(photometry))) + .bench_local(|| { + drop(black_box( + lossless_jpeg::decode(black_box(&stream)).expect("decode"), + )); + }); +} + +/// Lossless-JPEG codestream decode, Adobe DNG SDK: `DecodeLosslessJPEG` over the *same* stream. +/// +/// Timed: the SDK's decode plus the FFI export path (spool vector → `malloc`d buffer → `Vec`), +/// which is two passes over the sample volume more than gamut pays. That bias favours gamut and +/// is the reason this file publishes two comparisons rather than one. +#[divan::bench(args = PHOTOMETRIES)] +fn decode_lossless_jpeg_adobe_sdk(bencher: Bencher, photometry: Photometry) { + let stream = lossless_jpeg_stream(photometry); + let expected = (WIDTH * HEIGHT * photometry.planes()) as usize; + bencher + .counter(BytesCount::new(raw_bytes(photometry))) + .bench_local(|| { + drop(black_box( + gamut_dng_oracle::decode_lossless_jpeg(black_box(&stream), expected) + .expect("SDK decode"), + )); + }); +} From c6620b29be010c1b997452bf13d6920bb6a77ed1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:56:25 -0400 Subject: [PATCH 03/24] docs(dng): record the codec benchmark harness and its fairness State what the harness measures, what sits inside and outside each timed region, and -- the part a reader cannot re-derive -- which of the two gamut-versus-SDK comparisons favours which side and by how much. Also state that no absolute throughput figure is pinned: unlike the #196 ratios, MB/s is a property of the machine that produced it. Refs #163 --- crates/gamut-dng/STATUS.md | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 79d047d1..bbd70318 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -351,6 +351,46 @@ crate encoded with before: The Adobe DNG SDK validates the output on every fixture the oracle covers (CFA and LinearRaw, 8- and 16-bit, strips and tiles), so the migration is correctness-neutral. +## Codec benchmark harness (#163) + +`cargo bench -p gamut-dng --bench codec` measures **encode and decode throughput across the whole +shipped codec matrix** — uncompressed, Deflate and lossless JPEG, each for CFA and `LinearRaw` +photometry — and puts gamut's decode next to the **Adobe DNG SDK's**. (The older `--bench +compression` is narrower and stays as it is: it answers the #196 question, "which DEFLATE encoder +should the ZIP path use", on packed payloads.) Fixtures are synthesised in-process, so the harness +needs no sample corpus and runs by default; the ~178 MiB real-camera submodule behind `mise run +fetch-dng-samples` is deliberately not a prerequisite. + +**What is timed.** The codec call, the allocation and growth of the buffer it produces, and that +buffer's teardown — the last of those explicitly, because divan would otherwise defer a returned +value's drop past the timed region, which would charge gamut nothing for freeing a decoded image +while the SDK's `dng_negative` destructor runs inside its own call. Fixture synthesis, the +`RawImage`/`CameraProfile` build, and the encode that produces the bytes a decode benchmark reads +are all outside it. Nothing touches the filesystem. + +**Whether the comparison is fair.** Two comparisons are published and their biases point in +opposite directions, which is what makes the pair usable: + +- `decode_dng_*` **favours the SDK, by a margin the harness prints.** Both sides parse the same + in-memory bytes: the oracle gained a timed entry point (`decode_dng_in_memory`) that opens no + temporary file and skips the FFI export `memcpy`, so the reference implementation is not charged + for the shim. What remains is that `DngDecoder::decode` is a *whole-file* decode while + `ReadStage1Image` is not — gamut also unpacks IFD 0's uncompressed RGB preview and rebuilds the + metadata. The preview's size is exact (`⌊w/2⌋ × ⌊h/2⌋ × 3` against the raw's `w × h × planes × + 2`, i.e. 37.5 % of a 16-bit CFA frame), so the fixture table prints it per case. It is not + normalised away: gamut exposes no raw-image-only decode entry point, and adding one so a + benchmark reads better would be the wrong direction of causation. +- `decode_lossless_jpeg_*` **favours gamut, by less.** Same bare SOF3 stream in, same interleaved + `Vec` out, no container work either side; the residual bias is the oracle's export path + (spool vector → `malloc`d buffer → `Vec`), two memory-bandwidth passes gamut does not pay. + +There is no `encode_adobe_sdk`: the oracle shim wraps the SDK's *reader*, not its writer, so no +reference encode number exists and none is invented. Encode is reported for gamut alone. + +**No absolute figures are pinned here.** Unlike the #196 numbers above — a ratio comparison between +two encoders in the same process, which is robust to a loaded machine — throughput in MB/s is a +property of the machine that produced it. Run the harness on the box you care about. + ## Deferred / out of scope Each deferred item plugs into the same IFD-tree/chunk pipeline and oracles the shipped features From 30ff76819df3fadfb6e4cc22a310a8d765169a5c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 00:59:35 -0400 Subject: [PATCH 04/24] style(dng): apply rustfmt to the codec benchmark --- crates/gamut-dng/benches/codec.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 3c9a2926..1e38c1ff 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -108,8 +108,9 @@ impl Photometry { ]; RawImage::new_cfa(dims, BITS, (2, 2), pattern, samples).expect("valid CFA fixture") } - Photometry::LinearRaw => RawImage::new_linear_raw(dims, BITS, 3, samples) - .expect("valid LinearRaw fixture"), + Photometry::LinearRaw => { + RawImage::new_linear_raw(dims, BITS, 3, samples).expect("valid LinearRaw fixture") + } } .with_black_level(0.0) .expect("valid black level") @@ -349,7 +350,9 @@ fn decode_dng_gamut(bencher: Bencher, case: Case) { bencher .counter(BytesCount::new(case.raw_bytes())) .bench_local(|| { - drop(black_box(decoder.decode(black_box(&bytes)).expect("decode"))); + drop(black_box( + decoder.decode(black_box(&bytes)).expect("decode"), + )); }); } From 83461e74d4538bf3a8e5a9dbaa8da6aa5dca1bec Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 01:31:44 -0400 Subject: [PATCH 05/24] fix(dng): give the benchmark's blue plane blue's gain The linear-raw branch derived a colour from `(plane % 2, plane / 2)`, which sends plane 2 to the fall-through arm -- so blue photosites were generated at green's gain. Harmless to a timing number, but the fixture no longer matched what its own comment claimed it was, and a fixture nobody can read is one nobody can check. Index a named `GAINS` table by colour instead, derived from the RGGB tile for a mosaic and from the plane for a linear image. Refs #163 --- crates/gamut-dng/benches/codec.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 1e38c1ff..83cddb19 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -10,10 +10,10 @@ //! # What is inside the timed region, and what is not //! //! **Inside**, for every benchmark: the codec call itself, the allocation and growth of the buffer -//! it produces, and that buffer's teardown. Every closure below returns `()` and drops its result -//! explicitly, because divan otherwise defers a returned value's drop until after timing — which -//! would charge gamut nothing for freeing a decoded image while the SDK, whose `dng_negative` -//! destructor runs inside its own call, pays in full. +//! it produces, and that buffer's teardown. Every closure below returns `()`, so its result is +//! released where it was made rather than handed back to divan — which defers a returned value's +//! drop until after timing, and would therefore charge gamut nothing for freeing a decoded image +//! while the SDK, whose `dng_negative` destructor runs inside its own call, pays in full. //! //! **Outside**, for every benchmark: synthesising the sensor samples, building the [`RawImage`] //! and [`CameraProfile`], and encoding the DNG (or the bare lossless-JPEG stream) that the decode @@ -212,6 +212,9 @@ const CASES: [Case; 6] = [ /// the two cannot share one generator because they produce different things — that bench needs /// packed bytes for `gamut-deflate`, this one needs `u16` samples for a [`RawImage`]. fn sensor_samples(planes: u32) -> Vec { + /// Per-colour gain, R/G/B, as a fraction of full scale. + const GAINS: [f64; 3] = [0.42, 0.70, 0.31]; + let max = f64::from((1u32 << BITS) - 1); let mut samples = Vec::with_capacity((WIDTH * HEIGHT * planes) as usize); for y in 0..HEIGHT { @@ -221,18 +224,18 @@ fn sensor_samples(planes: u32) -> Vec { let dx = f64::from(x) / f64::from(WIDTH) - 0.5; let dy = f64::from(y) / f64::from(HEIGHT) - 0.5; let falloff = 1.0 - 1.4 * (dx * dx + dy * dy); - // A green photosite collects roughly twice what red and blue do; for a linear - // image the same gains index the interleaved planes. - let channel = if planes == 1 { - (x % 2, y % 2) + // A green photosite collects roughly twice what red and blue do. In a CFA mosaic + // the RGGB tile decides the colour; in a linear image the interleaved plane does. + let colour = if planes == 1 { + match (x % 2, y % 2) { + (0, 0) => 0, // R + (1, 1) => 2, // B + _ => 1, // G + } } else { - (plane % 2, plane / 2) - }; - let gain = match channel { - (0, 0) => 0.42, // R - (1, 1) => 0.31, // B - _ => 0.70, // G + plane as usize }; + let gain = GAINS[colour.min(GAINS.len() - 1)]; // Deterministic shot-noise stand-in, a few percent of full scale. let hash = ((y * WIDTH + x) * planes + plane).wrapping_mul(2_654_435_761) >> 11; let noise = f64::from(hash % 2048) / 2048.0 - 0.5; From a589d6143d25b122d799f41a5ce249514e2dadcf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:44:48 -0400 Subject: [PATCH 06/24] style(dng): pad the benchmark's case column through the formatter The fixture table's first column did not line up. `{case:<26}` sets a width on the formatter, but both `Display` impls wrote through `write_str`/`write!`, which go straight to the underlying buffer and ignore width and alignment -- so every row was ragged and the table was harder to read than the plain text beside it. `Formatter::pad` is the method that honours those flags. `Case` has to build its composite string first, which costs an allocation in a function that runs once per case outside every timed region. Refs #163 --- crates/gamut-dng/benches/codec.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 83cddb19..fb63e5f6 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -123,7 +123,9 @@ impl Photometry { impl std::fmt::Display for Photometry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { + // `pad`, not `write_str`: the latter goes straight to the underlying buffer and drops the + // formatter's width and alignment, so a `{:<26}` column would not line up. + f.pad(match self { Photometry::Cfa => "cfa", Photometry::LinearRaw => "linear-raw", }) @@ -161,12 +163,14 @@ impl Case { impl std::fmt::Display for Case { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let scheme = match self.compression { - Compression::Uncompressed => "uncompressed", - Compression::Deflate => "deflate", - Compression::LosslessJpeg => "lossless-jpeg", - other => return write!(f, "{}/{other:?}", self.photometry), + Compression::Uncompressed => "uncompressed".to_string(), + Compression::Deflate => "deflate".to_string(), + Compression::LosslessJpeg => "lossless-jpeg".to_string(), + other => format!("{other:?}"), }; - write!(f, "{}/{scheme}", self.photometry) + // `pad`, not `write!`: `write!` writes through to the buffer and ignores the formatter's + // width, so the fixture table's `{case:<26}` column would not align. + f.pad(&format!("{}/{scheme}", self.photometry)) } } From 3ec14d6a4d390b6a2327907bbab97c6f70afae40 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 04:44:55 -0400 Subject: [PATCH 07/24] docs(dng): record the two defects the codec benchmark found A harness that is never read is worth nothing, so its first run is written down where the crate's other measurements live. Lossless JPEG is the outlier at both ends. Decode is ~60x slower than the reference implementation while every other scheme is within 1.25x, which localises the cost to `decode_symbol`'s linear scan of the whole code table rather than to decode overhead (#583). And a CFA file gets *larger* when the scheme is turned on, because the mosaic goes to the encoder as one full-width component and predictor 1 then differences a red photosite against its green neighbour; the spec's reshape takes the payload from 119.7% of raw to 91.5% (#584). Both quantities are bytes or ratios rather than absolute times, so both reproduce off the box that measured them. Refs #163 --- crates/gamut-dng/STATUS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index bbd70318..4e31b4b3 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -391,6 +391,23 @@ reference encode number exists and none is invented. Encode is reported for gamu two encoders in the same process, which is robust to a loaded machine — throughput in MB/s is a property of the machine that produced it. Run the harness on the box you care about. +**What the harness found, on its first run.** Two defects, both filed rather than fixed here — a +benchmark that measures the codec is not the place to change it: + +- **#583, decode speed.** Every decode path is within 1.25× of the SDK *except* lossless JPEG, + which is ~60× slower. `lossless_jpeg::decode_symbol` scans the whole 256-entry code table once + per candidate bit length, so a symbol costs ~1000 comparisons where the reference implementation + spends one table probe. The isolation is the evidence: nothing else is out by more than a + quarter. +- **#584, CFA lossless-JPEG size.** `cfa/lossless-jpeg` is *larger* than `cfa/uncompressed` + (157.4 % of the raw samples against 137.7 %), because the encoder hands the mosaic to + `lossless_jpeg::encode` as one full-width component, so predictor 1 differences a red photosite + against its green neighbour. Declaring the same samples as `(width / 2, height, 2)` — the + reshape DNG 1.7.1.0 p. 20 describes, needing no sample reordering and already readable by this + crate's decoder — takes the payload from 119.7 % to 91.5 % of raw. + +Both are byte- or ratio-quantities rather than absolute times, so both reproduce off this box. + ## Deferred / out of scope Each deferred item plugs into the same IFD-tree/chunk pipeline and oracles the shipped features From 86dc515ba7d38665b163cd82f4af141bf743e063 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:43:54 -0400 Subject: [PATCH 08/24] test(dng): price the oracle's lossless-JPEG export path The codestream comparison in the codec benchmark carried one residual bias that was described rather than measured: crossing the FFI boundary costs the Adobe DNG SDK a `malloc`, a `memcpy` out of its spool buffer and a copy into a `Vec`, none of which gamut's single `Vec` pays. Add `decode_lossless_jpeg_extent`, which runs the identical `DecodeLosslessJPEG` into the identical spool buffer and stops before those copies, so the gap between the two entry points is the export path and nothing else. A differential test pins that the two reach the same decode. Also rename the memory-stream oracle test to what it asserts: it compares the stage-1 *extent*, not the pixels, because the entry point it covers deliberately exports none. --- crates/gamut-dng/src/lossless_jpeg.rs | 15 ++++++ tooling/gamut-dng-oracle/src/lib.rs | 49 ++++++++++++++++++-- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 26 +++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/gamut-dng/src/lossless_jpeg.rs b/crates/gamut-dng/src/lossless_jpeg.rs index 83004cea..339de9a7 100644 --- a/crates/gamut-dng/src/lossless_jpeg.rs +++ b/crates/gamut-dng/src/lossless_jpeg.rs @@ -1491,6 +1491,21 @@ mod tests { } } + /// The oracle's non-exporting entry point (`decode_lossless_jpeg_extent`) reaches the + /// same decode as the exporting one: same stream in, same sample count out. The codec + /// benchmark times the pair against each other to price the FFI export path, and that + /// subtraction is only a price if the two decodes are otherwise identical. + #[test] + fn sdk_extent_entry_counts_the_same_samples_as_the_exporting_entry() { + let samples = test_samples(8, 8, 3, 16); + let stream = encode(&samples, 8, 8, 3, 16).expect("encode"); + let exported = + gamut_dng_oracle::decode_lossless_jpeg(&stream, samples.len()).expect("SDK decode"); + let counted = gamut_dng_oracle::decode_lossless_jpeg_extent(&stream, samples.len()) + .expect("SDK extent decode"); + assert_eq!(counted, exported.len()); + } + #[test] fn sdk_matches_every_predictor() { for predictor in 1..=7u8 { diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index 1dc42596..77159c6c 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -53,6 +53,16 @@ unsafe extern "C" { out_len: *mut usize, ) -> c_int; + /// Decodes the same bare lossless-JPEG stream as `gdng_decode_lossless_jpeg` but stops at + /// the spooler, reporting only how many samples the SDK produced; `0` on success, else the + /// SDK error code. Nothing is allocated for the caller, so there is nothing to free. + fn gdng_decode_lossless_jpeg_extent( + data: *const u8, + len: usize, + expected_samples: usize, + out_len: *mut usize, + ) -> c_int; + /// Decodes the DNG in `data`/`len` from memory and reports the stage-1 image's geometry and /// sample count without exporting the samples; `0` on success, else the SDK error code. /// Nothing is allocated for the caller, so there is nothing to free. @@ -385,6 +395,38 @@ pub fn decode_lossless_jpeg(stream: &[u8], expected_samples: usize) -> Result` call into the identical +/// spool buffer and differ only in what happens afterwards: [`decode_lossless_jpeg`] must +/// `malloc` a buffer, `memcpy` the spool into it and copy that into a `Vec` to cross the FFI +/// boundary, and this one does none of those. Timing the pair therefore measures the export path +/// and nothing else, which is how `cargo bench -p gamut-dng --bench codec` quantifies the one +/// residual bias in its codestream comparison instead of merely asserting it is small. +/// +/// # Errors +/// +/// Returns an error message (with the SDK's numeric error code) if the SDK cannot decode the +/// stream, or if it produces a different number of samples than `expected_samples`. +pub fn decode_lossless_jpeg_extent( + stream: &[u8], + expected_samples: usize, +) -> Result { + let mut len: usize = 0; + // SAFETY: `stream` outlives the call and the shim only reads `stream.len()` bytes from it; + // `len` is a live local and the shim allocates nothing for us. + let code = unsafe { + gdng_decode_lossless_jpeg_extent(stream.as_ptr(), stream.len(), expected_samples, &mut len) + }; + if code != 0 { + return Err(format!( + "Adobe DNG SDK could not decode the lossless JPEG (code {code})" + )); + } + Ok(len) +} + #[cfg(test)] mod tests { use super::*; @@ -412,10 +454,11 @@ mod tests { ); } - /// The memory-stream decode reaches the same stage-1 image as the file-stream one, so the - /// entry point a benchmark times is not a cheaper, different decode. + /// The memory-stream decode reports the same stage-1 extent as the file-stream one, so the + /// entry point a benchmark times is not a cheaper, different decode. It compares extents and + /// not pixels because the entry point deliberately exports no pixels. #[test] - fn in_memory_decode_reaches_the_same_image_as_the_file_decode() { + fn in_memory_decode_reports_the_same_extent_as_the_file_decode() { let bytes = sample_file("05_PGTM2_unsigned8.dng").expect("sample DNG present"); let exported = read_raw_dng(&bytes).expect("file-stream decode"); let extent = decode_dng_in_memory(&bytes).expect("memory-stream decode"); diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index d8761259..beba759a 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -302,6 +302,32 @@ extern "C" int gdng_decode_lossless_jpeg(const uint8_t *data, size_t len, size_t } } +// Decodes the same bare lossless-JPEG stream as `gdng_decode_lossless_jpeg` but stops at the +// spooler: it reports how many samples the SDK produced and exports none of them. The only +// difference between the two entry points is the FFI export path (the `malloc` plus the `memcpy` +// out of the spool buffer), so timing them against each other measures that export cost and +// nothing else. Returns `dng_error_none` on success or the SDK error code. +extern "C" int gdng_decode_lossless_jpeg_extent(const uint8_t *data, size_t len, + size_t expected_samples, size_t *out_len) { + *out_len = 0; + try { + dng_stream stream(data, static_cast(len)); + buffer_spooler spooler; + uint32 byte_count = static_cast(expected_samples * sizeof(uint16_t)); + DecodeLosslessJPEG(stream, spooler, byte_count, byte_count, false, + static_cast(len)); + if (spooler.bytes.size() != byte_count) { + return dng_error_bad_format; + } + *out_len = spooler.bytes.size() / sizeof(uint16_t); + return dng_error_none; + } catch (const dng_exception &except) { + return except.ErrorCode(); + } catch (...) { + return dng_error_unknown; + } +} + // Releases a buffer returned by `gdng_read_raw` / `gdng_read_linear` / // `gdng_decode_lossless_jpeg`. // Returns the camera-neutral coordinates the reference implementation derives for the DNG at From 2cf50204c2641467eb89fcbc208706875a280fb0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:43:54 -0400 Subject: [PATCH 09/24] perf(dng): interleave each codec pair into one benchmark The two implementations sat in separate divan benchmarks, which run in name order, so every reference case was measured minutes away from its counterpart. On a shared machine that drifts, a ratio measured minutes apart is not a ratio: across two runs of the split harness a 1.24x Deflate figure moved to below 1.0x. Take the implementation as a benchmark argument instead, naming the arguments so divan's own name sort keeps the members of a pair adjacent. The codestream benchmark gains a third arm, `adobe-sdk-no-export`, whose distance from `adobe-sdk` prices the FFI export path. Put the IFD-0 preview volume into gamut's divan counter for the whole-file decode and for encode, both of which handle the preview while the SDK's stage-1 read does not. The median-time column is then the uncorrected ratio and the throughput column the preview-corrected one, so no reader has a subtraction to do. The fixture table prints both volumes and the factor. --- crates/gamut-dng/benches/codec.rs | 363 ++++++++++++++++++++++-------- 1 file changed, 269 insertions(+), 94 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index fb63e5f6..76958f43 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -22,33 +22,62 @@ //! //! # Is the gamut-versus-SDK comparison fair? //! -//! Two comparisons are published, and their biases point in *opposite* directions, so together -//! they bracket the truth rather than flattering one side. +//! Every asymmetry between the two implementations is either **removed** or **measured**. None is +//! left as an adjective. //! -//! `decode_dng_*` — **the SDK is favoured, by a stated and computable margin.** Both sides parse -//! the same in-memory bytes: [`gamut_dng_oracle::decode_dng_in_memory`] exists precisely so the -//! reference implementation is not charged for a temporary file or for the export `memcpy` that -//! crossing the FFI boundary would otherwise need (see its docs). What remains is that -//! `DngDecoder::decode` is a *whole-file* decode and `ReadStage1Image` is not: gamut additionally -//! decodes IFD 0's uncompressed RGB preview and reconstructs the metadata, work the SDK's stage-1 -//! read skips entirely. The preview's size is exact and not a guess — `⌊w/2⌋ × ⌊h/2⌋ × 3` bytes -//! against the raw's `w × h × planes × 2` — so the fixture table prints it per case and the -//! handicap can be read off directly. It is not normalised away because gamut exposes no -//! raw-image-only decode entry point, and inventing one to make a benchmark look better would be -//! the wrong direction of causation. +//! **Neither side pays for the FFI boundary on the way in.** +//! [`gamut_dng_oracle::decode_dng_in_memory`] hands the SDK a `dng_stream` over the caller's own +//! bytes, so the reference implementation parses the very buffer gamut parses: no temporary file, +//! no import copy on either side. //! -//! `decode_lossless_jpeg_*` — **gamut is favoured, by a smaller margin.** Here the subjects match -//! exactly: the same bare SOF3 stream in, the same interleaved `Vec` out, no container work -//! on either side. The residual bias is the FFI export path -//! ([`gamut_dng_oracle::decode_lossless_jpeg`] spools into a `std::vector`, copies that into a -//! `malloc`d buffer, and copies *that* into a `Vec`), which charges the SDK two extra passes over -//! the sample volume that gamut's single `Vec` does not pay. Those are memory-bandwidth passes, -//! not entropy decoding, so this is the tighter of the two comparisons — but it is a bias, and it -//! runs the other way. +//! **Neither side pays for it on the way out, in the container comparison.** That entry point +//! reports the decoded image's *extent* and exports no samples, so the SDK is not charged for a +//! `malloc` + `memcpy` that only exists because the caller is in Rust. //! -//! There is no `encode_adobe_sdk`: the oracle shim wraps the SDK's reader, not its writer, so no -//! reference encode number exists to compare against and none is fabricated. Encode throughput is -//! reported for gamut alone, across the same matrix. +//! **The one asymmetry left in `decode_dng` is the preview, and the throughput column corrects +//! for it.** `DngDecoder::decode` is a *whole-file* decode and `ReadStage1Image` is not: gamut +//! additionally unpacks IFD 0's uncompressed RGB preview and reconstructs the metadata. The +//! preview's volume is exact — `⌊w/2⌋ × ⌊h/2⌋ × 3` bytes against the raw's `w × h × planes × 2` — +//! so this file applies the **counter rule** below and the fixture table prints the factor per +//! case. It is not normalised away by changing the codec: gamut exposes no raw-image-only decode +//! entry point, and inventing one to make a benchmark look better would be the wrong direction of +//! causation. +//! +//! **The one asymmetry left in `decode_lossless_jpeg` is the export path, and a third arm prices +//! it.** [`gamut_dng_oracle::decode_lossless_jpeg`] spools into a `std::vector`, copies that into +//! a `malloc`d buffer and copies *that* into a `Vec`; gamut fills one `Vec`. Rather than assert +//! that the difference is small, `adobe-sdk-no-export` runs the identical decode into the +//! identical spool buffer and stops there, so the gap between the two SDK arms **is** the export +//! cost, measured on the same box in the same run. +//! +//! # The counter rule +//! +//! Every benchmark's counter is **the pixel volume that implementation actually moves**: +//! +//! - the raw sample volume for every SDK arm and for gamut's bare-codestream decode; and +//! - the raw sample volume **plus the IFD-0 preview** for gamut's whole-file DNG decode and for +//! gamut's DNG encode, both of which also handle the preview. +//! +//! So in `decode_dng` the **median-time** column is the *uncorrected* comparison and the +//! **throughput** column is the *preview-corrected* one — a reader has no subtraction to do. The +//! correction charges preview bytes at the raw path's per-byte rate, which is close to exact on +//! the uncompressed cases (both paths just move bytes) and generous to gamut on the compressed +//! ones (where a raw byte costs far more than a preview byte), so on those rows the corrected +//! ratio is a *lower bound* on gamut's true one. In `decode_lossless_jpeg` all three arms share +//! the raw volume, so there the two columns say the same thing. +//! +//! # Why each pair is one benchmark +//! +//! `decode_dng` and `decode_lossless_jpeg` take the implementation as a benchmark *argument* +//! rather than living in a benchmark each. divan runs benchmarks in name order, so two separate +//! benchmarks would measure every reference case minutes away from its counterpart — and on a +//! shared machine that drifts, a ratio measured minutes apart is not a ratio. As arguments the +//! pair members run back to back, under the same instantaneous load. The argument names are +//! ordered so that divan's own name sort keeps them adjacent. +//! +//! There is no `encode` arm for the SDK: the oracle shim wraps the SDK's reader, not its writer, +//! so no reference encode number exists to compare against and none is fabricated. Encode +//! throughput is reported for gamut alone, across the same matrix. use divan::counter::BytesCount; use divan::{Bencher, black_box}; @@ -154,10 +183,17 @@ impl Case { out } - /// Raw sample volume in bytes — the throughput denominator for every benchmark. + /// Raw sample volume in bytes — the denominator every SDK arm is quoted against. fn raw_bytes(self) -> usize { raw_bytes(self.photometry) } + + /// Pixel volume gamut moves for this case: the raw samples *plus* the IFD-0 preview, which + /// `DngDecoder::decode` unpacks and `DngEncoder::encode` derives. See the counter rule in + /// this file's header. + fn gamut_bytes(self) -> usize { + self.raw_bytes() + preview_bytes() + } } impl std::fmt::Display for Case { @@ -207,6 +243,128 @@ const CASES: [Case; 6] = [ }, ]; +/// Which implementation one `decode_dng` measurement runs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DngImpl { + /// gamut's `DngDecoder::decode` — a whole-file decode, preview and metadata included. + Gamut, + /// The Adobe DNG SDK: parse → build negative → `ReadStage1Image`, over the same bytes, from + /// memory, exporting nothing. + AdobeSdk, +} + +impl std::fmt::Display for DngImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `pad`, not `write_str`, so a width on the formatter survives — see `Photometry`. + f.pad(match self { + DngImpl::Gamut => "gamut", + DngImpl::AdobeSdk => "adobe-sdk", + }) + } +} + +/// One `decode_dng` measurement: a matrix cell decoded by one implementation. +#[derive(Clone, Copy)] +struct DngJob { + case: Case, + imp: DngImpl, +} + +impl std::fmt::Display for DngJob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.pad(&format!("{} {}", self.case, self.imp)) + } +} + +/// Every case, each decoded by both implementations. The two arms of a pair are emitted next to +/// each other *and* sort next to each other by name, so divan measures them back to back. +const DNG_JOBS: [DngJob; CASES.len() * 2] = dng_jobs(); + +/// Builds [`DNG_JOBS`]: the cross product of [`CASES`] with both implementations. +const fn dng_jobs() -> [DngJob; CASES.len() * 2] { + let mut jobs = [DngJob { + case: CASES[0], + imp: DngImpl::Gamut, + }; CASES.len() * 2]; + let mut index = 0; + while index < CASES.len() { + jobs[index * 2] = DngJob { + case: CASES[index], + imp: DngImpl::Gamut, + }; + jobs[index * 2 + 1] = DngJob { + case: CASES[index], + imp: DngImpl::AdobeSdk, + }; + index += 1; + } + jobs +} + +/// Which implementation one `decode_lossless_jpeg` measurement runs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum JpegImpl { + /// gamut's `lossless_jpeg::decode`, filling one `Vec`. + Gamut, + /// The Adobe DNG SDK's `DecodeLosslessJPEG`, exported across the FFI boundary. + AdobeSdk, + /// The same SDK decode, stopping at the spool buffer. The gap to `AdobeSdk` is the export + /// path's cost and nothing else. + AdobeSdkNoExport, +} + +impl std::fmt::Display for JpegImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.pad(match self { + JpegImpl::Gamut => "gamut", + JpegImpl::AdobeSdk => "adobe-sdk", + JpegImpl::AdobeSdkNoExport => "adobe-sdk-no-export", + }) + } +} + +/// One `decode_lossless_jpeg` measurement: a photometry decoded by one implementation. +#[derive(Clone, Copy)] +struct JpegJob { + photometry: Photometry, + imp: JpegImpl, +} + +impl std::fmt::Display for JpegJob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.pad(&format!("{} {}", self.photometry, self.imp)) + } +} + +/// Both photometries, each decoded by all three arms, adjacent by construction and by name sort. +const JPEG_JOBS: [JpegJob; PHOTOMETRIES.len() * 3] = jpeg_jobs(); + +/// Builds [`JPEG_JOBS`]: the cross product of [`PHOTOMETRIES`] with all three arms. +const fn jpeg_jobs() -> [JpegJob; PHOTOMETRIES.len() * 3] { + let mut jobs = [JpegJob { + photometry: PHOTOMETRIES[0], + imp: JpegImpl::Gamut, + }; PHOTOMETRIES.len() * 3]; + let arms = [ + JpegImpl::Gamut, + JpegImpl::AdobeSdk, + JpegImpl::AdobeSdkNoExport, + ]; + let mut photometry = 0; + while photometry < PHOTOMETRIES.len() { + let mut arm = 0; + while arm < arms.len() { + jobs[photometry * arms.len() + arm] = JpegJob { + photometry: PHOTOMETRIES[photometry], + imp: arms[arm], + }; + arm += 1; + } + photometry += 1; + } + jobs +} + /// A sensor-like frame: a smooth illumination falloff, a per-CFA-channel gain, and deterministic /// per-photosite noise, interleaved across `planes`. /// @@ -301,42 +459,56 @@ fn lossless_jpeg_stream(photometry: Photometry) -> Vec { /// Prints the byte volumes each measured region moves, so a throughput number can be read against /// what it is a throughput *of* — including the preview volume that separates gamut's whole-file -/// decode from the SDK's stage-1 read. +/// decode from the SDK's stage-1 read, and the factor that volume puts into gamut's counter. fn print_fixture_table() { println!( "\nDNG codec fixtures, {WIDTH}x{HEIGHT} at {BITS}-bit (bytes):\n\n\ - {:<26} {:>12} {:>12} {:>8} {:>12} {:>10}", - "case", "raw samples", "encoded DNG", "of raw", "IFD0 preview", "of raw" + {:<26} {:>12} {:>12} {:>8} {:>12} {:>8} {:>12} {:>8}", + "case", + "raw samples", + "encoded DNG", + "of raw", + "IFD0 preview", + "of raw", + "gamut vol.", + "/ raw" ); for case in CASES { let raw = case.raw_bytes(); let encoded = case.encoded().len(); let preview = preview_bytes(); + let gamut = case.gamut_bytes(); println!( - "{case:<26} {raw:>12} {encoded:>12} {:>7.1}% {preview:>12} {:>9.1}%", + "{case:<26} {raw:>12} {encoded:>12} {:>7.1}% {preview:>12} {:>7.1}% {gamut:>12} {:>8.3}", encoded as f64 / raw as f64 * 100.0, preview as f64 / raw as f64 * 100.0, + gamut as f64 / raw as f64, ); } println!( - "\n`decode_dng_gamut` decodes the whole file — raw image, that IFD-0 preview and the\n\ - metadata; `decode_dng_adobe_sdk` reads the raw image only. The \"IFD0 preview / of raw\"\n\ - column is the pixel volume of that difference. `decode_lossless_jpeg_*` has no such gap:\n\ - same stream in, same samples out.\n" + "\n`decode_dng gamut` decodes the whole file — raw image, that IFD-0 preview and the\n\ + metadata; `decode_dng adobe-sdk` reads the raw image only, from the same bytes, and\n\ + exports nothing. The counters differ by exactly the preview column, so in\n\ + `decode_dng` the median-time column is the uncorrected ratio and the throughput column\n\ + is the preview-corrected one. `decode_lossless_jpeg` needs no correction: same stream\n\ + in, same samples out, one counter for all three arms — and the gap between its\n\ + `adobe-sdk` and `adobe-sdk-no-export` arms is the FFI export path, priced rather than\n\ + assumed.\n" ); } /// Encode: `DngEncoder::encode` over a prepared raw image and profile. /// /// Timed: preview derivation, sample packing and compression, IFD-tree layout, and the growth and -/// teardown of the output buffer. Not timed: building the raw image and the profile. +/// teardown of the output buffer. Not timed: building the raw image and the profile. The counter +/// is the raw volume plus the preview, because the encoder derives the preview too. #[divan::bench(args = CASES)] fn encode_gamut(bencher: Bencher, case: Case) { let raw = case.raw(); let profile = profile(); let encoder = encoder(case.compression); bencher - .counter(BytesCount::new(case.raw_bytes())) + .counter(BytesCount::new(case.gamut_bytes())) .bench_local(|| { let mut out = Vec::new(); encoder @@ -346,75 +518,78 @@ fn encode_gamut(bencher: Bencher, case: Case) { }); } -/// Decode, gamut: `DngDecoder::decode` over a prepared DNG. +/// Whole-file DNG decode, both implementations, interleaved: gamut's `DngDecoder::decode` and the +/// Adobe DNG SDK's parse → `ReadStage1Image`, over the *same* in-memory bytes. /// -/// Timed: container parse, raw-image decode, IFD-0 preview decode, metadata reconstruction, and -/// the teardown of everything decoded. Not timed: producing the DNG bytes. -#[divan::bench(args = CASES)] -fn decode_dng_gamut(bencher: Bencher, case: Case) { - let bytes = case.encoded(); - let decoder = DngDecoder::new(); - bencher - .counter(BytesCount::new(case.raw_bytes())) - .bench_local(|| { - drop(black_box( - decoder.decode(black_box(&bytes)).expect("decode"), - )); - }); -} - -/// Decode, Adobe DNG SDK: parse → build negative → `ReadStage1Image`, over the *same* bytes, from -/// memory. +/// Timed, gamut: container parse, raw-image decode, IFD-0 preview decode, metadata +/// reconstruction, and the teardown of everything decoded. Timed, SDK: everything the reference +/// implementation does to materialise the raw image, including the negative's teardown, which +/// runs inside the C++ call. Not timed, either side: producing the DNG bytes — and by +/// construction of [`gamut_dng_oracle::decode_dng_in_memory`], no temporary file and no FFI +/// export copy. /// -/// Timed: everything the reference implementation does to materialise the raw image, including -/// the negative's teardown. Not timed: producing the DNG bytes — and, by construction of -/// [`gamut_dng_oracle::decode_dng_in_memory`], no temporary file and no FFI export copy. See this -/// file's header for the residual asymmetry against `decode_dng_gamut`. -#[divan::bench(args = CASES)] -fn decode_dng_adobe_sdk(bencher: Bencher, case: Case) { - let bytes = case.encoded(); - bencher - .counter(BytesCount::new(case.raw_bytes())) - .bench_local(|| { - // No `drop` to place: `DecodedExtent` is plain `Copy` data, because the SDK's own - // teardown already ran — inside the call, and so inside this timed region. - black_box( - gamut_dng_oracle::decode_dng_in_memory(black_box(&bytes)).expect("SDK decode"), - ); - }); +/// The counters differ by the preview, deliberately: see this file's counter rule. +#[divan::bench(args = DNG_JOBS)] +fn decode_dng(bencher: Bencher, job: DngJob) { + let bytes = job.case.encoded(); + match job.imp { + DngImpl::Gamut => { + let decoder = DngDecoder::new(); + bencher + .counter(BytesCount::new(job.case.gamut_bytes())) + .bench_local(|| { + drop(black_box( + decoder.decode(black_box(&bytes)).expect("decode"), + )); + }); + } + DngImpl::AdobeSdk => { + bencher + .counter(BytesCount::new(job.case.raw_bytes())) + .bench_local(|| { + // No `drop` to place: `DecodedExtent` is plain `Copy` data, because the SDK's + // own teardown already ran — inside the call, and so inside this timed region. + black_box( + gamut_dng_oracle::decode_dng_in_memory(black_box(&bytes)) + .expect("SDK decode"), + ); + }); + } + } } -/// Lossless-JPEG codestream decode, gamut: `lossless_jpeg::decode` over a bare SOF3 stream. +/// Bare lossless-JPEG (SOF3) codestream decode, all three arms, interleaved: gamut, the Adobe DNG +/// SDK exporting its samples across the FFI boundary, and the same SDK decode stopping at the +/// spool buffer. /// -/// Timed: marker parse, Huffman + predictor decode, and the teardown of the sample buffer. Not -/// timed: encoding the stream. -#[divan::bench(args = PHOTOMETRIES)] -fn decode_lossless_jpeg_gamut(bencher: Bencher, photometry: Photometry) { - let stream = lossless_jpeg_stream(photometry); - bencher - .counter(BytesCount::new(raw_bytes(photometry))) - .bench_local(|| { +/// Timed: marker parse, Huffman and predictor decode, and the teardown of the sample buffer — +/// plus, for `adobe-sdk`, the export path (spool → `malloc`d buffer → `Vec`). Not timed: +/// encoding the stream. The gap between the two SDK arms is that export path and nothing else, +/// which is how this file quantifies its one remaining bias rather than describing it. +#[divan::bench(args = JPEG_JOBS)] +fn decode_lossless_jpeg(bencher: Bencher, job: JpegJob) { + let stream = lossless_jpeg_stream(job.photometry); + let expected = (WIDTH * HEIGHT * job.photometry.planes()) as usize; + let bencher = bencher.counter(BytesCount::new(raw_bytes(job.photometry))); + match job.imp { + JpegImpl::Gamut => bencher.bench_local(|| { drop(black_box( lossless_jpeg::decode(black_box(&stream)).expect("decode"), )); - }); -} - -/// Lossless-JPEG codestream decode, Adobe DNG SDK: `DecodeLosslessJPEG` over the *same* stream. -/// -/// Timed: the SDK's decode plus the FFI export path (spool vector → `malloc`d buffer → `Vec`), -/// which is two passes over the sample volume more than gamut pays. That bias favours gamut and -/// is the reason this file publishes two comparisons rather than one. -#[divan::bench(args = PHOTOMETRIES)] -fn decode_lossless_jpeg_adobe_sdk(bencher: Bencher, photometry: Photometry) { - let stream = lossless_jpeg_stream(photometry); - let expected = (WIDTH * HEIGHT * photometry.planes()) as usize; - bencher - .counter(BytesCount::new(raw_bytes(photometry))) - .bench_local(|| { + }), + JpegImpl::AdobeSdk => bencher.bench_local(|| { drop(black_box( gamut_dng_oracle::decode_lossless_jpeg(black_box(&stream), expected) .expect("SDK decode"), )); - }); + }), + JpegImpl::AdobeSdkNoExport => bencher.bench_local(|| { + // Returns a `usize`; there is nothing allocated for the caller to release, which is + // the whole point of this arm. + black_box( + gamut_dng_oracle::decode_lossless_jpeg_extent(black_box(&stream), expected) + .expect("SDK decode"), + ); + }), + } } From fd5a03f892272cc1e93ab641aeeeb0f66d827895 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 05:43:54 -0400 Subject: [PATCH 10/24] docs(dng): publish every decode row the harness measures The isolation claim for #583 was stated as "every decode path is within 1.25x except lossless JPEG", and the two rows that contradict it -- both uncompressed cases, at 1.8x and 2.4x -- were absent from the table. Publish the whole matrix, both runs, with the preview-corrected column beside it, and rest the isolation on the codestream pair instead: that pair carries no container asymmetry and its one residual bias is now measured at under 2%. Quote #584 against a single denominator throughout. Read in sequence the previous bullet switched from the whole file to the codestream mid-sentence, which inflates the remedy about fivefold; on the file denominator the reshape lands 6.2% below the uncompressed baseline. Record that the fixture table must not become a codec gate: the margin is a property of frame-uniform synthetic gains, and pinning an encoder requirement to one synthetic fixture is the failure a benchmark harness exists to avoid. --- crates/gamut-dng/STATUS.md | 137 ++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 34 deletions(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 4e31b4b3..93fa79f0 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -368,45 +368,114 @@ while the SDK's `dng_negative` destructor runs inside its own call. Fixture synt `RawImage`/`CameraProfile` build, and the encode that produces the bytes a decode benchmark reads are all outside it. Nothing touches the filesystem. -**Whether the comparison is fair.** Two comparisons are published and their biases point in -opposite directions, which is what makes the pair usable: - -- `decode_dng_*` **favours the SDK, by a margin the harness prints.** Both sides parse the same - in-memory bytes: the oracle gained a timed entry point (`decode_dng_in_memory`) that opens no - temporary file and skips the FFI export `memcpy`, so the reference implementation is not charged - for the shim. What remains is that `DngDecoder::decode` is a *whole-file* decode while - `ReadStage1Image` is not — gamut also unpacks IFD 0's uncompressed RGB preview and rebuilds the - metadata. The preview's size is exact (`⌊w/2⌋ × ⌊h/2⌋ × 3` against the raw's `w × h × planes × - 2`, i.e. 37.5 % of a 16-bit CFA frame), so the fixture table prints it per case. It is not - normalised away: gamut exposes no raw-image-only decode entry point, and adding one so a - benchmark reads better would be the wrong direction of causation. -- `decode_lossless_jpeg_*` **favours gamut, by less.** Same bare SOF3 stream in, same interleaved - `Vec` out, no container work either side; the residual bias is the oracle's export path - (spool vector → `malloc`d buffer → `Vec`), two memory-bandwidth passes gamut does not pay. - -There is no `encode_adobe_sdk`: the oracle shim wraps the SDK's *reader*, not its writer, so no -reference encode number exists and none is invented. Encode is reported for gamut alone. +**Whether the comparison is fair.** Every asymmetry between the two implementations is either +removed or measured; none is left as an adjective. + +- **Neither side pays for the FFI boundary on the way in.** The oracle gained a timed entry point, + `decode_dng_in_memory`, which hands the SDK a `dng_stream` over the caller's own bytes: no + temporary file, no import copy, the same buffer gamut parses. +- **Neither side pays for it on the way out, in the container comparison.** That entry point + reports the decoded image's extent and exports no samples, so the reference implementation is not + charged for a `malloc` + `memcpy` that exists only because the caller is in Rust. +- **The one asymmetry left in `decode_dng` is the IFD-0 preview** (plus the metadata + reconstruction), which `DngDecoder::decode` performs and `ReadStage1Image` does not. The + preview's volume is exact — `⌊w/2⌋ × ⌊h/2⌋ × 3` against the raw's `w × h × planes × 2`, i.e. + 37.5 % of a 16-bit CFA frame and 12.5 % of a `LinearRaw` one — so the harness puts it in gamut's + divan counter: the **median-time** column is the uncorrected ratio and the **throughput** column + is the preview-corrected one, and a reader has no subtraction to do. The correction charges + preview bytes at the raw path's per-byte rate, which is close to exact on the uncompressed cases + (both paths just move bytes) and generous to gamut on the compressed ones, where the corrected + ratio is therefore a lower bound. It is not normalised away by changing the codec: gamut exposes + no raw-image-only decode entry point, and adding one so a benchmark reads better would be the + wrong direction of causation. +- **The one asymmetry left in `decode_lossless_jpeg` is the FFI export path, and a third arm + prices it.** `adobe-sdk-no-export` runs the identical `DecodeLosslessJPEG` into the + identical spool buffer and stops before the `malloc`/`memcpy`/`Vec` copies. Measured, that path + costs the reference implementation **0.3–1.9 %** across two runs, so the codestream comparison is + fair to within 2 % — a number rather than a claim. + +**Each pair is one benchmark, not two.** `decode_dng` and `decode_lossless_jpeg` take the +implementation as a divan *argument* rather than living in a benchmark each. Separate benchmarks +run in name order, which measures every reference case minutes away from its counterpart; on a +shared machine that drifts, a ratio measured minutes apart is not a ratio. As arguments the pair +members run back to back under the same instantaneous load, and the argument names are ordered so +divan's own name sort keeps them adjacent. + +There is no `encode` arm for the SDK: the oracle shim wraps the SDK's *reader*, not its writer, so +no reference encode number exists and none is invented. Encode is reported for gamut alone. **No absolute figures are pinned here.** Unlike the #196 numbers above — a ratio comparison between two encoders in the same process, which is robust to a loaded machine — throughput in MB/s is a property of the machine that produced it. Run the harness on the box you care about. -**What the harness found, on its first run.** Two defects, both filed rather than fixed here — a -benchmark that measures the codec is not the place to change it: - -- **#583, decode speed.** Every decode path is within 1.25× of the SDK *except* lossless JPEG, - which is ~60× slower. `lossless_jpeg::decode_symbol` scans the whole 256-entry code table once - per candidate bit length, so a symbol costs ~1000 comparisons where the reference implementation - spends one table probe. The isolation is the evidence: nothing else is out by more than a - quarter. -- **#584, CFA lossless-JPEG size.** `cfa/lossless-jpeg` is *larger* than `cfa/uncompressed` - (157.4 % of the raw samples against 137.7 %), because the encoder hands the mosaic to - `lossless_jpeg::encode` as one full-width component, so predictor 1 differences a red photosite - against its green neighbour. Declaring the same samples as `(width / 2, height, 2)` — the - reshape DNG 1.7.1.0 p. 20 describes, needing no sample reordering and already readable by this - crate's decoder — takes the payload from 119.7 % to 91.5 % of raw. - -Both are byte- or ratio-quantities rather than absolute times, so both reproduce off this box. +**What the harness measured.** Two runs, 100 samples each, 512×384 at 16 bits, in a quiet window on +a shared machine (one-minute load average bracketed by `uptime`: 3.18 → 2.91 for run A, 2.89 → 2.85 +for run B). Ratios only; every row of the matrix is here, including the ones that do not fit a +tidy story. + +Whole-file decode, gamut ÷ Adobe DNG SDK (median time; "corrected" divides out the preview volume +gamut also unpacks): + +| `decode_dng` case | run A | run B | corrected A | corrected B | +| -------------------------- | ------ | ------ | ----------- | ----------- | +| `cfa/uncompressed` | 2.44× | 2.39× | 1.78× | 1.74× | +| `cfa/deflate` | 0.97× | 0.94× | 0.71× | 0.69× | +| `cfa/lossless-jpeg` | 60.1× | 57.5× | 43.7× | 41.8× | +| `linear-raw/uncompressed` | 1.85× | 1.76× | 1.65× | 1.57× | +| `linear-raw/deflate` | 0.95× | 0.96× | 0.85× | 0.85× | +| `linear-raw/lossless-jpeg` | 59.0× | 58.8× | 52.4× | 52.2× | + +Bare codestream decode, which carries no container asymmetry — same SOF3 stream in, same samples +out, one counter for all three arms: + +| `decode_lossless_jpeg` case | gamut ÷ SDK, A | gamut ÷ SDK, B | SDK export path, A | B | +| --------------------------- | -------------- | -------------- | ------------------ | ----- | +| `cfa` | 56.7× | 56.4× | 1.9 % | 0.3 % | +| `linear-raw` | 58.6× | 57.5× | 1.1 % | 0.7 % | + +Interleaving is what makes the small ratios usable at all. Measured as two separate benchmark +groups, this harness previously reported `cfa/deflate` at 1.24× and `linear-raw/deflate` at 1.25×; +interleaved, both sit **below** 1.0× — gamut decodes Deflate DNGs slightly *faster* than the +reference implementation. A 25–30 % shift in a 1.2× ratio is the measurement moving, not the codec. + +**What the harness found.** Two defects, both filed rather than fixed here — a benchmark that +measures the codec is not the place to change it: + +- **#583, lossless-JPEG decode speed.** The isolating evidence is the **codestream pair** above, + which carries no container asymmetry and whose one residual bias is priced at under 2 %: there + gamut is **56–59× slower** than the reference implementation, in both runs, on both photometries. + `lossless_jpeg::decode_symbol` scans the whole 256-entry code table once per candidate bit + length, so a symbol costs ~1000 comparisons where the reference implementation spends one table + probe. + + The whole-file rows are published above in full rather than filtered to the ones that agree. Two + of them are not close to parity: `cfa/uncompressed` at 2.4× and `linear-raw/uncompressed` at + 1.8×. The preview correction explains part of that (1.7–1.8× and 1.6× corrected); the remainder + is the fixed IFD and metadata reconstruction, which does not scale with the frame and therefore + dominates exactly where the raw path is little more than a `memcpy`. This harness measures that + gap and does not attribute it further — and #583's isolation does not rest on it. +- **#584, CFA lossless-JPEG size.** Quoted throughout against **one** denominator, the raw sample + volume (393 216 bytes for this fixture): `cfa/uncompressed` writes a 541 440-byte file (137.7 %) + and `cfa/lossless-jpeg` a 618 800-byte one (157.4 %), so turning compression on makes the file + **14.3 % larger**. The encoder hands the mosaic to `lossless_jpeg::encode` as one full-width + component, so predictor 1 differences a red photosite against its green neighbour. Declaring the + same samples as `(width / 2, height, 2)` — the reshape DNG 1.7.1.0 p. 20 describes, which needs + no sample reordering and which this crate's decoder already reads — takes the codestream from + 470 576 bytes to 359 888. Both files carry an identical 148 224 bytes of preview and directory, + so the reshaped file would be 508 112 bytes, **129.2 %** of raw: **6.2 % smaller than the + uncompressed file**, not the ~33 % a reader gets by chaining the codestream ratio onto the file + ratio. + +**The fixture table is not a codec gate, and should not become one.** #584's verification section +proposes pinning the encoder to the sizes this harness prints. It should not be: the margin is a +property of frame-uniform synthetic gains — one gain per CFA colour across the entire frame, which +is what makes the interleaved-component reshape win so cleanly — and pinning an encoder requirement +to a single synthetic fixture is precisely the failure a benchmark harness exists to avoid. +Re-measure on the real-camera corpus (`mise run fetch-dng-samples`, then `mise run test-dng-real`) +before the encoder changes, and gate on that if anything is to be gated. + +Both defects are byte- or ratio-quantities rather than absolute times, so both reproduce off this +box. ## Deferred / out of scope From 63b17d21543cdcb5d1c2a4d5def8bc28e0ee5506 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 06:07:43 -0400 Subject: [PATCH 11/24] docs(dng): describe the benchmark's counter rule where it is stated Two header sentences still described the previous shape: that every counter is the raw sample volume, and that the preview asymmetry belongs to two benchmarks that no longer exist by those names. --- crates/gamut-dng/benches/codec.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 76958f43..49fd824b 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -3,9 +3,9 @@ //! `cargo bench -p gamut-dng --bench codec` first prints a fixture table — the byte volumes each //! measured region moves — then runs divan throughput benchmarks over the codec matrix the crate //! ships: **uncompressed**, **Deflate** and **lossless JPEG**, each for **CFA** and **LinearRaw** -//! photometry. The counter is always the *raw sample volume* (`samples × 2` bytes), so encode, -//! decode and the reference implementation are all quoted against the same denominator and are -//! directly comparable. +//! photometry. Every counter is a *pixel volume* in bytes — the raw sample volume (`samples × 2`), +//! plus the IFD-0 preview for the two gamut benchmarks that also handle it. See the counter rule +//! below: it is what makes the throughput column of `decode_dng` a preview-corrected comparison. //! //! # What is inside the timed region, and what is not //! @@ -436,9 +436,9 @@ fn raw_bytes(photometry: Photometry) -> usize { /// Bytes of IFD-0 preview a decode of one of these fixtures additionally unpacks: /// `⌊w/2⌋ × ⌊h/2⌋ × 3`, uncompressed RGB8 (the encoder always writes the preview uncompressed). /// -/// This is the whole of the `decode_dng_gamut` / `decode_dng_adobe_sdk` asymmetry that is -/// attributable to pixels; the rest is IFD and metadata reconstruction, which does not scale with -/// the frame. +/// This is the whole of the `decode_dng` gamut-versus-SDK asymmetry that is attributable to +/// pixels; the rest is IFD and metadata reconstruction, which does not scale with the frame. It +/// is what [`Case::gamut_bytes`] adds to the raw volume. fn preview_bytes() -> usize { (WIDTH / 2 * (HEIGHT / 2) * 3) as usize } From 371cab1e7f5109305ed576a77f7605a371c9c01b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:20:37 -0400 Subject: [PATCH 12/24] refactor(dng): read the oracle's negative through one shared flow The in-memory decode entry point repeated the SDK's parse -> post-parse -> validate -> make-negative -> parse -> post-parse -> read-stage-1 sequence that `read_negative` already ran for the file-stream entry point, so the two could drift apart silently. Take a `dng_stream &` in `read_negative` and keep the path form as a two-line overload that opens the file and delegates. Opening the stream is then the whole of the difference between the two flows, which is what the benchmark's fairness claim rests on. --- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 26 +++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index beba759a..32a80877 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -28,10 +28,11 @@ namespace { -// Parses `path` into a negative and reads its stage-1 (raw) image. Shared by the entry points. -dng_error_code read_negative(const char *path, dng_host &host, dng_info &info, +// Parses `stream` into a negative and reads its stage-1 (raw) image. This is the SDK flow every +// decoding entry point here runs, in one place: the file-stream and memory-stream entry points +// differ only in which `dng_stream` they hand it, so neither can drift from the other. +dng_error_code read_negative(dng_stream &stream, dng_host &host, dng_info &info, AutoPtr &negative) { - dng_file_stream stream(path); info.Parse(host, stream); info.PostParse(host); if (!info.IsValidDNG()) { @@ -44,6 +45,13 @@ dng_error_code read_negative(const char *path, dng_host &host, dng_info &info, return dng_error_none; } +// The same flow over the file at `path`. Opening the stream is the whole of the difference. +dng_error_code read_negative(const char *path, dng_host &host, dng_info &info, + AutoPtr &negative) { + dng_file_stream stream(path); + return read_negative(stream, host, info, negative); +} + // Copies a 16-bit-typed `dng_image` into a freshly `malloc`d interleaved `uint16` buffer, // filling the out-parameters. Returns `dng_error_none` on success. dng_error_code copy_short_image(const dng_image *image, uint32_t *out_w, uint32_t *out_h, @@ -167,16 +175,12 @@ extern "C" int gdng_decode_dng_in_memory(const uint8_t *data, size_t len, uint32 try { dng_host host; dng_info info; + AutoPtr negative; dng_stream stream(data, static_cast(len)); - info.Parse(host, stream); - info.PostParse(host); - if (!info.IsValidDNG()) { - return dng_error_bad_format; + dng_error_code rc = read_negative(stream, host, info, negative); + if (rc != dng_error_none) { + return rc; } - AutoPtr negative(host.Make_dng_negative()); - negative->Parse(host, stream, info); - negative->PostParse(host, stream, info); - negative->ReadStage1Image(host, stream, info); const dng_image *image = negative->Stage1Image(); if (image == nullptr) { return dng_error_unknown; From fca423e7dde6c77797bf1e70729e7b12064bb029 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:20:37 -0400 Subject: [PATCH 13/24] fix(dng): reject an oversized stream in the oracle's extent decode `gdng_decode_lossless_jpeg_extent` narrowed its `size_t` length to the `uint32` `dng_stream` takes without checking it fits, so a buffer above 4 GiB would have been decoded from a silently truncated view. Its sibling `gdng_decode_dng_in_memory` guards the same narrowing; this one did not. --- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 32a80877..ac3fbdf3 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -314,6 +314,10 @@ extern "C" int gdng_decode_lossless_jpeg(const uint8_t *data, size_t len, size_t extern "C" int gdng_decode_lossless_jpeg_extent(const uint8_t *data, size_t len, size_t expected_samples, size_t *out_len) { *out_len = 0; + // `dng_stream` takes a 32-bit length; a longer buffer would be silently truncated. + if (len > 0xFFFFFFFFu) { + return dng_error_bad_format; + } try { dng_stream stream(data, static_cast(len)); buffer_spooler spooler; From 202a548f0889152850858c13ef11f0886191264a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:20:51 -0400 Subject: [PATCH 14/24] test(dng): pin the oracle's in-memory decode where a gate runs it The pin for `decode_dng_in_memory` sat in `tooling/gamut-dng-oracle`, which is excluded from the workspace, so it never ran in automation and could not detect the drift it was written to detect. Its sibling `decode_lossless_jpeg_extent` was already pinned inside `gamut-dng`. Move it there too: encode a DNG with this crate, decode it both ways, and assert the memory-stream entry point reports the extent the exporting one produces and that this is the encoded image's own extent. Note on both oracle entry points where their pin now lives, and why it is not beside them. --- crates/gamut-dng/tests/roundtrip.rs | 30 +++++++++++++++++++++++++++++ tooling/gamut-dng-oracle/src/lib.rs | 27 ++++++++------------------ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/gamut-dng/tests/roundtrip.rs b/crates/gamut-dng/tests/roundtrip.rs index 5c45a9c5..dbe270e8 100644 --- a/crates/gamut-dng/tests/roundtrip.rs +++ b/crates/gamut-dng/tests/roundtrip.rs @@ -159,6 +159,36 @@ fn gamut_and_adobe_decoders_agree() { } } +/// The oracle's memory-stream, no-export decode (`decode_dng_in_memory`) reaches the same +/// stage-1 image as the file-stream, exporting one (`read_raw_dng`) over a file this crate wrote. +/// +/// This pins the entry point `cargo bench -p gamut-dng --bench codec` times the Adobe SDK with. +/// That benchmark's whole claim is that its two sides decode the same thing, so the cheaper entry +/// point must not be a cheaper *decode*. It lives here, in the gated crate, rather than beside the +/// entry point in `tooling/gamut-dng-oracle`: that crate is excluded from the workspace, so a test +/// there never runs in automation and could not detect the drift it is written to detect. +#[test] +fn adobe_in_memory_decode_matches_the_file_decode() { + let (dng, raw) = encode_cfa(ByteOrder::LittleEndian, 64, 48, 16); + let exported = gamut_dng_oracle::read_raw_dng(&dng).expect("adobe file-stream decode"); + let extent = gamut_dng_oracle::decode_dng_in_memory(&dng).expect("adobe memory-stream decode"); + assert_eq!( + (extent.width, extent.height, extent.planes, extent.samples), + ( + exported.width, + exported.height, + exported.planes, + exported.samples.len() + ), + "the timed entry point must report the extent the exporting one produces" + ); + assert_eq!( + extent.samples, + raw.samples().len(), + "and that extent must be the encoded image's" + ); +} + #[test] fn tiled_roundtrips_through_gamut() { use gamut_dng::Compression; diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index 77159c6c..95b7f0c9 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -327,6 +327,10 @@ pub struct DecodedExtent { /// /// Use [`read_raw_dng`] when you want the samples; this one when you want the time. /// +/// That the two agree is pinned by `adobe_in_memory_decode_matches_the_file_decode` in +/// `gamut-dng`'s `tests/roundtrip.rs`, not here: this crate is excluded from the workspace, so a +/// test in it never runs in automation. +/// /// # Errors /// /// Returns an error message (with the SDK's numeric error code) if the SDK cannot parse the bytes @@ -405,6 +409,10 @@ pub fn decode_lossless_jpeg(stream: &[u8], expected_samples: usize) -> Result Date: Thu, 10 Sep 2026 07:20:51 -0400 Subject: [PATCH 15/24] fix(dng): print only measured numbers from the codec benchmark Three of the harness's published quantities were not what they claimed. The preview correction modelled the IFD-0 preview at its stored width, one byte per sample. The decoder surfaces every sub-image as `SubImageData::Decoded( Vec)` whatever the stored depth, so the buffer it allocates, fills and tears down is twice that. Model the width the decoder materialises. The correction charges preview bytes at the raw path's per-byte rate, which holds only where both paths do comparable work per byte. Under Deflate and lossless JPEG a raw byte carries entropy-coding work a preview byte does not, so there the arithmetic yields a lower bound on gamut's ratio rather than a measurement of it. Suppress it on those rows -- gamut's counter is the raw volume and both divan columns are uncorrected -- and print which rows those are, in the fixture table and in the epilogue an operator actually reads. The export-path arm bounds the FFI copy; it does not price it. Its magnitude sits at the measurement floor, where its sign is not resolved. Say bound. Also record, at the epilogue, that a pair's arms cannot be interleaved per sample: one always runs first and the bias points one way for a whole run, so a published ratio is the mean of one run each way (`--sortr name` reverses divan's sort and with it the arm order). --- crates/gamut-dng/benches/codec.rs | 218 ++++++++++++++++++++++-------- 1 file changed, 160 insertions(+), 58 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 49fd824b..e27ef86b 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -4,8 +4,9 @@ //! measured region moves — then runs divan throughput benchmarks over the codec matrix the crate //! ships: **uncompressed**, **Deflate** and **lossless JPEG**, each for **CFA** and **LinearRaw** //! photometry. Every counter is a *pixel volume* in bytes — the raw sample volume (`samples × 2`), -//! plus the IFD-0 preview for the two gamut benchmarks that also handle it. See the counter rule -//! below: it is what makes the throughput column of `decode_dng` a preview-corrected comparison. +//! plus the IFD-0 preview on the gamut benchmarks that handle it and where charging for it is a +//! measurement rather than a bound. See the counter rule below, and the fixture table's epilogue, +//! which names the rows where the preview correction is applied and the rows where it is not. //! //! # What is inside the timed region, and what is not //! @@ -34,37 +35,48 @@ //! reports the decoded image's *extent* and exports no samples, so the SDK is not charged for a //! `malloc` + `memcpy` that only exists because the caller is in Rust. //! -//! **The one asymmetry left in `decode_dng` is the preview, and the throughput column corrects -//! for it.** `DngDecoder::decode` is a *whole-file* decode and `ReadStage1Image` is not: gamut -//! additionally unpacks IFD 0's uncompressed RGB preview and reconstructs the metadata. The -//! preview's volume is exact — `⌊w/2⌋ × ⌊h/2⌋ × 3` bytes against the raw's `w × h × planes × 2` — -//! so this file applies the **counter rule** below and the fixture table prints the factor per -//! case. It is not normalised away by changing the codec: gamut exposes no raw-image-only decode -//! entry point, and inventing one to make a benchmark look better would be the wrong direction of -//! causation. +//! **The one asymmetry left in `decode_dng` is the preview, and on the rows where correcting for +//! it is a measurement the throughput column does so.** `DngDecoder::decode` is a *whole-file* +//! decode and `ReadStage1Image` is not: gamut additionally unpacks IFD 0's uncompressed RGB +//! preview and reconstructs the metadata. The preview's volume is exact — see +//! [`preview_decode_bytes`], which models it at the width the *decoder* materialises, not the +//! width the file stores it at — so this file applies the **counter rule** below and the fixture +//! table prints, per case, that volume and whether the correction was applied. It is not +//! normalised away by changing the codec: gamut exposes no raw-image-only decode entry point, and +//! inventing one to make a benchmark look better would be the wrong direction of causation. //! -//! **The one asymmetry left in `decode_lossless_jpeg` is the export path, and a third arm prices +//! **The one asymmetry left in `decode_lossless_jpeg` is the export path, and a third arm bounds //! it.** [`gamut_dng_oracle::decode_lossless_jpeg`] spools into a `std::vector`, copies that into //! a `malloc`d buffer and copies *that* into a `Vec`; gamut fills one `Vec`. Rather than assert //! that the difference is small, `adobe-sdk-no-export` runs the identical decode into the -//! identical spool buffer and stops there, so the gap between the two SDK arms **is** the export -//! cost, measured on the same box in the same run. +//! identical spool buffer and stops there, so the gap between the two SDK arms **bounds** the +//! export cost, measured on the same box in the same run. Read that gap as a magnitude only: it +//! sits at this harness's measurement floor, where its *sign* is not resolved, so what it +//! supports is "the codestream comparison is fair to within the bound", not "the export path +//! costs the SDK X". //! //! # The counter rule //! //! Every benchmark's counter is **the pixel volume that implementation actually moves**: //! -//! - the raw sample volume for every SDK arm and for gamut's bare-codestream decode; and -//! - the raw sample volume **plus the IFD-0 preview** for gamut's whole-file DNG decode and for -//! gamut's DNG encode, both of which also handle the preview. +//! - the raw sample volume for every SDK arm and for gamut's bare-codestream decode; +//! - the raw sample volume **plus the preview the encoder derives** for `encode_gamut`, which has +//! no reference arm and so is not a comparison at all; and +//! - for gamut's whole-file DNG decode, the raw sample volume plus the preview the decoder +//! materialises — **but only on the rows where charging preview bytes at the raw path's +//! per-byte rate is a measurement rather than a bound.** //! -//! So in `decode_dng` the **median-time** column is the *uncorrected* comparison and the -//! **throughput** column is the *preview-corrected* one — a reader has no subtraction to do. The -//! correction charges preview bytes at the raw path's per-byte rate, which is close to exact on -//! the uncompressed cases (both paths just move bytes) and generous to gamut on the compressed -//! ones (where a raw byte costs far more than a preview byte), so on those rows the corrected -//! ratio is a *lower bound* on gamut's true one. In `decode_lossless_jpeg` all three arms share -//! the raw volume, so there the two columns say the same thing. +//! That proviso is the whole of the rule. On the **uncompressed** rows both paths do the same +//! kind of work per byte — unpack a stored integer and store it — so the correction is a +//! measurement, it is applied, and in `decode_dng` the **median-time** column is then the +//! uncorrected comparison while the **throughput** column is the preview-corrected one. On the +//! **compressed** rows a raw byte costs far more than a preview byte (the preview is stored +//! uncompressed whatever the raw scheme is), so the same arithmetic would credit gamut with more +//! than the preview actually costs: a *lower bound* printed where a reader will take a +//! measurement. There the correction is **suppressed** — gamut's counter is the raw volume, both +//! columns say the same uncorrected thing, and the fixture table and its epilogue say which rows +//! those are. In `decode_lossless_jpeg` all three arms share the raw volume, so no correction +//! arises. //! //! # Why each pair is one benchmark //! @@ -78,6 +90,22 @@ //! There is no `encode` arm for the SDK: the oracle shim wraps the SDK's reader, not its writer, //! so no reference encode number exists to compare against and none is fabricated. Encode //! throughput is reported for gamut alone, across the same matrix. +//! +//! # Alternate the arm order between runs +//! +//! Adjacent is not simultaneous. divan cannot interleave two arms *per sample*, so inside every +//! pair one arm always runs first, and the second inherits whatever the first left in the caches +//! and in the frequency governor. That is a real bias and it points one way for a whole run. +//! divan's sort is reversible, so the control already exists: take one run each way and publish +//! the mean of the two. +//! +//! ```text +//! cargo bench -p gamut-dng --bench codec # reference arm first +//! cargo bench -p gamut-dng --bench codec -- --sortr name # gamut arm first +//! ``` +//! +//! The epilogue printed under the fixture table repeats this, because that is where an operator +//! reads it rather than here. use divan::counter::BytesCount; use divan::{Bencher, black_box}; @@ -188,11 +216,39 @@ impl Case { raw_bytes(self.photometry) } - /// Pixel volume gamut moves for this case: the raw samples *plus* the IFD-0 preview, which - /// `DngDecoder::decode` unpacks and `DngEncoder::encode` derives. See the counter rule in - /// this file's header. - fn gamut_bytes(self) -> usize { - self.raw_bytes() + preview_bytes() + /// Pixel volume gamut's **encoder** moves for this case: the raw samples plus the preview it + /// derives. `encode_gamut` has no reference arm, so this is a description of the work, not a + /// correction to a comparison. + fn gamut_encode_bytes(self) -> usize { + self.raw_bytes() + preview_encode_bytes() + } + + /// Pixel volume gamut's **decoder** is credited with for this case — the counter rule's one + /// conditional. + /// + /// `DngDecoder::decode` always unpacks the IFD-0 preview that `ReadStage1Image` does not, so + /// the raw volume alone understates its work. But the correction charges preview bytes at the + /// *raw path's* per-byte rate, and that only holds where the two paths do comparable work per + /// byte. So the preview is added on the rows where [`Case::preview_correction_is_measured`] + /// holds and withheld everywhere else, rather than printing a bound a reader would take as a + /// measurement. + fn gamut_decode_bytes(self) -> usize { + if self.preview_correction_is_measured() { + self.raw_bytes() + preview_decode_bytes() + } else { + self.raw_bytes() + } + } + + /// Whether charging this case's preview bytes at its raw path's per-byte rate is a + /// measurement. + /// + /// It is exactly when the raw path is uncompressed: then both the raw samples and the + /// (always uncompressed) preview are unpacked and stored, at comparable cost per byte. Under + /// Deflate or lossless JPEG a raw byte carries Huffman/LZ77 work the preview byte does not, + /// so the same arithmetic would over-credit gamut and the correction is suppressed. + fn preview_correction_is_measured(self) -> bool { + matches!(self.compression, Compression::Uncompressed) } } @@ -433,14 +489,31 @@ fn raw_bytes(photometry: Photometry) -> usize { (WIDTH * HEIGHT * photometry.planes()) as usize * size_of::() } -/// Bytes of IFD-0 preview a decode of one of these fixtures additionally unpacks: -/// `⌊w/2⌋ × ⌊h/2⌋ × 3`, uncompressed RGB8 (the encoder always writes the preview uncompressed). +/// IFD-0 preview samples for one of these fixtures: `⌊w/2⌋ × ⌊h/2⌋ × 3`, RGB (the encoder +/// collapses each `2 × 2` block into one pixel, and always writes the preview uncompressed). +fn preview_samples() -> usize { + (WIDTH / 2 * (HEIGHT / 2) * 3) as usize +} + +/// Bytes of preview `DngEncoder::encode` derives: one byte per sample, because `preview:: +/// raw_preview` builds a `Vec` and the encoder writes it at 8 bits per sample. +fn preview_encode_bytes() -> usize { + preview_samples() * size_of::() +} + +/// Bytes of preview `DngDecoder::decode` **materialises**: two per sample, not one. +/// +/// The preview is *stored* at 8 bits, but the decoder surfaces every sub-image as +/// `SubImageData::Decoded(Vec)` — one `u16` per sample whatever the IFD's bit depth — so the +/// buffer it allocates, fills and tears down is twice the stored size. Modelling the stored width +/// here would under-state the work by half and, because the preview sits on gamut's side of the +/// comparison, would make gamut look slower than it is. /// /// This is the whole of the `decode_dng` gamut-versus-SDK asymmetry that is attributable to /// pixels; the rest is IFD and metadata reconstruction, which does not scale with the frame. It -/// is what [`Case::gamut_bytes`] adds to the raw volume. -fn preview_bytes() -> usize { - (WIDTH / 2 * (HEIGHT / 2) * 3) as usize +/// is what [`Case::gamut_decode_bytes`] adds to the raw volume where the counter rule allows. +fn preview_decode_bytes() -> usize { + preview_samples() * size_of::() } /// A bare lossless-JPEG (SOF3) stream over one photometry's fixture samples — one component for @@ -459,56 +532,84 @@ fn lossless_jpeg_stream(photometry: Photometry) -> Vec { /// Prints the byte volumes each measured region moves, so a throughput number can be read against /// what it is a throughput *of* — including the preview volume that separates gamut's whole-file -/// decode from the SDK's stage-1 read, and the factor that volume puts into gamut's counter. +/// decode from the SDK's stage-1 read, and, per case, whether that volume is charged into gamut's +/// counter or withheld because charging it would be a bound rather than a measurement. fn print_fixture_table() { println!( "\nDNG codec fixtures, {WIDTH}x{HEIGHT} at {BITS}-bit (bytes):\n\n\ - {:<26} {:>12} {:>12} {:>8} {:>12} {:>8} {:>12} {:>8}", + {:<26} {:>12} {:>12} {:>8} {:>12} {:>12} {:>8} {:>11}", "case", "raw samples", "encoded DNG", "of raw", - "IFD0 preview", - "of raw", - "gamut vol.", - "/ raw" + "preview", + "decode vol.", + "/ raw", + "correction" ); for case in CASES { let raw = case.raw_bytes(); let encoded = case.encoded().len(); - let preview = preview_bytes(); - let gamut = case.gamut_bytes(); + let preview = preview_decode_bytes(); + let decode_volume = case.gamut_decode_bytes(); + let correction = if case.preview_correction_is_measured() { + "applied" + } else { + "SUPPRESSED" + }; println!( - "{case:<26} {raw:>12} {encoded:>12} {:>7.1}% {preview:>12} {:>7.1}% {gamut:>12} {:>8.3}", + "{case:<26} {raw:>12} {encoded:>12} {:>7.1}% {preview:>12} {decode_volume:>12} \ + {:>8.3} {correction:>11}", encoded as f64 / raw as f64 * 100.0, - preview as f64 / raw as f64 * 100.0, - gamut as f64 / raw as f64, + decode_volume as f64 / raw as f64, ); } - println!( - "\n`decode_dng gamut` decodes the whole file — raw image, that IFD-0 preview and the\n\ - metadata; `decode_dng adobe-sdk` reads the raw image only, from the same bytes, and\n\ - exports nothing. The counters differ by exactly the preview column, so in\n\ - `decode_dng` the median-time column is the uncorrected ratio and the throughput column\n\ - is the preview-corrected one. `decode_lossless_jpeg` needs no correction: same stream\n\ - in, same samples out, one counter for all three arms — and the gap between its\n\ - `adobe-sdk` and `adobe-sdk-no-export` arms is the FFI export path, priced rather than\n\ - assumed.\n" - ); + print!("{FIXTURE_TABLE_EPILOGUE}"); } +/// What an operator has to know to read the table above and the divan output below it, printed +/// where they are read rather than only in this file's header: what the preview correction is, +/// which rows it is applied to, why it is withheld on the rest, and that a published ratio is the +/// mean of two runs taken in opposite arm orders. +const FIXTURE_TABLE_EPILOGUE: &str = " +`decode_dng gamut` decodes the whole file — raw image, the IFD-0 preview and the metadata; +`decode_dng adobe-sdk` reads the raw image only, from the same bytes, and exports nothing. The +`preview` column is the volume gamut's decoder materialises for that preview (two bytes per +sample: every sub-image surfaces as a `Vec`, whatever the stored depth). + +On the `correction: applied` rows that volume is added to gamut's counter, so the median-time +column is the uncorrected comparison and the throughput column is the preview-corrected one. On +the `correction: SUPPRESSED` rows it is not, and BOTH columns are uncorrected. Correcting there +would charge preview bytes at the compressed raw path's per-byte rate — arithmetic that credits +gamut with more than the preview costs, and so yields a lower bound on gamut's true ratio, not a +measurement of it. No number is printed for it, because a printed number is read as measured. Read +the compressed rows as: gamut's figure includes preview and metadata work the SDK arm does not do, +by an amount this harness does not measure. + +`decode_lossless_jpeg` needs no correction: same stream in, same samples out, one counter for all +three arms. The gap between its `adobe-sdk` and `adobe-sdk-no-export` arms bounds the FFI export +path — a magnitude, not a signed cost; it sits at the measurement floor. + +Adjacent is not simultaneous: divan cannot interleave a pair per sample, so one arm always runs +first and the bias points one way for a whole run. Take one run each way and publish the mean: + + cargo bench -p gamut-dng --bench codec # reference arm first + cargo bench -p gamut-dng --bench codec -- --sortr name # gamut arm first + +"; + /// Encode: `DngEncoder::encode` over a prepared raw image and profile. /// /// Timed: preview derivation, sample packing and compression, IFD-tree layout, and the growth and /// teardown of the output buffer. Not timed: building the raw image and the profile. The counter -/// is the raw volume plus the preview, because the encoder derives the preview too. +/// is the raw volume plus the preview the encoder derives (at the 8-bit width it derives it). #[divan::bench(args = CASES)] fn encode_gamut(bencher: Bencher, case: Case) { let raw = case.raw(); let profile = profile(); let encoder = encoder(case.compression); bencher - .counter(BytesCount::new(case.gamut_bytes())) + .counter(BytesCount::new(case.gamut_encode_bytes())) .bench_local(|| { let mut out = Vec::new(); encoder @@ -528,7 +629,8 @@ fn encode_gamut(bencher: Bencher, case: Case) { /// construction of [`gamut_dng_oracle::decode_dng_in_memory`], no temporary file and no FFI /// export copy. /// -/// The counters differ by the preview, deliberately: see this file's counter rule. +/// The two counters differ by the preview volume on the uncompressed rows and are identical on +/// the compressed ones, where that correction is suppressed: see this file's counter rule. #[divan::bench(args = DNG_JOBS)] fn decode_dng(bencher: Bencher, job: DngJob) { let bytes = job.case.encoded(); @@ -536,7 +638,7 @@ fn decode_dng(bencher: Bencher, job: DngJob) { DngImpl::Gamut => { let decoder = DngDecoder::new(); bencher - .counter(BytesCount::new(job.case.gamut_bytes())) + .counter(BytesCount::new(job.case.gamut_decode_bytes())) .bench_local(|| { drop(black_box( decoder.decode(black_box(&bytes)).expect("decode"), From da548319a2acb318220effa06c4732c8058a24e1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:37:16 -0400 Subject: [PATCH 16/24] fix(dng): print which zlib the benchmark's Deflate arm measured The oracle links the system libz dynamically, because the SDK includes unconditionally. So on the two `*/deflate` rows -- and only there -- the reference arm runs code that is not built from source committed to this repository, and which libz it runs is a property of the machine and even of the launcher: cargo puts every build script's native search path on `LD_LIBRARY_PATH`, so `cargo bench` resolves the stock zlib that another dev oracle happens to have built under `target/`, while running the same binary directly resolves the platform's, which on this box is a zlib-ng fork. Inflate implementations differ by more than the margin that decides which side of 1.0 a Deflate ratio falls on, so two correct runs of this harness can disagree on those rows with no defect in either. Print the resolved library -- version plus the path `dladdr` reports, since zlib-ng's compatibility build answers "1.3.1" exactly as stock zlib does -- above the divan output, and say in the epilogue that a Deflate figure travels with it or not at all. --- crates/gamut-dng/benches/codec.rs | 19 +++++++++ tooling/gamut-dng-oracle/src/lib.rs | 37 ++++++++++++++++- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 43 ++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index e27ef86b..7a3b64e0 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -55,6 +55,14 @@ //! supports is "the codestream comparison is fair to within the bound", not "the export path //! costs the SDK X". //! +//! **One measured path is not built from this repository, and it is a Deflate one.** The oracle +//! links the system libz dynamically (the SDK includes `` unconditionally), so on the two +//! `*/deflate` rows the reference arm's speed depends on which libz the machine resolves — +//! stock zlib and a zlib-ng-class fork differ by more than the margin that decides which side of +//! 1.0 those rows fall on. Every other row runs only code built here. The fixture table prints +//! [`gamut_dng_oracle::zlib_identity`] for exactly this reason: a Deflate ratio is not a fact about +//! two codecs unless the library it was taken against travels with it. +//! //! # The counter rule //! //! Every benchmark's counter is **the pixel volume that implementation actually moves**: @@ -564,6 +572,10 @@ fn print_fixture_table() { decode_volume as f64 / raw as f64, ); } + println!( + "\nThe SDK's Deflate arm calls the system zlib: {}.", + gamut_dng_oracle::zlib_identity() + ); print!("{FIXTURE_TABLE_EPILOGUE}"); } @@ -586,6 +598,13 @@ measurement of it. No number is printed for it, because a printed number is read the compressed rows as: gamut's figure includes preview and metadata work the SDK arm does not do, by an amount this harness does not measure. +The `*/deflate` rows are the only ones whose SDK arm runs code from outside this repository: the +oracle links the system libz dynamically, because the SDK includes unconditionally. Which +libz the dynamic linker resolves is a property of the machine, and inflate implementations differ +by more than the margin that decides which side of 1.0 a Deflate ratio falls on. The resolved +library is printed above; publish it with any Deflate figure, and do not compare a Deflate ratio +against one taken on a different libz. Every other row runs only code built here. + `decode_lossless_jpeg` needs no correction: same stream in, same samples out, one counter for all three arms. The gap between its `adobe-sdk` and `adobe-sdk-no-export` arms bounds the FFI export path — a magnitude, not a signed cost; it sits at the measurement floor. diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index 95b7f0c9..ff3bedd3 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -5,7 +5,7 @@ //! → read-stage-1 flow (the same one its `dng_validate` tool uses); it succeeds only if the SDK //! reads the file without error. All `unsafe` FFI is confined to this crate. -use std::ffi::CString; +use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_int}; use std::path::{Path, PathBuf}; @@ -75,6 +75,10 @@ unsafe extern "C" { out_len: *mut usize, ) -> c_int; + /// Returns the identity of the zlib the SDK's Deflate reader calls: its version, and where + /// the loader found it. Static storage duration; valid for the process. + fn gdng_zlib_identity() -> *const c_char; + /// Computes the SDK's `NewRawImageDigest` for the DNG at `path` into `out_digest` (16 bytes); /// `0` on success, else the SDK error code. fn gdng_new_raw_image_digest(path: *const c_char, out_digest: *mut u8) -> c_int; @@ -296,6 +300,37 @@ pub fn read_linear_dng(bytes: &[u8]) -> Result { read_image(bytes, gdng_read_linear, "stage-2 linear") } +/// Identifies the zlib the SDK's Deflate reader calls: its `zlibVersion()` string and, where the +/// loader can report it, the resolved path of the shared object the symbol came from — e.g. +/// `"1.3.1 from /usr/lib64/libz.so.1.3.1.zlib-ng"`. +/// +/// The path is the discriminating part: `zlibVersion()` reports the zlib *API* version, so the +/// zlib-ng compatibility build answers `"1.3.1"` exactly as stock zlib does. +/// +/// `build.rs` links the system libz dynamically (`-lz`) because the SDK includes `` +/// unconditionally. That makes the SDK's Deflate decode the one measured path in this oracle that +/// is **not** built from source committed to this repository: which libz the dynamic linker +/// resolves is a property of the machine, and inflate implementations differ by far more than the +/// margin that decides whether gamut or the reference implementation is faster on a Deflate row. +/// `cargo bench -p gamut-dng --bench codec` prints this next to its fixture table so a Deflate +/// ratio is never published without the library it is a ratio against. +/// +/// Falls back to `"unknown"` if libz returns no string, which it is not documented to do. +#[must_use] +pub fn zlib_identity() -> String { + // SAFETY: the shim returns a pointer to a NUL-terminated string with static storage duration, + // valid for the life of the process; the `CStr` borrow ends before this function returns. + let raw = unsafe { gdng_zlib_identity() }; + if raw.is_null() { + return "unknown".to_string(); + } + // SAFETY: non-null, and as above NUL-terminated and static. + unsafe { CStr::from_ptr(raw) } + .to_str() + .unwrap_or("unknown") + .to_string() +} + /// The extent of an image the Adobe DNG SDK decoded: its geometry and how many samples it holds. /// /// Deliberately carries no pixels. It is what [`decode_dng_in_memory`] reports, and the point of diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index ac3fbdf3..89c90d79 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -3,6 +3,11 @@ // negative, and read its stage-1 (raw) image. If any of that throws, the file is not a valid DNG // the reference implementation accepts. +// `dladdr` (used by `gdng_zlib_identity`) is a GNU extension; glibc hides it otherwise. +#ifndef _GNU_SOURCE +#define _GNU_SOURCE 1 +#endif + #include "dng_auto_ptr.h" #include "dng_camera_profile.h" #include "dng_color_spec.h" @@ -21,9 +26,14 @@ #include "dng_stream.h" #include "dng_tag_types.h" +#include + +#include #include #include #include +#include +#include #include namespace { @@ -92,6 +102,39 @@ dng_error_code copy_short_image(const dng_image *image, uint32_t *out_w, uint32_ } // namespace +// Identifies the zlib the SDK's Deflate reader is actually calling: its `zlibVersion()` string +// followed, where the loader can tell us, by the resolved path of the shared object the symbol +// came from. +// +// The path is the part that matters. `zlibVersion()` reports the zlib API version, so the +// zlib-ng compatibility build answers "1.3.1" exactly as stock zlib does and cannot tell the two +// apart -- while `dladdr` plus `realpath` yields e.g. `/usr/lib64/libz.so.1.3.1.zlib-ng`, which +// can. +// +// This matters to a *measurement*, not to correctness. `build.rs` links the system libz +// dynamically (`-lz`), so the SDK's Deflate decode is the one measured code path in this oracle +// that is not built from source committed to this repository: which libz the dynamic linker +// resolves is a property of the machine. Inflate implementations differ by well over the margin +// that separates "gamut is faster" from "the SDK is faster" on a Deflate row, so a Deflate +// throughput ratio is not interpretable without this string beside it. +// +// The returned pointer has static storage duration and lives for the process. +extern "C" const char *gdng_zlib_identity(void) { + static const std::string identity = [] { + std::string text = zlibVersion(); + Dl_info info; + if (dladdr(reinterpret_cast(&zlibVersion), &info) != 0 && + info.dli_fname != nullptr) { + char resolved[PATH_MAX]; + const char *path = realpath(info.dli_fname, resolved) ? resolved : info.dli_fname; + text += " from "; + text += path; + } + return text; + }(); + return identity.c_str(); +} + // The code gdng_validate returns when the SDK marks the negative damaged (a stored // RawImageDigest/NewRawImageDigest that does not match the image data). The SDK's non-validate // build records this via SetIsDamaged rather than throwing, so it must be surfaced explicitly. From 31f01840a16031aa8383fff0d060c5c734b61f2c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 07:57:15 -0400 Subject: [PATCH 17/24] docs(dng): republish the benchmark's numbers with what they depend on Re-measured across eight runs -- two repetitions of {stock zlib, zlib-ng} x {reference arm first, gamut arm first}, 100 samples each, at loads from 15 to 38, each bracketed by `uptime`. The two Deflate ratios an independent re-measurement could not reproduce are reproduced here, both of them: 0.94 under stock zlib 1.3.1 and 1.17-1.26 under zlib-ng 2.3.3, flat in load and in arm order. Neither figure was wrong. The gamut arm does not move between the two libraries; only the reference arm does, by 1.2-1.3x, which is enough to reverse which side of 1.0 the row falls on. Say so, and say which library each figure belongs to. The lossless-JPEG finding survives -- no fastest-sample ratio below 34x in any run -- but not at the precision "56-59x" claimed from two runs; quote the bound. The export-path figure does not survive at all: over sixteen case-runs it spans -4% to +64%, so publish it as a bound below the run-to-run spread rather than as a cost with a sign. Correct the preview volume to the width the decoder materialises, record that the correction is now suppressed where it would be a lower bound, and withdraw the claim that interleaving accounted for the Deflate shift, which is not this section's to explain. --- crates/gamut-dng/STATUS.md | 158 ++++++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 53 deletions(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 93fa79f0..c37e40f9 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -378,21 +378,41 @@ removed or measured; none is left as an adjective. reports the decoded image's extent and exports no samples, so the reference implementation is not charged for a `malloc` + `memcpy` that exists only because the caller is in Rust. - **The one asymmetry left in `decode_dng` is the IFD-0 preview** (plus the metadata - reconstruction), which `DngDecoder::decode` performs and `ReadStage1Image` does not. The - preview's volume is exact — `⌊w/2⌋ × ⌊h/2⌋ × 3` against the raw's `w × h × planes × 2`, i.e. - 37.5 % of a 16-bit CFA frame and 12.5 % of a `LinearRaw` one — so the harness puts it in gamut's - divan counter: the **median-time** column is the uncorrected ratio and the **throughput** column - is the preview-corrected one, and a reader has no subtraction to do. The correction charges - preview bytes at the raw path's per-byte rate, which is close to exact on the uncompressed cases - (both paths just move bytes) and generous to gamut on the compressed ones, where the corrected - ratio is therefore a lower bound. It is not normalised away by changing the codec: gamut exposes - no raw-image-only decode entry point, and adding one so a benchmark reads better would be the - wrong direction of causation. + reconstruction), which `DngDecoder::decode` performs and `ReadStage1Image` does not. Its volume + is exact, and it is the volume the *decoder materialises*, not the one the file stores: the + preview is written at 8 bits, but every sub-image is surfaced as `SubImageData::Decoded( + Vec)`, so the buffer gamut allocates, fills and frees is `⌊w/2⌋ × ⌊h/2⌋ × 3 × 2` bytes + against the raw's `w × h × planes × 2` — 75 % of a 16-bit CFA frame and 25 % of a `LinearRaw` + one. On the **uncompressed** rows that volume goes into gamut's divan counter, so the + **median-time** column is the uncorrected ratio and the **throughput** column the corrected one. + On the **compressed** rows it does not. Correcting there charges preview bytes at the raw path's + per-byte rate, and under Deflate or lossless JPEG a raw byte carries entropy-coding work a + preview byte does not, so the arithmetic yields a lower bound on gamut's ratio rather than a + measurement of it; the harness prints no number for it and says so, in the fixture table and in + the epilogue below it. Read a compressed row as: gamut's figure includes preview and metadata + work the reference arm does not do, by an amount this harness does not measure. It is not + normalised away by changing the codec: gamut exposes no raw-image-only decode entry point, and + adding one so a benchmark reads better would be the wrong direction of causation. - **The one asymmetry left in `decode_lossless_jpeg` is the FFI export path, and a third arm - prices it.** `adobe-sdk-no-export` runs the identical `DecodeLosslessJPEG` into the - identical spool buffer and stops before the `malloc`/`memcpy`/`Vec` copies. Measured, that path - costs the reference implementation **0.3–1.9 %** across two runs, so the codestream comparison is - fair to within 2 % — a number rather than a claim. + bounds it.** `adobe-sdk-no-export` runs the identical `DecodeLosslessJPEG` into the + identical spool buffer and stops before the `malloc`/`memcpy`/`Vec` copies. Across sixteen + case-runs the gap between the two SDK arms spans −4 % to +64 %: an effect below this harness's + run-to-run spread on a shared machine, whose *sign* is not resolved. What that supports is a + **bound** — on the runs where neither SDK arm was disturbed the gap is under 3 %, and the + fairness claim needs only that the export path cannot account for a 30×-plus ratio — not a + figure for what the export path costs. Earlier revisions of this section quoted 0.3–1.9 % as + though it were the cost; it was two samples of a quantity at the noise floor. +- **One measured path is not built from this repository, and it is a Deflate one.** `build.rs` + links the system libz dynamically (`-lz`), because the SDK includes `` unconditionally. + So on the two `*/deflate` rows — and only there — the reference arm's speed is a property of the + machine, and even of the launcher: `cargo bench` puts every build script's native search path on + `LD_LIBRARY_PATH`, so it resolves whichever stock zlib another dev oracle has built under + `target/`, while running the same binary directly resolves the platform's. Measured here, that + choice moves the reference arm by 1.2–1.3× and moves gamut's arm not at all — enough to reverse + which side of 1.0 a Deflate row falls on, with no defect in either implementation. The harness + therefore prints the resolved library above its divan output (`zlibVersion()` plus the path + `dladdr` reports, since zlib-ng's compatibility build answers `"1.3.1"` exactly as stock zlib + does), and a Deflate figure below travels with the library it was taken against or not at all. **Each pair is one benchmark, not two.** `decode_dng` and `decode_lossless_jpeg` take the implementation as a divan *argument* rather than living in a benchmark each. Separate benchmarks @@ -401,59 +421,90 @@ shared machine that drifts, a ratio measured minutes apart is not a ratio. As ar members run back to back under the same instantaneous load, and the argument names are ordered so divan's own name sort keeps them adjacent. +Interleaving is kept on that argument alone. An earlier revision of this section also credited it +with a 25–30 % shift in the two Deflate ratios; that attribution is **withdrawn**. Those are the two +rows now known to depend on which libz the machine resolves, an effect of the same magnitude and the +same sign, and this round did not re-run the non-interleaved arrangement under a pinned library, so +the shift is not this section's to explain. + There is no `encode` arm for the SDK: the oracle shim wraps the SDK's *reader*, not its writer, so no reference encode number exists and none is invented. Encode is reported for gamut alone. +**Alternate the arm order between runs.** Adjacent is not simultaneous: divan cannot interleave a +pair *per sample*, so one arm always runs first and inherits nothing while the second inherits the +caches and the frequency governor the first left. divan's sort is reversible, so the control +already exists — `--sortr name` runs the gamut arm first — and a published ratio is the mean of one +run each way. Measured across the eight runs below, the order is worth about a percent, well under +the run-to-run spread; it is corrected for because it is one-directional, not because it is large. + **No absolute figures are pinned here.** Unlike the #196 numbers above — a ratio comparison between two encoders in the same process, which is robust to a loaded machine — throughput in MB/s is a property of the machine that produced it. Run the harness on the box you care about. -**What the harness measured.** Two runs, 100 samples each, 512×384 at 16 bits, in a quiet window on -a shared machine (one-minute load average bracketed by `uptime`: 3.18 → 2.91 for run A, 2.89 → 2.85 -for run B). Ratios only; every row of the matrix is here, including the ones that do not fit a -tidy story. - -Whole-file decode, gamut ÷ Adobe DNG SDK (median time; "corrected" divides out the preview volume -gamut also unpacks): - -| `decode_dng` case | run A | run B | corrected A | corrected B | -| -------------------------- | ------ | ------ | ----------- | ----------- | -| `cfa/uncompressed` | 2.44× | 2.39× | 1.78× | 1.74× | -| `cfa/deflate` | 0.97× | 0.94× | 0.71× | 0.69× | -| `cfa/lossless-jpeg` | 60.1× | 57.5× | 43.7× | 41.8× | -| `linear-raw/uncompressed` | 1.85× | 1.76× | 1.65× | 1.57× | -| `linear-raw/deflate` | 0.95× | 0.96× | 0.85× | 0.85× | -| `linear-raw/lossless-jpeg` | 59.0× | 58.8× | 52.4× | 52.2× | +**What the harness measured.** Eight runs, 100 samples each, 512×384 at 16 bits, on a shared +machine at one-minute load averages of 15 to 38 (bracketed by `uptime` per run): two repetitions of +`{stock zlib, zlib-ng} × {reference arm first, gamut arm first}`. Ratios only, medians unless +marked; every row of the matrix is here, including the ones that do not fit a tidy story, and the +raw divan output for all eight is published with the pull request rather than summarised into these +cells. + +Whole-file decode, gamut ÷ Adobe DNG SDK, median time — uncorrected, which is what the harness now +prints on the compressed rows: + +| `decode_dng` case | stock zlib 1.3.1 | zlib-ng 2.3.3 | +| -------------------------- | --------------------------- | --------------------------- | +| `cfa/uncompressed` | 2.17, 2.27, 2.23, 2.21 | 2.52, 2.26, 2.14, 2.34 | +| `cfa/deflate` | 0.94, 0.94, 0.94, (2.71) | 1.23, 1.25, 1.26, 1.17 | +| `cfa/lossless-jpeg` | 83, 50, 89, (18) | 87, 45, 66, 92 | +| `linear-raw/uncompressed` | 1.73, 1.72, 1.79, 1.79 | 1.84, 1.70, 1.78, 1.82 | +| `linear-raw/deflate` | 1.00, 0.90, 0.97, 0.84 | 1.41, 1.28, 1.28, 1.28 | +| `linear-raw/lossless-jpeg` | 35, 101, 60, 47 | 47, 47, 75, 57 | + +The four cells per column are, in order, `{rep 1, rep 2} × {reference arm first, gamut arm first}`. +The parenthesised `cfa` figures come from the noisiest run in the set (load 36.8); its +*fastest*-sample ratios are 0.97 and 27, in line with the rest. The `uncompressed` rows are quoted +uncorrected here; with the preview correction the harness applies to them, they read 1.24–1.44 and +1.36–1.47 respectively. Bare codestream decode, which carries no container asymmetry — same SOF3 stream in, same samples -out, one counter for all three arms: - -| `decode_lossless_jpeg` case | gamut ÷ SDK, A | gamut ÷ SDK, B | SDK export path, A | B | -| --------------------------- | -------------- | -------------- | ------------------ | ----- | -| `cfa` | 56.7× | 56.4× | 1.9 % | 0.3 % | -| `linear-raw` | 58.6× | 57.5× | 1.1 % | 0.7 % | - -Interleaving is what makes the small ratios usable at all. Measured as two separate benchmark -groups, this harness previously reported `cfa/deflate` at 1.24× and `linear-raw/deflate` at 1.25×; -interleaved, both sit **below** 1.0× — gamut decodes Deflate DNGs slightly *faster* than the -reference implementation. A 25–30 % shift in a 1.2× ratio is the measurement moving, not the codec. +out, one counter for all three arms. Medians are unusable here (gamut's arm is ~100 ms, long enough +to swallow a scheduling event whole), so the fastest-sample ratio is given alongside: + +| `decode_lossless_jpeg` case | gamut ÷ SDK, median | gamut ÷ SDK, fastest sample | +| --------------------------- | ------------------- | --------------------------- | +| `cfa` | 45–92 | 34–61 | +| `linear-raw` | 42–91 | 41–63 | + +**The two Deflate rows depend on a library this repository does not build**, and that is the whole +of a discrepancy an independent re-measurement raised against an earlier revision of this section. +The earlier figures (0.94–0.97, gamut faster) and the independent ones (1.20–1.26, the SDK faster) +are **both correct**, and the eight runs above reproduce both: 0.94 under stock zlib 1.3.1, 1.17–1.26 +under zlib-ng 2.3.3, at every load from 15 to 38 and in both arm orders. The isolating evidence is +that the *gamut* arm does not move between the two — its `cfa/deflate` median is 1.04–1.05 ms under +either library — while the reference arm moves from 0.83 ms to 0.97–1.11 ms. Neither measurement was +wrong; the harness failed to say which inflate implementation it had measured, so two correct runs +looked like a contradiction. It now prints it. **What the harness found.** Two defects, both filed rather than fixed here — a benchmark that measures the codec is not the place to change it: - **#583, lossless-JPEG decode speed.** The isolating evidence is the **codestream pair** above, - which carries no container asymmetry and whose one residual bias is priced at under 2 %: there - gamut is **56–59× slower** than the reference implementation, in both runs, on both photometries. - `lossless_jpeg::decode_symbol` scans the whole 256-entry code table once per candidate bit - length, so a symbol costs ~1000 comparisons where the reference implementation spends one table - probe. + which carries no container asymmetry and whose one residual bias — the FFI export path — is + bounded well below the effect: there gamut is **one and a half to two orders of magnitude + slower** than the reference implementation. Across eight runs, on both photometries and in both + arm orders, no fastest-sample ratio is below **34×** and the medians centre near 50–60×; the + earlier "56–59×" was a two-run figure and is not reproducible to that precision on a loaded box, + but nothing in the eight runs brings the effect near parity. `lossless_jpeg::decode_symbol` scans + the whole 256-entry code table once per candidate bit length, so a symbol costs ~1000 comparisons + where the reference implementation spends one table probe. The whole-file rows are published above in full rather than filtered to the ones that agree. Two - of them are not close to parity: `cfa/uncompressed` at 2.4× and `linear-raw/uncompressed` at - 1.8×. The preview correction explains part of that (1.7–1.8× and 1.6× corrected); the remainder - is the fixed IFD and metadata reconstruction, which does not scale with the frame and therefore - dominates exactly where the raw path is little more than a `memcpy`. This harness measures that - gap and does not attribute it further — and #583's isolation does not rest on it. + of them are not close to parity: `cfa/uncompressed` at 2.1–2.5× and `linear-raw/uncompressed` at + 1.7–1.8×. Those are the rows the preview correction applies to, and corrected they read 1.2–1.4× + and 1.4–1.5×; the remainder is the fixed IFD and metadata reconstruction, which does not scale + with the frame and therefore dominates exactly where the raw path is little more than a `memcpy`. + This harness measures that gap and does not attribute it further — and #583's isolation does not + rest on it. - **#584, CFA lossless-JPEG size.** Quoted throughout against **one** denominator, the raw sample volume (393 216 bytes for this fixture): `cfa/uncompressed` writes a 541 440-byte file (137.7 %) and `cfa/lossless-jpeg` a 618 800-byte one (157.4 %), so turning compression on makes the file @@ -474,8 +525,9 @@ to a single synthetic fixture is precisely the failure a benchmark harness exist Re-measure on the real-camera corpus (`mise run fetch-dng-samples`, then `mise run test-dng-real`) before the encoder changes, and gate on that if anything is to be gated. -Both defects are byte- or ratio-quantities rather than absolute times, so both reproduce off this -box. +#584 is a byte quantity and reproduces anywhere. #583 is a ratio, and the only outside code in its +measured path is the SDK's own lossless-JPEG decoder, built here from the committed SDK source — so +unlike the Deflate rows it does not depend on what the machine has installed. ## Deferred / out of scope From 347c8ade6c3abc7d5275439e6042dcdece693cc9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 08:17:04 -0400 Subject: [PATCH 18/24] docs(dng): call the export-path arm a bound at its own definition Two doc sites still called the gap between the two SDK lossless-JPEG arms the export path's cost. Measured over sixteen case-runs it spans -4% to +64%, so what the pair supports is a bound at the measurement floor, not a signed price. The module header already said so; these did not. --- crates/gamut-dng/benches/codec.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 7a3b64e0..9f6a5626 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -372,8 +372,9 @@ enum JpegImpl { Gamut, /// The Adobe DNG SDK's `DecodeLosslessJPEG`, exported across the FFI boundary. AdobeSdk, - /// The same SDK decode, stopping at the spool buffer. The gap to `AdobeSdk` is the export - /// path's cost and nothing else. + /// The same SDK decode, stopping at the spool buffer. The export path is the only difference + /// between this arm and `AdobeSdk`, so the gap between them bounds its cost — a magnitude, not + /// a signed price: it sits at this harness's measurement floor. AdobeSdkNoExport, } @@ -685,8 +686,8 @@ fn decode_dng(bencher: Bencher, job: DngJob) { /// /// Timed: marker parse, Huffman and predictor decode, and the teardown of the sample buffer — /// plus, for `adobe-sdk`, the export path (spool → `malloc`d buffer → `Vec`). Not timed: -/// encoding the stream. The gap between the two SDK arms is that export path and nothing else, -/// which is how this file quantifies its one remaining bias rather than describing it. +/// encoding the stream. The export path is the only difference between the two SDK arms, so the +/// gap between them bounds this file's one remaining bias rather than leaving it described. #[divan::bench(args = JPEG_JOBS)] fn decode_lossless_jpeg(bencher: Bencher, job: JpegJob) { let stream = lossless_jpeg_stream(job.photometry); From 5e0f33df00dcca3b551600839a99b45d884e4b95 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:50:51 -0400 Subject: [PATCH 19/24] fix(dng): guard the oracle's exporting lossless-JPEG decode too `gdng_decode_lossless_jpeg_extent` rejects a length that does not fit the `uint32` `dng_stream` takes; `gdng_decode_lossless_jpeg` narrowed the same `size_t` unguarded. The guard's own rationale named `gdng_decode_dng_in_memory` as the sibling to match, which is the wrong one: the entry point the extent arm is *timed against* is the exporting decode, and an asymmetric guard is both a difference in what the two accept and a difference inside the measured region. Give the exporting arm the identical check and state the reason at both sites. Unreachable from this crate's fixtures either way -- the streams are kilobytes. --- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 89c90d79..d1b260cb 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -325,6 +325,11 @@ extern "C" int gdng_decode_lossless_jpeg(const uint8_t *data, size_t len, size_t uint16_t **out_data, size_t *out_len) { *out_data = nullptr; *out_len = 0; + // The same narrowing guard `gdng_decode_lossless_jpeg_extent` carries, for the reason stated + // there: the two are timed against each other and must accept exactly the same inputs. + if (len > 0xFFFFFFFFu) { + return dng_error_bad_format; + } try { dng_stream stream(data, static_cast(len)); buffer_spooler spooler; @@ -357,7 +362,10 @@ extern "C" int gdng_decode_lossless_jpeg(const uint8_t *data, size_t len, size_t extern "C" int gdng_decode_lossless_jpeg_extent(const uint8_t *data, size_t len, size_t expected_samples, size_t *out_len) { *out_len = 0; - // `dng_stream` takes a 32-bit length; a longer buffer would be silently truncated. + // `dng_stream` takes a 32-bit length; a longer buffer would be silently truncated. The guard + // has to be identical to `gdng_decode_lossless_jpeg`'s: these two are timed against each other, + // so any check one runs and the other does not is a difference in the measured region as well + // as a difference in what each accepts. if (len > 0xFFFFFFFFu) { return dng_error_bad_format; } From 4c969069fe7f52b1abe4365eb4848417c2831b2f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:51:20 -0400 Subject: [PATCH 20/24] feat(dng): flag a libz the loader took from the build graph The harness printed which zlib the reference arm called, which makes a Deflate ratio interpretable but not reproducible: a resolution that came from the build graph rather than from the platform is one nobody else gets. `cargo` exports every build script's native search path on the runner's library path, and `gamut-dng` dev-depends on `libtiff-oracle`, which builds a `libz.so` of its own -- so `cargo bench -p gamut-dng` alone is enough to measure stock zlib where the same binary run directly measures the platform's zlib-ng, and the two move that row by 1.2-1.3x. Split the resolved path out of the identity string so a caller can test it instead of reading it, and warn when it has a `target` component. --- crates/gamut-dng/benches/codec.rs | 39 +++++++++++++++- tooling/gamut-dng-oracle/src/lib.rs | 30 ++++++++++++ tooling/gamut-dng-oracle/src/oracle_shim.cpp | 49 ++++++++++++++++---- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 9f6a5626..199cceab 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -573,11 +573,48 @@ fn print_fixture_table() { decode_volume as f64 / raw as f64, ); } + print_zlib_identity(); + print!("{FIXTURE_TABLE_EPILOGUE}"); +} + +/// Prints which libz the reference arm's Deflate decode actually called, and flags the case where +/// the loader resolved it out of a build directory. +/// +/// Printing the library is what makes a Deflate ratio interpretable; flagging a build-tree +/// resolution is what makes it *reproducible*. `cargo` puts every build script's native search +/// path on `LD_LIBRARY_PATH`, and this crate's own dev-dependency `libtiff-oracle` builds a +/// `libz.so` under `target/`, so a run launched through `cargo bench` can measure a different +/// inflate implementation from the one the same binary measures when run directly — a difference +/// large enough to move a Deflate row across 1.0. That resolution belongs to whoever's build +/// graph produced it and to nobody else, so it is called out rather than merely recorded. +fn print_zlib_identity() { println!( "\nThe SDK's Deflate arm calls the system zlib: {}.", gamut_dng_oracle::zlib_identity() ); - print!("{FIXTURE_TABLE_EPILOGUE}"); + if gamut_dng_oracle::zlib_path().is_some_and(|path| is_build_tree(&path)) { + print!("{BUILD_TREE_ZLIB_WARNING}"); + } +} + +/// Printed when the loader resolved libz out of a build directory: the `*/deflate` rows still +/// measured something, but not something a reader elsewhere can reproduce. +const BUILD_TREE_ZLIB_WARNING: &str = "\ +WARNING: that libz came out of a build directory, not the platform. `cargo` exports every build +script's native search path on the runner's library path, and this crate dev-depends on +`libtiff-oracle`, which builds a `libz.so` of its own — so the `*/deflate` rows below were taken +against a library that belongs to this build graph and to no one else's. Run the bench binary +under `target/release/deps/` directly to measure against the platform's libz instead, and say +which of the two any published Deflate figure came from. +"; + +/// Whether `path` lies inside a Cargo build directory, i.e. has a `target` component. +/// +/// Deliberately a path test and not a comparison against this build's own `target/`: the loader +/// may resolve a `libz.so` any build script in the graph produced, and every one of those is +/// equally unreproducible for a reader elsewhere. +fn is_build_tree(path: &std::path::Path) -> bool { + path.components().any(|c| c.as_os_str() == "target") } /// What an operator has to know to read the table above and the divan output below it, printed diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index ff3bedd3..3f1f2342 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -79,6 +79,10 @@ unsafe extern "C" { /// the loader found it. Static storage duration; valid for the process. fn gdng_zlib_identity() -> *const c_char; + /// Returns the resolved path of that same zlib alone, or null when the loader cannot report + /// one. Static storage duration; valid for the process. + fn gdng_zlib_path() -> *const c_char; + /// Computes the SDK's `NewRawImageDigest` for the DNG at `path` into `out_digest` (16 bytes); /// `0` on success, else the SDK error code. fn gdng_new_raw_image_digest(path: *const c_char, out_digest: *mut u8) -> c_int; @@ -331,6 +335,32 @@ pub fn zlib_identity() -> String { .to_string() } +/// The resolved path of that libz on its own — the same string [`zlib_identity`] appends, handed +/// over unformatted so a caller can *test* it rather than print it. +/// +/// Returns `None` when the loader reports no path, or reports one that is not UTF-8. +/// +/// A caller that finds this path inside a Cargo build directory has learned something the version +/// string cannot tell it: the loader resolved libz from the build graph rather than from the +/// platform. `cargo` puts every build script's native search path on `LD_LIBRARY_PATH`, and +/// `gamut-dng`'s own dev-dependency `libtiff-oracle` builds a `libz.so` under `target/`, so a +/// benchmark launched through `cargo` can measure a different inflate implementation from the one +/// the same binary measures when run directly. That resolution is not reproducible for anyone +/// else, which is why `cargo bench -p gamut-dng --bench codec` flags it rather than only printing +/// it. +#[must_use] +pub fn zlib_path() -> Option { + // SAFETY: the shim returns either null or a pointer to a NUL-terminated string with static + // storage duration, valid for the life of the process; the `CStr` borrow ends here. + let raw = unsafe { gdng_zlib_path() }; + if raw.is_null() { + return None; + } + // SAFETY: non-null, and as above NUL-terminated and static. + let text = unsafe { CStr::from_ptr(raw) }.to_str().ok()?; + Some(PathBuf::from(text)) +} + /// The extent of an image the Adobe DNG SDK decoded: its geometry and how many samples it holds. /// /// Deliberately carries no pixels. It is what [`decode_dng_in_memory`] reports, and the point of diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index d1b260cb..000a36a4 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -102,6 +102,25 @@ dng_error_code copy_short_image(const dng_image *image, uint32_t *out_w, uint32_ } // namespace +namespace { + +// The resolved path of the shared object `zlibVersion` came from, or an empty string when the +// loader cannot report one. Computed once; the storage lives for the process. +const std::string &resolved_zlib_path() { + static const std::string path = [] { + Dl_info info; + if (dladdr(reinterpret_cast(&zlibVersion), &info) == 0 || + info.dli_fname == nullptr) { + return std::string(); + } + char resolved[PATH_MAX]; + return std::string(realpath(info.dli_fname, resolved) ? resolved : info.dli_fname); + }(); + return path; +} + +} // namespace + // Identifies the zlib the SDK's Deflate reader is actually calling: its `zlibVersion()` string // followed, where the loader can tell us, by the resolved path of the shared object the symbol // came from. @@ -112,21 +131,19 @@ dng_error_code copy_short_image(const dng_image *image, uint32_t *out_w, uint32_ // can. // // This matters to a *measurement*, not to correctness. `build.rs` links the system libz -// dynamically (`-lz`), so the SDK's Deflate decode is the one measured code path in this oracle -// that is not built from source committed to this repository: which libz the dynamic linker -// resolves is a property of the machine. Inflate implementations differ by well over the margin -// that separates "gamut is faster" from "the SDK is faster" on a Deflate row, so a Deflate -// throughput ratio is not interpretable without this string beside it. +// dynamically (`-lz`), so the SDK's Deflate decode is a measured code path this oracle neither +// builds nor pins: which libz the dynamic linker resolves is a property of the machine, and of +// the launcher, since cargo puts every build script's native search path on `LD_LIBRARY_PATH`. +// Inflate implementations differ by well over the margin that separates "gamut is faster" from +// "the SDK is faster" on a Deflate row, so a Deflate throughput ratio is not interpretable +// without this string beside it. // // The returned pointer has static storage duration and lives for the process. extern "C" const char *gdng_zlib_identity(void) { static const std::string identity = [] { std::string text = zlibVersion(); - Dl_info info; - if (dladdr(reinterpret_cast(&zlibVersion), &info) != 0 && - info.dli_fname != nullptr) { - char resolved[PATH_MAX]; - const char *path = realpath(info.dli_fname, resolved) ? resolved : info.dli_fname; + const std::string &path = resolved_zlib_path(); + if (!path.empty()) { text += " from "; text += path; } @@ -135,6 +152,18 @@ extern "C" const char *gdng_zlib_identity(void) { return identity.c_str(); } +// The resolved path alone, or `nullptr` when the loader cannot report one -- the same string +// `gdng_zlib_identity` appends, handed over unformatted so a caller can *test* it rather than +// print it. A caller that finds this path inside a Cargo build directory knows the loader +// resolved libz from the build graph rather than from the platform, which is a resolution nobody +// else reproduces. +// +// The returned pointer has static storage duration and lives for the process. +extern "C" const char *gdng_zlib_path(void) { + const std::string &path = resolved_zlib_path(); + return path.empty() ? nullptr : path.c_str(); +} + // The code gdng_validate returns when the SDK marks the negative damaged (a stored // RawImageDigest/NewRawImageDigest that does not match the image data). The SDK's non-validate // build records this via SetIsDamaged rather than throwing, so it must be surfaced explicitly. From 3bf6f16cb442bd154b1992655ce6bf861025790f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:51:26 -0400 Subject: [PATCH 21/24] docs(dng): name the inflate each Deflate arm actually runs The header, the printed epilogue and the STATUS section all said that on the `*/deflate` rows "one measured path is not built from this repository" and that "every other row runs only code built here". A reader takes that as a claim about gamut's own codec, and it is not one: `gamut-deflate` is deliberately encoder-only, so this crate inflates with `miniz_oxide`. Neither arm on those rows is gamut-authored. Say so, and give the distinction that carries the section's real content -- `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, and the system libz is pinned by nothing, not even by the machine. --- crates/gamut-dng/STATUS.md | 31 +++++++++++++-------- crates/gamut-dng/benches/codec.rs | 46 ++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index c37e40f9..23087a1d 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -402,17 +402,26 @@ removed or measured; none is left as an adjective. fairness claim needs only that the export path cannot account for a 30×-plus ratio — not a figure for what the export path costs. Earlier revisions of this section quoted 0.3–1.9 % as though it were the cost; it was two samples of a quantity at the noise floor. -- **One measured path is not built from this repository, and it is a Deflate one.** `build.rs` - links the system libz dynamically (`-lz`), because the SDK includes `` unconditionally. - So on the two `*/deflate` rows — and only there — the reference arm's speed is a property of the - machine, and even of the launcher: `cargo bench` puts every build script's native search path on - `LD_LIBRARY_PATH`, so it resolves whichever stock zlib another dev oracle has built under - `target/`, while running the same binary directly resolves the platform's. Measured here, that - choice moves the reference arm by 1.2–1.3× and moves gamut's arm not at all — enough to reverse - which side of 1.0 a Deflate row falls on, with no defect in either implementation. The harness - therefore prints the resolved library above its divan output (`zlibVersion()` plus the path - `dladdr` reports, since zlib-ng's compatibility build answers `"1.3.1"` exactly as stock zlib - does), and a Deflate figure below travels with the library it was taken against or not at all. +- **On the Deflate rows neither arm's inflate is gamut-authored, and only one of the two is + pinned.** `gamut-deflate` is deliberately encoder-only, so this crate inflates with + `miniz_oxide`; the oracle's `build.rs` links the system libz dynamically (`-lz`), because the + SDK includes `` unconditionally. A `*/deflate` row is therefore **`miniz_oxide` against + whatever libz the loader resolved** — not gamut's own codec against the SDK's. The distinction + that decides whether the row is reproducible is **pinning**, not authorship: `miniz_oxide` is + pinned by `Cargo.lock` to one version and one checksum, so every run of this harness anywhere + inflates with the same code, while the system libz is pinned by nothing. Not by a version — + zlib-ng's compatibility build answers `zlibVersion()` with `"1.3.1"`, exactly as stock zlib does + — and not even by the machine: `cargo bench` puts every build script's native search path on + `LD_LIBRARY_PATH`, so it resolves whichever stock zlib a dev oracle in the graph has built under + `target/` (`gamut-dng` dev-depends on `libtiff-oracle`, which builds one, so `cargo bench -p + gamut-dng` on its own is enough), while running the same binary directly resolves the platform's. + Measured here, that choice moves the reference arm by 1.2–1.3× and moves gamut's arm not at all — + enough to reverse which side of 1.0 a Deflate row falls on, with no defect in either + implementation. The harness therefore prints the resolved library above its divan output + (`zlibVersion()` plus the path `dladdr` reports, since the version string cannot tell the two + builds apart) and **warns when that path lies inside a build directory**, because a resolution + that came from the build graph rather than from the platform is one nobody else reproduces. A + Deflate figure below travels with the library it was taken against or not at all. **Each pair is one benchmark, not two.** `decode_dng` and `decode_lossless_jpeg` take the implementation as a divan *argument* rather than living in a benchmark each. Separate benchmarks diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 199cceab..12492eb0 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -55,13 +55,29 @@ //! supports is "the codestream comparison is fair to within the bound", not "the export path //! costs the SDK X". //! -//! **One measured path is not built from this repository, and it is a Deflate one.** The oracle -//! links the system libz dynamically (the SDK includes `` unconditionally), so on the two -//! `*/deflate` rows the reference arm's speed depends on which libz the machine resolves — -//! stock zlib and a zlib-ng-class fork differ by more than the margin that decides which side of -//! 1.0 those rows fall on. Every other row runs only code built here. The fixture table prints -//! [`gamut_dng_oracle::zlib_identity`] for exactly this reason: a Deflate ratio is not a fact about -//! two codecs unless the library it was taken against travels with it. +//! **On the two `*/deflate` rows neither arm's inflate is gamut-authored, and only one of them is +//! pinned.** `gamut-deflate` is deliberately encoder-only, so this crate inflates with +//! `miniz_oxide`; the reference arm calls the system libz, which the oracle links dynamically +//! because the SDK includes `` unconditionally. A Deflate row is therefore `miniz_oxide` +//! against whatever libz the loader resolved — not gamut's own codec against the SDK's, which is +//! how the wording here used to read. +//! +//! The distinction that decides whether such a row is reproducible is **pinning**, not +//! authorship: `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, so every +//! run of this harness anywhere inflates with the same code, while the system libz is pinned by +//! nothing. Not by a version — zlib-ng's compatibility build answers `zlibVersion()` with stock +//! zlib's own string — and not even by the machine: cargo puts every build script's native search +//! path on `LD_LIBRARY_PATH`, and `gamut-dng`'s own dev-dependency `libtiff-oracle` builds a +//! `libz.so` under `target/`, so `cargo bench` and the same binary launched directly can resolve +//! different implementations. Stock zlib and a zlib-ng-class fork differ by more than the margin +//! that decides which side of 1.0 those rows fall on. Every other row runs only code this +//! repository builds or pins. +//! +//! The fixture table prints [`gamut_dng_oracle::zlib_identity`] for exactly this reason, and +//! warns when [`gamut_dng_oracle::zlib_path`] falls inside a build directory, because a +//! resolution that came from the build graph is one nobody else reproduces. A Deflate ratio is +//! not a fact about two inflate implementations unless the library it was taken against travels +//! with it. //! //! # The counter rule //! @@ -636,12 +652,16 @@ measurement of it. No number is printed for it, because a printed number is read the compressed rows as: gamut's figure includes preview and metadata work the SDK arm does not do, by an amount this harness does not measure. -The `*/deflate` rows are the only ones whose SDK arm runs code from outside this repository: the -oracle links the system libz dynamically, because the SDK includes unconditionally. Which -libz the dynamic linker resolves is a property of the machine, and inflate implementations differ -by more than the margin that decides which side of 1.0 a Deflate ratio falls on. The resolved -library is printed above; publish it with any Deflate figure, and do not compare a Deflate ratio -against one taken on a different libz. Every other row runs only code built here. +On the `*/deflate` rows neither arm's inflate is gamut's own: `gamut-deflate` is encoder-only, so +this crate inflates with miniz_oxide, and the SDK calls the system libz, which the oracle links +dynamically because it includes unconditionally. Read those rows as miniz_oxide against +that libz. What separates them is that miniz_oxide is pinned by Cargo.lock -- one version, one +checksum, the same code everywhere -- and the system libz is pinned by nothing: not by a version +(zlib-ng answers zlibVersion() with stock zlib's string), and not by the machine, since cargo puts +every build script's native search path on LD_LIBRARY_PATH. The resolved library is printed above, +with a warning when it came out of a build directory; publish it with any Deflate figure, and do +not compare a Deflate ratio against one taken on a different libz. Every other row runs only code +this repository builds or pins. `decode_lossless_jpeg` needs no correction: same stream in, same samples out, one counter for all three arms. The gap between its `adobe-sdk` and `adobe-sdk-no-export` arms bounds the FFI export From e862279c26e4eefdce824b13278dd7d8be14b34d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 09:53:26 -0400 Subject: [PATCH 22/24] docs(dng): point the benchmark section at the two issues it opened The section's two findings are filed as #583 and #584, which were written from its first revision and still quote figures three later passes withdrew -- the Deflate ratios, the localisation argument resting on them, a fixture-table column the harness no longer prints, and a verification command naming benchmarks that no longer exist. Neither issue can be corrected in place from here, so #617 carries the correction; name it, and name #618 for pinning the libz the reference arm links. --- crates/gamut-dng/STATUS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 23087a1d..1a2ac96d 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -421,7 +421,10 @@ removed or measured; none is left as an adjective. (`zlibVersion()` plus the path `dladdr` reports, since the version string cannot tell the two builds apart) and **warns when that path lies inside a build directory**, because a resolution that came from the build graph rather than from the platform is one nobody else reproduces. A - Deflate figure below travels with the library it was taken against or not at all. + Deflate figure below travels with the library it was taken against or not at all. Pinning that + library for the benchmark while keeping `-lz` for conformance is filed as **#618** and not taken + here: it is a build-system change to a crate every `gamut-dng` test links, and it belongs to its + own change rather than to the one that added the benchmark. **Each pair is one benchmark, not two.** `decode_dng` and `decode_lossless_jpeg` take the implementation as a divan *argument* rather than living in a benchmark each. Separate benchmarks @@ -538,6 +541,15 @@ before the encoder changes, and gate on that if anything is to be gated. measured path is the SDK's own lossless-JPEG decoder, built here from the committed SDK source — so unlike the Deflate rows it does not depend on what the machine has installed. +**Both issues were filed from the first revision of this section and still quote figures it has +since withdrawn**: #583 asserts the two Deflate ratios and rests its localisation argument on them, +omitting the uncompressed rows that falsify it, and its verification command names benchmarks that +no longer exist (the arms were merged into one benchmark taking the implementation as an argument — +`cargo bench -p gamut-dng --bench codec -- decode_lossless_jpeg`); #584 quotes a fixture-table +column this harness no longer prints, whose preview model has since doubled. **#617** states +precisely which figure in each is withdrawn and what replaced it. This section is that replacement +text; the two findings themselves stand. + ## Deferred / out of scope Each deferred item plugs into the same IFD-tree/chunk pipeline and oracles the shipped features From b927c98261cc32529375b2bc81a247c3b102f1c3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:40:43 -0400 Subject: [PATCH 23/24] docs(dng): say which two zlibs the version string cannot separate Four doc sites and the benchmark's printed epilogue justified the resolved path print by claiming a zlib-ng compatibility build answers zlibVersion() with stock zlib's string. Executed on this box, it does not: the platform build answers "1.3.1.zlib-ng" and the stock copy under target/ answers "1.3.1", so the version separates that particular pair. The worked example in the oracle -- version "1.3.1" resolving to a path ending .zlib-ng -- is a composite that cannot occur. The mechanism and the print survive on the narrower true claim: the pair the loader actually collides is two *stock* builds of one version, the copy a dev oracle left under target/ and an installed libz.so.1.3.1, and those are indistinguishable by version string. The path is what identifies the resolution; a fork renaming itself is not something the identification may rest on. Refs #163 --- crates/gamut-dng/STATUS.md | 16 +++++++++++----- crates/gamut-dng/benches/codec.rs | 12 +++++++++--- tooling/gamut-dng-oracle/src/lib.rs | 8 +++++--- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 12 ++++++++---- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 1a2ac96d..2d4e3312 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -409,17 +409,23 @@ removed or measured; none is left as an adjective. whatever libz the loader resolved** — not gamut's own codec against the SDK's. The distinction that decides whether the row is reproducible is **pinning**, not authorship: `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, so every run of this harness anywhere - inflates with the same code, while the system libz is pinned by nothing. Not by a version — - zlib-ng's compatibility build answers `zlibVersion()` with `"1.3.1"`, exactly as stock zlib does - — and not even by the machine: `cargo bench` puts every build script's native search path on + inflates with the same code, while the system libz is pinned by nothing. Not by a version — two + *stock* builds of one zlib version answer `zlibVersion()` with the same string, so the version + cannot say which of them was loaded, and on the resolution this harness actually trips over both + candidates are stock 1.3.1: the copy a dev oracle left under `target/` and an installed + `/usr/lib64/libz.so.1.3.1`. (A fork that changes the string, such as this box's + `zlib-ng`-compatibility build answering `"1.3.1.zlib-ng"`, *is* separable by version; the + identification cannot rest on that, because the pair that actually collides does not differ.) + And not even by the machine: `cargo bench` puts every build script's native search path on `LD_LIBRARY_PATH`, so it resolves whichever stock zlib a dev oracle in the graph has built under `target/` (`gamut-dng` dev-depends on `libtiff-oracle`, which builds one, so `cargo bench -p gamut-dng` on its own is enough), while running the same binary directly resolves the platform's. Measured here, that choice moves the reference arm by 1.2–1.3× and moves gamut's arm not at all — enough to reverse which side of 1.0 a Deflate row falls on, with no defect in either implementation. The harness therefore prints the resolved library above its divan output - (`zlibVersion()` plus the path `dladdr` reports, since the version string cannot tell the two - builds apart) and **warns when that path lies inside a build directory**, because a resolution + (`zlibVersion()` plus the path `dladdr` reports; the path is what identifies the resolution, + because two stock builds of one version are indistinguishable by version string) and **warns + when that path lies inside a build directory**, because a resolution that came from the build graph rather than from the platform is one nobody else reproduces. A Deflate figure below travels with the library it was taken against or not at all. Pinning that library for the benchmark while keeping `-lz` for conformance is filed as **#618** and not taken diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 12492eb0..74ba1cad 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -65,8 +65,13 @@ //! The distinction that decides whether such a row is reproducible is **pinning**, not //! authorship: `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, so every //! run of this harness anywhere inflates with the same code, while the system libz is pinned by -//! nothing. Not by a version — zlib-ng's compatibility build answers `zlibVersion()` with stock -//! zlib's own string — and not even by the machine: cargo puts every build script's native search +//! nothing. Not by a version — two *stock* builds of one zlib version answer `zlibVersion()` +//! identically, so the version cannot say which was loaded, and the two candidates this harness +//! actually collides are exactly that pair: the copy a dev oracle left under `target/` and an +//! installed `libz.so.1.3.1`. A fork that changes the string (a `zlib-ng`-compatibility build +//! answering `"1.3.1.zlib-ng"`, say) *is* separable by version, which is why the identification +//! must rest on the path instead: the pair that collides does not differ in the string at all. +//! And not even by the machine: cargo puts every build script's native search //! path on `LD_LIBRARY_PATH`, and `gamut-dng`'s own dev-dependency `libtiff-oracle` builds a //! `libz.so` under `target/`, so `cargo bench` and the same binary launched directly can resolve //! different implementations. Stock zlib and a zlib-ng-class fork differ by more than the margin @@ -657,7 +662,8 @@ this crate inflates with miniz_oxide, and the SDK calls the system libz, which t dynamically because it includes unconditionally. Read those rows as miniz_oxide against that libz. What separates them is that miniz_oxide is pinned by Cargo.lock -- one version, one checksum, the same code everywhere -- and the system libz is pinned by nothing: not by a version -(zlib-ng answers zlibVersion() with stock zlib's string), and not by the machine, since cargo puts +(two stock builds of one zlib version answer zlibVersion() identically, so the path printed above, +not the version, is what says which one was loaded), and not by the machine, since cargo puts every build script's native search path on LD_LIBRARY_PATH. The resolved library is printed above, with a warning when it came out of a build directory; publish it with any Deflate figure, and do not compare a Deflate ratio against one taken on a different libz. Every other row runs only code diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index 3f1f2342..51ca079e 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -306,10 +306,12 @@ pub fn read_linear_dng(bytes: &[u8]) -> Result { /// Identifies the zlib the SDK's Deflate reader calls: its `zlibVersion()` string and, where the /// loader can report it, the resolved path of the shared object the symbol came from — e.g. -/// `"1.3.1 from /usr/lib64/libz.so.1.3.1.zlib-ng"`. +/// `"1.3.1 from /usr/lib64/libz.so.1.3.1"`. /// -/// The path is the discriminating part: `zlibVersion()` reports the zlib *API* version, so the -/// zlib-ng compatibility build answers `"1.3.1"` exactly as stock zlib does. +/// The path is the discriminating part: `zlibVersion()` reports the string the loaded build +/// carries, and the two candidates that actually collide here — a stock `libz.so.1.3.1` a build +/// script left under `target/` and a stock `libz.so.1.3.1` installed on the platform — carry the +/// same one. /// /// `build.rs` links the system libz dynamically (`-lz`) because the SDK includes `` /// unconditionally. That makes the SDK's Deflate decode the one measured path in this oracle that diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 000a36a4..9054039f 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -125,10 +125,14 @@ const std::string &resolved_zlib_path() { // followed, where the loader can tell us, by the resolved path of the shared object the symbol // came from. // -// The path is the part that matters. `zlibVersion()` reports the zlib API version, so the -// zlib-ng compatibility build answers "1.3.1" exactly as stock zlib does and cannot tell the two -// apart -- while `dladdr` plus `realpath` yields e.g. `/usr/lib64/libz.so.1.3.1.zlib-ng`, which -// can. +// The path is the part that matters. `zlibVersion()` reports the string the loaded build carries, +// which separates two builds only when they chose different strings -- and the pair this oracle +// actually collides did not. Two *stock* builds of one version, the copy a build script left at +// `/release/build/*/out/zlib-prefix/lib/libz.so.1.3.1` and an installed +// `/usr/lib64/libz.so.1.3.1`, both answer "1.3.1"; `dladdr` plus `realpath` names each one +// exactly. (A fork that renames itself, e.g. a zlib-ng-compatibility build answering +// "1.3.1.zlib-ng", is separable by version -- but the identification cannot rest on a fork +// choosing to rename itself.) // // This matters to a *measurement*, not to correctness. `build.rs` links the system libz // dynamically (`-lz`), so the SDK's Deflate decode is a measured code path this oracle neither From 8b34b9734f749c95be68fb107c946347485be559 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 10 Sep 2026 10:57:41 -0400 Subject: [PATCH 24/24] docs(dng): say what makes the two candidate zlibs separable, not that they are The previous commit replaced one unexecuted claim with a second one: that the pair the loader collides is two stock builds of a version. On this box it is not -- the platform ships a fork that renames itself, which the same commit's own message says. What is true of every box is the shape: the loader chooses between a copy some dev oracle built under target/ and whatever the platform installed, and zlibVersion() separates those only when the platform's build renamed itself. A box shipping stock zlib 1.3.1 gives two resolutions that answer identically, so the identification rests on the path either way. Refs #163 --- crates/gamut-dng/STATUS.md | 13 ++++++------- crates/gamut-dng/benches/codec.rs | 10 ++++------ tooling/gamut-dng-oracle/src/lib.rs | 6 +++--- tooling/gamut-dng-oracle/src/oracle_shim.cpp | 12 +++++------- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/crates/gamut-dng/STATUS.md b/crates/gamut-dng/STATUS.md index 2d4e3312..790fc55f 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -409,13 +409,12 @@ removed or measured; none is left as an adjective. whatever libz the loader resolved** — not gamut's own codec against the SDK's. The distinction that decides whether the row is reproducible is **pinning**, not authorship: `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, so every run of this harness anywhere - inflates with the same code, while the system libz is pinned by nothing. Not by a version — two - *stock* builds of one zlib version answer `zlibVersion()` with the same string, so the version - cannot say which of them was loaded, and on the resolution this harness actually trips over both - candidates are stock 1.3.1: the copy a dev oracle left under `target/` and an installed - `/usr/lib64/libz.so.1.3.1`. (A fork that changes the string, such as this box's - `zlib-ng`-compatibility build answering `"1.3.1.zlib-ng"`, *is* separable by version; the - identification cannot rest on that, because the pair that actually collides does not differ.) + inflates with the same code, while the system libz is pinned by nothing. Not by a version: the + loader chooses between a copy a dev oracle built under `target/` and whatever the platform + installed, and `zlibVersion()` separates those two only when the platform's build renamed itself. + This box's did — it answers `"1.3.1.zlib-ng"` where the build-tree copy answers `"1.3.1"` — but a + box shipping stock zlib 1.3.1 gives two resolutions that answer identically, so what identifies + the loaded library cannot be the version string. And not even by the machine: `cargo bench` puts every build script's native search path on `LD_LIBRARY_PATH`, so it resolves whichever stock zlib a dev oracle in the graph has built under `target/` (`gamut-dng` dev-depends on `libtiff-oracle`, which builds one, so `cargo bench -p diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs index 74ba1cad..f53b755e 100644 --- a/crates/gamut-dng/benches/codec.rs +++ b/crates/gamut-dng/benches/codec.rs @@ -65,12 +65,10 @@ //! The distinction that decides whether such a row is reproducible is **pinning**, not //! authorship: `miniz_oxide` is pinned by `Cargo.lock` to one version and one checksum, so every //! run of this harness anywhere inflates with the same code, while the system libz is pinned by -//! nothing. Not by a version — two *stock* builds of one zlib version answer `zlibVersion()` -//! identically, so the version cannot say which was loaded, and the two candidates this harness -//! actually collides are exactly that pair: the copy a dev oracle left under `target/` and an -//! installed `libz.so.1.3.1`. A fork that changes the string (a `zlib-ng`-compatibility build -//! answering `"1.3.1.zlib-ng"`, say) *is* separable by version, which is why the identification -//! must rest on the path instead: the pair that collides does not differ in the string at all. +//! nothing. Not by a version: the loader chooses between a copy a dev oracle built under +//! `target/` and whatever the platform installed, and `zlibVersion()` separates those two only +//! when the platform's build renamed itself. A box shipping stock zlib 1.3.1 gives two +//! resolutions that answer identically, so the identification rests on the path instead. //! And not even by the machine: cargo puts every build script's native search //! path on `LD_LIBRARY_PATH`, and `gamut-dng`'s own dev-dependency `libtiff-oracle` builds a //! `libz.so` under `target/`, so `cargo bench` and the same binary launched directly can resolve diff --git a/tooling/gamut-dng-oracle/src/lib.rs b/tooling/gamut-dng-oracle/src/lib.rs index 51ca079e..6a8309bf 100644 --- a/tooling/gamut-dng-oracle/src/lib.rs +++ b/tooling/gamut-dng-oracle/src/lib.rs @@ -309,9 +309,9 @@ pub fn read_linear_dng(bytes: &[u8]) -> Result { /// `"1.3.1 from /usr/lib64/libz.so.1.3.1"`. /// /// The path is the discriminating part: `zlibVersion()` reports the string the loaded build -/// carries, and the two candidates that actually collide here — a stock `libz.so.1.3.1` a build -/// script left under `target/` and a stock `libz.so.1.3.1` installed on the platform — carry the -/// same one. +/// carries, so it separates the copy a build script left under `target/` from the platform's only +/// when the platform's build renamed itself. Two stock builds of one version — which is what a box +/// shipping stock zlib gives — carry the same string, and the path still tells them apart. /// /// `build.rs` links the system libz dynamically (`-lz`) because the SDK includes `` /// unconditionally. That makes the SDK's Deflate decode the one measured path in this oracle that diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 9054039f..99425395 100644 --- a/tooling/gamut-dng-oracle/src/oracle_shim.cpp +++ b/tooling/gamut-dng-oracle/src/oracle_shim.cpp @@ -126,13 +126,11 @@ const std::string &resolved_zlib_path() { // came from. // // The path is the part that matters. `zlibVersion()` reports the string the loaded build carries, -// which separates two builds only when they chose different strings -- and the pair this oracle -// actually collides did not. Two *stock* builds of one version, the copy a build script left at -// `/release/build/*/out/zlib-prefix/lib/libz.so.1.3.1` and an installed -// `/usr/lib64/libz.so.1.3.1`, both answer "1.3.1"; `dladdr` plus `realpath` names each one -// exactly. (A fork that renames itself, e.g. a zlib-ng-compatibility build answering -// "1.3.1.zlib-ng", is separable by version -- but the identification cannot rest on a fork -// choosing to rename itself.) +// so it separates the two candidates -- the copy a build script left at +// `/release/build/*/out/zlib-prefix/lib/libz.so.1.3.1` and whatever the platform installed +// -- only when the platform's build renamed itself. On a box shipping stock zlib 1.3.1 both answer +// "1.3.1" and the version says nothing; `dladdr` plus `realpath` names each one exactly either +// way. The identification cannot rest on a fork choosing to rename itself. // // This matters to a *measurement*, not to correctness. `build.rs` links the system libz // dynamically (`-lz`), so the SDK's Deflate decode is a measured code path this oracle neither