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/STATUS.md b/crates/gamut-dng/STATUS.md index 79d047d1..790fc55f 100644 --- a/crates/gamut-dng/STATUS.md +++ b/crates/gamut-dng/STATUS.md @@ -351,6 +351,210 @@ 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.** 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. 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 + 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. +- **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: 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 + 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; 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 + 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 +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. + +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.** 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. 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 — 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.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 + **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. + +#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. + +**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 diff --git a/crates/gamut-dng/benches/codec.rs b/crates/gamut-dng/benches/codec.rs new file mode 100644 index 00000000..f53b755e --- /dev/null +++ b/crates/gamut-dng/benches/codec.rs @@ -0,0 +1,778 @@ +//! 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. Every counter is a *pixel volume* in bytes — the raw sample volume (`samples × 2`), +//! 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 +//! +//! **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 `()`, 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 +//! 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? +//! +//! 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.** +//! [`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. +//! +//! **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. +//! +//! **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 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 **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". +//! +//! **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: 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 +//! 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 +//! +//! 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; +//! - 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.** +//! +//! 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 +//! +//! `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. +//! +//! # 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}; +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 { + // `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", + }) + } +} + +/// 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 denominator every SDK arm is quoted against. + fn raw_bytes(self) -> usize { + raw_bytes(self.photometry) + } + + /// 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) + } +} + +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".to_string(), + Compression::Deflate => "deflate".to_string(), + Compression::LosslessJpeg => "lossless-jpeg".to_string(), + other => format!("{other:?}"), + }; + // `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)) + } +} + +/// 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, + }, +]; + +/// 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 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, +} + +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`. +/// +/// 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 { + /// 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 { + 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. 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 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; + 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::() +} + +/// 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_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 +/// 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, 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} {:>12} {:>8} {:>11}", + "case", + "raw samples", + "encoded DNG", + "of raw", + "preview", + "decode vol.", + "/ raw", + "correction" + ); + for case in CASES { + let raw = case.raw_bytes(); + let encoded = case.encoded().len(); + 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} {decode_volume:>12} \ + {:>8.3} {correction:>11}", + encoded as f64 / raw as f64 * 100.0, + 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() + ); + 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 +/// 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. + +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 +(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 +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 +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 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_encode_bytes())) + .bench_local(|| { + let mut out = Vec::new(); + encoder + .encode(black_box(&raw), black_box(&profile), &mut out) + .expect("encode"); + drop(black_box(out)); + }); +} + +/// 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, 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. +/// +/// 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(); + match job.imp { + DngImpl::Gamut => { + let decoder = DngDecoder::new(); + bencher + .counter(BytesCount::new(job.case.gamut_decode_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"), + ); + }); + } + } +} + +/// 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 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 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); + 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"), + )); + }), + 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"), + ); + }), + } +} 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/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 e117ab30..6a8309bf 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}; @@ -53,6 +53,36 @@ 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. + 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; + + /// 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; + + /// 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; @@ -274,6 +304,132 @@ 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"`. +/// +/// The path is the discriminating part: `zlibVersion()` reports the string the loaded build +/// 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 +/// 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 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 +/// 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. +/// +/// 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 +/// 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). @@ -310,6 +466,42 @@ 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. +/// +/// That the two agree is pinned by `sdk_extent_entry_counts_the_same_samples_as_the_exporting_entry` +/// in `gamut-dng`'s `lossless_jpeg` tests, not here, for the reason given on +/// [`decode_dng_in_memory`]. +/// +/// # 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::*; diff --git a/tooling/gamut-dng-oracle/src/oracle_shim.cpp b/tooling/gamut-dng-oracle/src/oracle_shim.cpp index 9b8f5638..99425395 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,17 +26,23 @@ #include "dng_stream.h" #include "dng_tag_types.h" +#include + +#include #include #include #include +#include +#include #include 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 +55,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, @@ -84,6 +102,70 @@ 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. +// +// The path is the part that matters. `zlibVersion()` reports the string the loaded build carries, +// 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 +// 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(); + const std::string &path = resolved_zlib_path(); + if (!path.empty()) { + text += " from "; + text += path; + } + return text; + }(); + 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. @@ -141,6 +223,58 @@ 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; + AutoPtr negative; + dng_stream stream(data, static_cast(len)); + dng_error_code rc = read_negative(stream, host, info, negative); + if (rc != dng_error_none) { + return rc; + } + 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, @@ -222,6 +356,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; @@ -246,6 +385,39 @@ 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; + // `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; + } + 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