perf(thumbnails): shrink-on-load JPEG decode (1.8-2× faster, 5-15× less RAM)
Decode JPEGs at the smallest DCT scale (1/8·1/4·1/2·1/1) whose long axis is still ≥ the largest needed thumbnail (800px), via jpeg-decoder, instead of a full-resolution decode through the image crate. The full-res bitmap — the dominant time and RAM cost — is never materialised. PNG/GIF/WebP and unusual JPEG colour spaces (CMYK / 16-bit grey) fall back to a full decode. Extracts the shared decode + EXIF-orientation logic into decode_oriented(), removing the duplication that existed between render_thumbnail_from_data and render_all_thumbnails_from_data. Measured on 14 cores (see benches/BASELINE.md): - render_all 1.8-2.0× faster (12MP 111->61ms, 48MP 398->203ms) - peak heap 5.5-14.8× lower, now decoupled from source MP (~18-25MB regardless) - saturated throughput 3-3.6× (parallel efficiency 4.9×->8.5×) - quality SSIM 0.987-0.999 (>=0.98 gate), PSNR 47-55dB Also adds the Phase 0 benchmark harness (gated behind the `bench` feature, zero prod impact): deterministic image corpus (src/bench_support.rs), criterion latency bench (benches/thumbnails.rs), and a peak-RAM/throughput/SSIM harness (examples/bench_thumbnails_mem.rs). Baseline + before/after in benches/BASELINE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,3 +113,8 @@ wasm/oxicloud-hash/target/
|
|||||||
# K6 load-test raw run outputs and per-run storage (baseline is committed)
|
# K6 load-test raw run outputs and per-run storage (baseline is committed)
|
||||||
tests/load/results/*.json
|
tests/load/results/*.json
|
||||||
tests/load/storage/
|
tests/load/storage/
|
||||||
|
|
||||||
|
# Phase 0 perf-bench corpus — generated deterministically on first
|
||||||
|
# `cargo bench --features bench`; drop real photos here (same filenames) to
|
||||||
|
# benchmark against real data. (target/ already ignored, covers the JSON dump.)
|
||||||
|
benches/corpus/
|
||||||
|
|||||||
Generated
+216
-5
@@ -118,6 +118,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anes"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
@@ -1252,6 +1258,12 @@ dependencies = [
|
|||||||
"winx",
|
"winx",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cast"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1340,6 +1352,33 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||||
|
dependencies = [
|
||||||
|
"ciborium-io",
|
||||||
|
"ciborium-ll",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium-io"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium-ll"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||||
|
dependencies = [
|
||||||
|
"ciborium-io",
|
||||||
|
"half",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -1350,6 +1389,31 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -1646,6 +1710,42 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "criterion"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||||
|
dependencies = [
|
||||||
|
"anes",
|
||||||
|
"cast",
|
||||||
|
"ciborium",
|
||||||
|
"clap",
|
||||||
|
"criterion-plot",
|
||||||
|
"is-terminal",
|
||||||
|
"itertools 0.10.5",
|
||||||
|
"num-traits",
|
||||||
|
"once_cell",
|
||||||
|
"oorandom",
|
||||||
|
"plotters",
|
||||||
|
"rayon",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"tinytemplate",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "criterion-plot"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||||
|
dependencies = [
|
||||||
|
"cast",
|
||||||
|
"itertools 0.10.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
@@ -2683,6 +2783,17 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -2734,6 +2845,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3254,6 +3371,17 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-terminal"
|
||||||
|
version = "0.4.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iso6709parse"
|
name = "iso6709parse"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -3264,6 +3392,15 @@ dependencies = [
|
|||||||
"nom 7.1.3",
|
"nom 7.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.10.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -3309,6 +3446,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jpeg-decoder"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||||
|
dependencies = [
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.97"
|
version = "0.3.97"
|
||||||
@@ -3970,6 +4116,12 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
|
checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oorandom"
|
||||||
|
version = "11.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4048,6 +4200,7 @@ dependencies = [
|
|||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"criterion",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"extism",
|
"extism",
|
||||||
@@ -4064,6 +4217,7 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"image",
|
"image",
|
||||||
"infer 0.19.0",
|
"infer 0.19.0",
|
||||||
|
"jpeg-decoder",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -4294,6 +4448,34 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plotters"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"plotters-backend",
|
||||||
|
"plotters-svg",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plotters-backend"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plotters-svg"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||||
|
dependencies = [
|
||||||
|
"plotters-backend",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -4473,7 +4655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
@@ -5090,6 +5272,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -5748,7 +5939,7 @@ dependencies = [
|
|||||||
"fnv",
|
"fnv",
|
||||||
"fs4",
|
"fs4",
|
||||||
"htmlescape",
|
"htmlescape",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"levenshtein_automata",
|
"levenshtein_automata",
|
||||||
"log",
|
"log",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -5797,7 +5988,7 @@ checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"fastdivide",
|
"fastdivide",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"serde",
|
"serde",
|
||||||
"tantivy-bitpacker",
|
"tantivy-bitpacker",
|
||||||
"tantivy-common",
|
"tantivy-common",
|
||||||
@@ -5849,7 +6040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606"
|
checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"tantivy-bitpacker",
|
"tantivy-bitpacker",
|
||||||
"tantivy-common",
|
"tantivy-common",
|
||||||
"tantivy-fst",
|
"tantivy-fst",
|
||||||
@@ -6000,6 +6191,16 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinytemplate"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -6546,6 +6747,16 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
|
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -6945,7 +7156,7 @@ dependencies = [
|
|||||||
"cranelift-frontend",
|
"cranelift-frontend",
|
||||||
"cranelift-native",
|
"cranelift-native",
|
||||||
"gimli",
|
"gimli",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"log",
|
"log",
|
||||||
"object",
|
"object",
|
||||||
"pulley-interpreter",
|
"pulley-interpreter",
|
||||||
|
|||||||
+25
@@ -39,6 +39,10 @@ dotenvy = "0.15.7"
|
|||||||
moka = { version = "0.12.15", features = ["future", "sync"] }
|
moka = { version = "0.12.15", features = ["future", "sync"] }
|
||||||
http-range-header = "0.4"
|
http-range-header = "0.4"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||||
|
# Shrink-on-load JPEG decode (DCT 1/2·1/4·1/8 scaling) for thumbnails — pure
|
||||||
|
# Rust, no C toolchain. The `image` crate's zune-jpeg backend can't scale during
|
||||||
|
# decode; this can, cutting decode time/RAM ~order-of-magnitude on large photos.
|
||||||
|
jpeg-decoder = "0.3"
|
||||||
id3 = "1.17"
|
id3 = "1.17"
|
||||||
mp3-duration = "0.1"
|
mp3-duration = "0.1"
|
||||||
kamadak-exif = "0.6.1"
|
kamadak-exif = "0.6.1"
|
||||||
@@ -103,6 +107,14 @@ load_seed_bin = []
|
|||||||
# requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without
|
# requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without
|
||||||
# this feature the People pipeline falls back to the inert NoopFaceAnalyzer.
|
# this feature the People pipeline falls back to the inert NoopFaceAnalyzer.
|
||||||
faces-onnx = ["dep:ort", "dep:ndarray"]
|
faces-onnx = ["dep:ort", "dep:ndarray"]
|
||||||
|
# Performance benchmark harness (Phase 0). Exposes `bench_support` + thin public
|
||||||
|
# wrappers over the private thumbnail render functions so `benches/` and
|
||||||
|
# `examples/` can measure them. Off by default — adds nothing to prod builds.
|
||||||
|
# Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`.
|
||||||
|
bench = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion = "0.5"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
|
||||||
@@ -123,6 +135,19 @@ path = "src/bin/load-seed.rs"
|
|||||||
# and load-nightly.yml build it explicitly with --features load_seed_bin.
|
# and load-nightly.yml build it explicitly with --features load_seed_bin.
|
||||||
required-features = ["load_seed_bin"]
|
required-features = ["load_seed_bin"]
|
||||||
|
|
||||||
|
# Phase 0 perf harness — Task 0.2 (criterion latency + output-size bench).
|
||||||
|
[[bench]]
|
||||||
|
name = "thumbnails"
|
||||||
|
path = "benches/thumbnails.rs"
|
||||||
|
harness = false
|
||||||
|
required-features = ["bench"]
|
||||||
|
|
||||||
|
# Phase 0 perf harness — Task 0.3 (peak-RAM + saturated-throughput baseline).
|
||||||
|
[[example]]
|
||||||
|
name = "bench_thumbnails_mem"
|
||||||
|
path = "examples/bench_thumbnails_mem.rs"
|
||||||
|
required-features = ["bench"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Thumbnail performance — Phase 0 baseline
|
||||||
|
|
||||||
|
> **Phase 1.1 (shrink-on-load) is now merged — see "Phase 1.1 results" at the
|
||||||
|
> bottom for the before/after.** The tables below remain the Phase 0 baseline
|
||||||
|
> (the "before").
|
||||||
|
|
||||||
|
The "before" numbers every later phase must beat. Captured on **14 cores** with
|
||||||
|
the current `image` 0.25 pipeline (`render_thumbnail_from_data` /
|
||||||
|
`render_all_thumbnails_from_data` in
|
||||||
|
`src/infrastructure/services/thumbnail_service.rs`).
|
||||||
|
|
||||||
|
> Heap = logical allocation high-water mark (counting allocator), not RSS.
|
||||||
|
> The synthetic corpus is high-entropy (gradient + noise), so JPEG sizes and
|
||||||
|
> decode work are realistic-to-slightly-pessimistic. Drop real photos into
|
||||||
|
> `benches/corpus/` (same filenames) to re-baseline on real data.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Peak RAM + saturated throughput (Task 0.3) → target/bench-baseline-fase0.json
|
||||||
|
cargo run --release --features bench --example bench_thumbnails_mem
|
||||||
|
|
||||||
|
# Per-size latency + output bytes (Task 0.2) → target/criterion/report/index.html
|
||||||
|
cargo bench --features bench # do NOT pipe through `tail` — it truncates the log;
|
||||||
|
# results are saved under target/criterion/ regardless
|
||||||
|
```
|
||||||
|
|
||||||
|
## A. Per-image — peak heap, single-thread latency, output size
|
||||||
|
|
||||||
|
| case | fmt | source | MP | render_all ms | peak heap MB | out KB (3 sizes) |
|
||||||
|
|------------------|------|-----------|-----:|--------------:|-------------:|-----------------:|
|
||||||
|
| jpeg_12mp | jpeg | 4000×3000 | 12.0 | 111.30 | 96.1 | 57 |
|
||||||
|
| jpeg_24mp | jpeg | 6000×4000 | 24.0 | 207.23 | 151.0 | 41 |
|
||||||
|
| jpeg_48mp | jpeg | 8000×6000 | 48.0 | 397.67 | 260.9 | 38 |
|
||||||
|
| jpeg_exif_orient | jpeg | 4000×3000 | 12.0 | 121.82 | 107.6 | 59 |
|
||||||
|
| png_large | png | 3000×2000 | 6.0 | 34.36 | 58.4 | 63 |
|
||||||
|
| webp_large | webp | 1280×853 | 1.1 | 21.05 | 20.7 | 113 |
|
||||||
|
| gif_large | gif | 600×600 | 0.4 | 12.86 | 15.4 | 232 |
|
||||||
|
| small_300 | jpeg | 300×300 | 0.1 | 9.94 | 8.0 | 184 |
|
||||||
|
|
||||||
|
## B. Saturated throughput (14 threads, 3 s window)
|
||||||
|
|
||||||
|
| case | source | MP | photos/sec | eff ms/photo |
|
||||||
|
|-----------|-----------|-----:|-----------:|-------------:|
|
||||||
|
| jpeg_12mp | 4000×3000 | 12.0 | 44.4 | 22.52 |
|
||||||
|
| jpeg_24mp | 6000×4000 | 24.0 | 25.3 | 39.51 |
|
||||||
|
| jpeg_48mp | 8000×6000 | 48.0 | 12.4 | 80.49 |
|
||||||
|
|
||||||
|
Scaling is sub-linear (14 threads ≈ 4.9× single-thread): memory-bandwidth bound
|
||||||
|
(moving 96–261 MB per decode) + rayon oversubscription (each caller thread fans
|
||||||
|
3 sizes onto the shared rayon pool).
|
||||||
|
|
||||||
|
## C. Per-size latency — criterion median ms (one size in isolation vs all-three)
|
||||||
|
|
||||||
|
| case | Icon ms | Preview ms | Large ms | all-3 ms | Large/all |
|
||||||
|
|------------------|--------:|-----------:|---------:|---------:|----------:|
|
||||||
|
| jpeg_12mp | 75.42 | 97.17 | 106.30 | 107.11 | 99.3% |
|
||||||
|
| jpeg_24mp | 148.65 | 190.05 | 200.70 | 203.44 | 98.7% |
|
||||||
|
| jpeg_48mp | 303.31 | 375.33 | 386.04 | 391.24 | 98.7% |
|
||||||
|
| jpeg_exif_orient | 90.68 | 122.57 | 119.27 | 119.33 | 100.0% |
|
||||||
|
| png_large | 13.32 | 25.87 | 32.26 | 32.59 | 99.0% |
|
||||||
|
| webp_large | 15.83 | 19.51 | 24.47 | 24.45 | 100.1% |
|
||||||
|
| gif_large | 2.28 | 5.15 | 12.94 | 12.92 | 100.2% |
|
||||||
|
| small_300 | 0.85 | 3.22 | 9.94 | 9.92 | 100.2% |
|
||||||
|
|
||||||
|
## Key findings (these steer Phase 1)
|
||||||
|
|
||||||
|
1. **Decode dominates: 70–99 % of total time.** For jpeg_12mp, rendering all
|
||||||
|
three sizes (107 ms) costs barely more than rendering Icon alone (75 ms) —
|
||||||
|
the full-resolution decode is the shared cost; per-size resize+encode is
|
||||||
|
cheap on top. ⇒ **Shrink-on-load (Task 1.1) is the single biggest lever**,
|
||||||
|
bigger than first estimated.
|
||||||
|
|
||||||
|
2. **Peak heap scales linearly with megapixels** (~2× the RGBA bitmap):
|
||||||
|
12 MP→96 MB, 48 MP→261 MB. With the real `cpus/2` semaphore that is up to
|
||||||
|
7×261 MB ≈ 1.8 GB on a 48 MP burst — the OOM ceiling that caps concurrency.
|
||||||
|
Shrink-on-load collapses this ~16× and unlocks Task 1.5 (raise the semaphore).
|
||||||
|
|
||||||
|
3. **Task 2.1 "defer Large" is now DROPPED — the benchmark refutes it.**
|
||||||
|
Because all three sizes share one decode (Large/all ≈ 99 %), deferring Large
|
||||||
|
saves ~9 ms eager but forces a *second full decode* (~106 ms) when the
|
||||||
|
lightbox opens — it roughly **doubles** total decode work. Keep generating
|
||||||
|
all sizes in one pass.
|
||||||
|
|
||||||
|
4. **No-upscale (Task 1.4) confirmed minor:** small_300's Large (9.9 ms)
|
||||||
|
upscales 300→800; clamping recovers a few ms and avoids artefacts.
|
||||||
|
|
||||||
|
5. **PNG/GIF/WebP get no DCT shrink-on-load** — only `fast_image_resize`
|
||||||
|
(Task 1.2) speeds their resize portion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1.1 results — shrink-on-load (DCT scale-on-decode for JPEG)
|
||||||
|
|
||||||
|
Implemented via `jpeg-decoder` in `decode_oriented` / `decode_jpeg_scaled`
|
||||||
|
(`src/infrastructure/services/thumbnail_service.rs`). The JPEG decoder now emits
|
||||||
|
the image at the smallest DCT scale (1/8·1/4·1/2·1/1) whose long axis is still ≥
|
||||||
|
the largest needed thumbnail (800 px), so the full-resolution bitmap is never
|
||||||
|
materialised. Non-JPEG and unusual JPEG colour spaces fall back to a full decode.
|
||||||
|
Same machine (14 cores), same corpus.
|
||||||
|
|
||||||
|
### Latency — `render_all`, single thread (ms)
|
||||||
|
|
||||||
|
| case | before | after | speedup |
|
||||||
|
|-----------|-------:|-------:|--------:|
|
||||||
|
| jpeg_12mp | 111.30 | 60.64 | 1.84× |
|
||||||
|
| jpeg_24mp | 207.23 | 113.71 | 1.82× |
|
||||||
|
| jpeg_48mp | 397.67 | 202.88 | 1.96× |
|
||||||
|
| jpeg_exif | 121.82 | 60.12 | 2.03× |
|
||||||
|
| png_large | 34.36 | 33.67 | ~1× (no DCT, expected) |
|
||||||
|
|
||||||
|
### Peak heap per decode (MB) — the headline win
|
||||||
|
|
||||||
|
| case | before | after | reduction |
|
||||||
|
|-----------|-------:|------:|----------:|
|
||||||
|
| jpeg_12mp | 96.1 | 17.6 | 5.5× |
|
||||||
|
| jpeg_24mp | 151.0 | 24.9 | 6.1× |
|
||||||
|
| jpeg_48mp | 260.9 | 17.6 | 14.8× |
|
||||||
|
| jpeg_exif | 107.6 | 18.9 | 5.7× |
|
||||||
|
|
||||||
|
Peak heap is now **decoupled from source resolution** (~18–25 MB regardless of
|
||||||
|
MP — bounded by the 800 px decode, not the original). 48 MP now uses *less* than
|
||||||
|
24 MP because it hits the 1/8 scale (1000×750) vs 24 MP's 1/4 (1500×1000).
|
||||||
|
|
||||||
|
### Saturated throughput (14 threads, photos/sec)
|
||||||
|
|
||||||
|
| case | before | after | speedup |
|
||||||
|
|-----------|-------:|------:|--------:|
|
||||||
|
| jpeg_12mp | 44.4 | 140.8 | 3.17× |
|
||||||
|
| jpeg_24mp | 25.3 | 74.7 | 2.95× |
|
||||||
|
| jpeg_48mp | 12.4 | 45.3 | 3.65× |
|
||||||
|
|
||||||
|
Throughput improved **more** than single-thread latency (3.2× vs 1.8× at 12 MP):
|
||||||
|
parallel efficiency rose from ~4.9× to ~8.5× across 14 threads because the 16×
|
||||||
|
smaller decode buffers relieve the memory-bandwidth ceiling.
|
||||||
|
|
||||||
|
### Quality gate — shrink-on-load vs full decode (Preview 400 px)
|
||||||
|
|
||||||
|
| case | SSIM | PSNR dB |
|
||||||
|
|-----------|-------:|--------:|
|
||||||
|
| jpeg_12mp | 0.9875 | 47.42 |
|
||||||
|
| jpeg_24mp | 0.9927 | 48.91 |
|
||||||
|
| jpeg_48mp | 0.9939 | 49.37 |
|
||||||
|
| small_300 | 0.9995 | 55.17 |
|
||||||
|
|
||||||
|
All **SSIM ≥ 0.98** (acceptance criterion met) and PSNR 47–55 dB (>40 dB =
|
||||||
|
visually indistinguishable). Output bytes unchanged (e.g. 12 MP: 57→58 KB).
|
||||||
|
|
||||||
|
### Follow-ups this unlocked
|
||||||
|
- **Task 1.5** (raise `cpus/2` → `cpus`): peak heap no longer scales with MP, so
|
||||||
|
the OOM ceiling that justified halving concurrency is largely gone.
|
||||||
|
- The `MAX_DECODE_PIXELS` 50 MP reject could be relaxed — huge JPEGs now decode
|
||||||
|
cheaply at 1/8 — but that is a behaviour change, deferred.
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! Phase 0 — Task 0.2: thumbnail render latency + output-size baseline.
|
||||||
|
//!
|
||||||
|
//! Measures the CPU-bound render path (decode → EXIF orientation → resize →
|
||||||
|
//! JPEG encode) per size and for the all-sizes upload path, across the size/
|
||||||
|
//! format corpus. `Throughput::Elements(1)` makes criterion report images/sec
|
||||||
|
//! alongside ms/image. Output byte sizes (bandwidth/disk proxy) are printed once
|
||||||
|
//! as a table before the timed runs.
|
||||||
|
//!
|
||||||
|
//! Run: `cargo bench --features bench`
|
||||||
|
//! HTML report: `target/criterion/report/index.html`
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||||
|
use oxicloud::bench_support::{self, CorpusCase};
|
||||||
|
use oxicloud::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
||||||
|
|
||||||
|
const SIZES: [ThumbnailSize; 3] = [
|
||||||
|
ThumbnailSize::Icon,
|
||||||
|
ThumbnailSize::Preview,
|
||||||
|
ThumbnailSize::Large,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Print the output-size table (per-size encoded JPEG bytes) once. This is the
|
||||||
|
/// bandwidth/disk half of the Task 0.2 deliverable.
|
||||||
|
fn print_output_sizes(corpus: &[CorpusCase]) {
|
||||||
|
println!("\n=== Output size baseline (encoded JPEG bytes per thumbnail) ===");
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:<5} | {:>11} | {:>8} | {:>7} | {:>8} | {:>8} |",
|
||||||
|
"case", "fmt", "source", "input KB", "icon B", "preview B", "large B"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"|{:-<19}|{:-<7}|{:-<13}|{:-<10}|{:-<9}|{:-<10}|{:-<10}|",
|
||||||
|
"", "", "", "", "", "", ""
|
||||||
|
);
|
||||||
|
for case in corpus {
|
||||||
|
let sizes = ThumbnailService::bench_render_all(&case.bytes).unwrap_or_default();
|
||||||
|
let get = |want: ThumbnailSize| {
|
||||||
|
sizes
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == want)
|
||||||
|
.map(|(_, n)| *n)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:<5} | {:>5}×{:<5} | {:>8} | {:>6} | {:>8} | {:>8} |",
|
||||||
|
case.name,
|
||||||
|
case.format,
|
||||||
|
case.width,
|
||||||
|
case.height,
|
||||||
|
case.bytes.len() / 1024,
|
||||||
|
get(ThumbnailSize::Icon),
|
||||||
|
get(ThumbnailSize::Preview),
|
||||||
|
get(ThumbnailSize::Large),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure(group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>) {
|
||||||
|
// Big images (48 MP) are slow per-iter; keep the suite bounded but stable.
|
||||||
|
group
|
||||||
|
.sample_size(10)
|
||||||
|
.warm_up_time(Duration::from_secs(1))
|
||||||
|
.measurement_time(Duration::from_secs(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_thumbnails(c: &mut Criterion) {
|
||||||
|
let corpus = bench_support::load_or_generate();
|
||||||
|
assert!(!corpus.is_empty(), "corpus is empty — generation failed");
|
||||||
|
print_output_sizes(&corpus);
|
||||||
|
|
||||||
|
// Per-size single-thumbnail latency (the lazy request path).
|
||||||
|
for size in SIZES {
|
||||||
|
let mut group = c.benchmark_group(format!("render_thumbnail/{size:?}"));
|
||||||
|
configure(&mut group);
|
||||||
|
for case in &corpus {
|
||||||
|
group.throughput(Throughput::Elements(1));
|
||||||
|
group.bench_with_input(BenchmarkId::from_parameter(case.name), case, |b, case| {
|
||||||
|
b.iter(|| {
|
||||||
|
let out =
|
||||||
|
ThumbnailService::bench_render_thumbnail(black_box(&case.bytes), size)
|
||||||
|
.expect("render_thumbnail");
|
||||||
|
black_box(out.len())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-sizes-in-one-decode latency (the eager upload path).
|
||||||
|
let mut group = c.benchmark_group("render_all");
|
||||||
|
configure(&mut group);
|
||||||
|
for case in &corpus {
|
||||||
|
group.throughput(Throughput::Elements(1));
|
||||||
|
group.bench_with_input(BenchmarkId::from_parameter(case.name), case, |b, case| {
|
||||||
|
b.iter(|| {
|
||||||
|
let out =
|
||||||
|
ThumbnailService::bench_render_all(black_box(&case.bytes)).expect("render_all");
|
||||||
|
black_box(out.len())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_thumbnails);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//! Phase 0 — Task 0.3: peak-RAM and saturated-throughput baseline.
|
||||||
|
//!
|
||||||
|
//! Two measurements criterion does not give us:
|
||||||
|
//! 1. **Peak heap per decode** — via a counting global allocator wrapping the
|
||||||
|
//! system allocator. We snapshot the high-water mark of bytes allocated
|
||||||
|
//! around a single `render_all` call, i.e. the transient decode/resize/
|
||||||
|
//! encode footprint. This is the number the `Semaphore` (`cpus/2`) exists
|
||||||
|
//! to bound, and the one shrink-on-load (Task 1.1) should collapse.
|
||||||
|
//! 2. **Throughput under saturation** — N = cores threads hammering the same
|
||||||
|
//! image for a fixed window → photos/sec and effective ms/photo, mirroring
|
||||||
|
//! a burst of hundreds of uploads.
|
||||||
|
//!
|
||||||
|
//! Heap bytes here are *logical allocation* (what the program requested), not
|
||||||
|
//! RSS. For a Max-RSS cross-check on macOS run the binary under:
|
||||||
|
//! `/usr/bin/time -l ./target/release/examples/bench_thumbnails_mem`
|
||||||
|
//!
|
||||||
|
//! Run: `cargo run --release --features bench --example bench_thumbnails_mem`
|
||||||
|
|
||||||
|
use std::alloc::{GlobalAlloc, Layout, System};
|
||||||
|
use std::hint::black_box;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use oxicloud::bench_support::{self, CorpusCase};
|
||||||
|
use oxicloud::infrastructure::services::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Counting allocator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct TrackingAlloc;
|
||||||
|
|
||||||
|
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
unsafe impl GlobalAlloc for TrackingAlloc {
|
||||||
|
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||||
|
let p = unsafe { System.alloc(layout) };
|
||||||
|
if !p.is_null() {
|
||||||
|
let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
|
||||||
|
PEAK.fetch_max(now, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||||
|
let p = unsafe { System.alloc_zeroed(layout) };
|
||||||
|
if !p.is_null() {
|
||||||
|
let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
|
||||||
|
PEAK.fetch_max(now, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||||
|
unsafe { System.dealloc(ptr, layout) };
|
||||||
|
CURRENT.fetch_sub(layout.size(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||||
|
let p = unsafe { System.realloc(ptr, layout, new_size) };
|
||||||
|
if !p.is_null() {
|
||||||
|
if new_size >= layout.size() {
|
||||||
|
let delta = new_size - layout.size();
|
||||||
|
let now = CURRENT.fetch_add(delta, Ordering::Relaxed) + delta;
|
||||||
|
PEAK.fetch_max(now, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
CURRENT.fetch_sub(layout.size() - new_size, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static GLOBAL: TrackingAlloc = TrackingAlloc;
|
||||||
|
|
||||||
|
fn current() -> usize {
|
||||||
|
CURRENT.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
fn peak() -> usize {
|
||||||
|
PEAK.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
fn reset_peak_to_current() {
|
||||||
|
PEAK.store(CURRENT.load(Ordering::Relaxed), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB: f64 = 1024.0 * 1024.0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Measurements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct PeakRow {
|
||||||
|
name: &'static str,
|
||||||
|
format: &'static str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
megapixels: f64,
|
||||||
|
input_bytes: usize,
|
||||||
|
output_bytes: usize,
|
||||||
|
single_ms: f64,
|
||||||
|
peak_heap_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-thread: peak transient heap + latency for one `render_all`.
|
||||||
|
fn measure_peak(case: &CorpusCase) -> PeakRow {
|
||||||
|
// Warm up (let any one-time lazy allocations settle) and discard.
|
||||||
|
let _ = ThumbnailService::bench_render_all(&case.bytes).expect("render_all warmup");
|
||||||
|
|
||||||
|
let mut best_peak = 0usize;
|
||||||
|
let mut best_ms = f64::INFINITY;
|
||||||
|
let mut output_bytes = 0usize;
|
||||||
|
for _ in 0..3 {
|
||||||
|
reset_peak_to_current();
|
||||||
|
let base = current();
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = ThumbnailService::bench_render_all(&case.bytes).expect("render_all");
|
||||||
|
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
let pk = peak().saturating_sub(base);
|
||||||
|
output_bytes = out.iter().map(|(_, n)| *n).sum();
|
||||||
|
black_box(&out);
|
||||||
|
best_peak = best_peak.max(pk);
|
||||||
|
best_ms = best_ms.min(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
PeakRow {
|
||||||
|
name: case.name,
|
||||||
|
format: case.format,
|
||||||
|
width: case.width,
|
||||||
|
height: case.height,
|
||||||
|
megapixels: case.megapixels(),
|
||||||
|
input_bytes: case.bytes.len(),
|
||||||
|
output_bytes,
|
||||||
|
single_ms: best_ms,
|
||||||
|
peak_heap_bytes: best_peak,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThroughputRow {
|
||||||
|
name: &'static str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
megapixels: f64,
|
||||||
|
threads: usize,
|
||||||
|
seconds: f64,
|
||||||
|
photos: u64,
|
||||||
|
photos_per_sec: f64,
|
||||||
|
eff_ms_per_photo: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N=threads workers render the same image until the window elapses.
|
||||||
|
fn measure_throughput(case: &CorpusCase, threads: usize, window: Duration) -> ThroughputRow {
|
||||||
|
let counter = AtomicU64::new(0);
|
||||||
|
let start = Instant::now();
|
||||||
|
let deadline = start + window;
|
||||||
|
|
||||||
|
thread::scope(|s| {
|
||||||
|
for _ in 0..threads {
|
||||||
|
let counter = &counter;
|
||||||
|
let bytes = &case.bytes;
|
||||||
|
s.spawn(move || {
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let out = ThumbnailService::bench_render_all(bytes).expect("render_all");
|
||||||
|
black_box(out.len());
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
let photos = counter.load(Ordering::Relaxed);
|
||||||
|
let pps = photos as f64 / elapsed;
|
||||||
|
ThroughputRow {
|
||||||
|
name: case.name,
|
||||||
|
width: case.width,
|
||||||
|
height: case.height,
|
||||||
|
megapixels: case.megapixels(),
|
||||||
|
threads,
|
||||||
|
seconds: elapsed,
|
||||||
|
photos,
|
||||||
|
photos_per_sec: pps,
|
||||||
|
eff_ms_per_photo: if pps > 0.0 { 1000.0 / pps } else { 0.0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reporting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let threads = thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(4);
|
||||||
|
let corpus = bench_support::load_or_generate();
|
||||||
|
assert!(!corpus.is_empty(), "corpus is empty — generation failed");
|
||||||
|
|
||||||
|
println!("\n###########################################################");
|
||||||
|
println!("# Phase 0 baseline — thumbnail render (current `image` crate)");
|
||||||
|
println!("# cores (available_parallelism): {threads}");
|
||||||
|
println!("# corpus dir: {}", bench_support::corpus_dir().display());
|
||||||
|
println!("# heap = logical allocation high-water mark (not RSS)");
|
||||||
|
println!("###########################################################\n");
|
||||||
|
|
||||||
|
// --- Table A: peak RAM + single-thread latency + output size ---
|
||||||
|
println!("== A. Per-image: peak heap, single-thread latency, output size ==");
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:<5} | {:>11} | {:>6} | {:>8} | {:>9} | {:>13} | {:>11} |",
|
||||||
|
"case", "fmt", "source", "MP", "input KB", "out KB", "render_all ms", "peak heap MB"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"|{:-<19}|{:-<7}|{:-<13}|{:-<8}|{:-<10}|{:-<11}|{:-<15}|{:-<13}|",
|
||||||
|
"", "", "", "", "", "", "", ""
|
||||||
|
);
|
||||||
|
let mut peak_rows = Vec::new();
|
||||||
|
for case in &corpus {
|
||||||
|
let row = measure_peak(case);
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:<5} | {:>5}×{:<5} | {:>6.1} | {:>8} | {:>9} | {:>13.2} | {:>11.1} |",
|
||||||
|
row.name,
|
||||||
|
row.format,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.megapixels,
|
||||||
|
row.input_bytes / 1024,
|
||||||
|
row.output_bytes / 1024,
|
||||||
|
row.single_ms,
|
||||||
|
row.peak_heap_bytes as f64 / MB,
|
||||||
|
);
|
||||||
|
peak_rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table B: saturated throughput (JPEG photo sizes only) ---
|
||||||
|
println!("\n== B. Saturated throughput ({threads} threads, 3s window) ==");
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:>11} | {:>6} | {:>7} | {:>8} | {:>11} | {:>14} |",
|
||||||
|
"case", "source", "MP", "threads", "photos", "photos/sec", "eff ms/photo"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"|{:-<19}|{:-<13}|{:-<8}|{:-<9}|{:-<10}|{:-<13}|{:-<16}|",
|
||||||
|
"", "", "", "", "", "", ""
|
||||||
|
);
|
||||||
|
let mut tp_rows = Vec::new();
|
||||||
|
for case in corpus.iter().filter(|c| is_throughput_case(c.name)) {
|
||||||
|
let row = measure_throughput(case, threads, Duration::from_secs(3));
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:>5}×{:<5} | {:>6.1} | {:>7} | {:>8} | {:>11.1} | {:>14.2} |",
|
||||||
|
row.name,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.megapixels,
|
||||||
|
row.threads,
|
||||||
|
row.photos,
|
||||||
|
row.photos_per_sec,
|
||||||
|
row.eff_ms_per_photo,
|
||||||
|
);
|
||||||
|
tp_rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table C: quality — shrink-on-load vs full decode (Task 1.1 gate) ---
|
||||||
|
println!("\n== C. Quality: shrink-on-load vs full-decode, Preview 400px ==");
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:>11} | {:>7} | {:>9} |",
|
||||||
|
"case", "thumb dims", "SSIM", "PSNR dB"
|
||||||
|
);
|
||||||
|
println!("|{:-<19}|{:-<13}|{:-<9}|{:-<11}|", "", "", "", "");
|
||||||
|
for case in corpus.iter().filter(|c| {
|
||||||
|
matches!(
|
||||||
|
c.name,
|
||||||
|
"jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp" | "small_300"
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
let new_jpeg =
|
||||||
|
ThumbnailService::bench_render_thumbnail(&case.bytes, ThumbnailSize::Preview)
|
||||||
|
.expect("shrink-on-load render");
|
||||||
|
let ref_jpeg = reference_render_full_decode(&case.bytes, 400);
|
||||||
|
let (a, aw, ah) = decode_to_luma(&new_jpeg);
|
||||||
|
let (b, bw, bh) = decode_to_luma(&ref_jpeg);
|
||||||
|
if (aw, ah) != (bw, bh) {
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:>4}×{:<4} ⚠ ref {}×{} (dim mismatch) |",
|
||||||
|
case.name, aw, ah, bw, bh
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ssim = block_ssim(&a, &b, aw, ah);
|
||||||
|
let psnr = psnr(&a, &b);
|
||||||
|
let flag = if ssim >= 0.98 { "" } else { " ⚠ below 0.98" };
|
||||||
|
println!(
|
||||||
|
"| {:<17} | {:>4}×{:<4} | {:>7.4} | {:>9.2} |{}",
|
||||||
|
case.name, aw, ah, ssim, psnr, flag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(threads, &peak_rows, &tp_rows);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nWrote machine-readable baseline → {}",
|
||||||
|
json_path().display()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"For Max RSS / CPU time cross-check, re-run under:\n /usr/bin/time -l ./target/release/examples/bench_thumbnails_mem\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throughput is only meaningful on the realistic upload load — the JPEG photo
|
||||||
|
/// sizes. (Tiny / GIF / WebP cases stay in the per-image table.)
|
||||||
|
fn is_throughput_case(name: &str) -> bool {
|
||||||
|
matches!(name, "jpeg_12mp" | "jpeg_24mp" | "jpeg_48mp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_path() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("target")
|
||||||
|
.join("bench-baseline-fase0.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json(threads: usize, peak_rows: &[PeakRow], tp_rows: &[ThroughputRow]) {
|
||||||
|
let per_image: Vec<_> = peak_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"case": r.name,
|
||||||
|
"format": r.format,
|
||||||
|
"width": r.width,
|
||||||
|
"height": r.height,
|
||||||
|
"megapixels": r.megapixels,
|
||||||
|
"input_bytes": r.input_bytes,
|
||||||
|
"output_bytes": r.output_bytes,
|
||||||
|
"single_ms": r.single_ms,
|
||||||
|
"peak_heap_bytes": r.peak_heap_bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let throughput: Vec<_> = tp_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"case": r.name,
|
||||||
|
"width": r.width,
|
||||||
|
"height": r.height,
|
||||||
|
"megapixels": r.megapixels,
|
||||||
|
"threads": r.threads,
|
||||||
|
"seconds": r.seconds,
|
||||||
|
"photos": r.photos,
|
||||||
|
"photos_per_sec": r.photos_per_sec,
|
||||||
|
"eff_ms_per_photo": r.eff_ms_per_photo,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let doc = serde_json::json!({
|
||||||
|
"phase": 0,
|
||||||
|
"label": "baseline-image-crate",
|
||||||
|
"cores": threads,
|
||||||
|
"per_image": per_image,
|
||||||
|
"throughput": throughput,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(
|
||||||
|
json_path(),
|
||||||
|
serde_json::to_string_pretty(&doc).unwrap_or_default(),
|
||||||
|
) {
|
||||||
|
eprintln!("could not write {}: {e}", json_path().display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quality verification helpers (Table C)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reference thumbnail: identical resample + q80 JPEG encode as production, but
|
||||||
|
/// forced through a **full decode** (no shrink-on-load). Comparing against this
|
||||||
|
/// isolates exactly the quality impact of DCT scale-on-decode. EXIF orientation
|
||||||
|
/// is not applied here, so only run it on orientation=1 corpus cases.
|
||||||
|
fn reference_render_full_decode(bytes: &[u8], max_dim: u32) -> Vec<u8> {
|
||||||
|
let img = image::load_from_memory(bytes).expect("ref full decode");
|
||||||
|
let (ow, oh) = (img.width(), img.height());
|
||||||
|
let (nw, nh) = if ow > oh {
|
||||||
|
(max_dim, (oh as f32 * (max_dim as f32 / ow as f32)) as u32)
|
||||||
|
} else {
|
||||||
|
((ow as f32 * (max_dim as f32 / oh as f32)) as u32, max_dim)
|
||||||
|
};
|
||||||
|
let rgb = img
|
||||||
|
.resize(nw, nh, image::imageops::FilterType::CatmullRom)
|
||||||
|
.to_rgb8();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 80);
|
||||||
|
rgb.write_with_encoder(enc).expect("ref encode");
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a JPEG thumbnail back to an 8-bit luma plane for comparison.
|
||||||
|
fn decode_to_luma(jpeg: &[u8]) -> (Vec<u8>, u32, u32) {
|
||||||
|
let img = image::load_from_memory(jpeg).expect("decode thumbnail");
|
||||||
|
let luma = img.to_luma8();
|
||||||
|
let (w, h) = (luma.width(), luma.height());
|
||||||
|
(luma.into_raw(), w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean SSIM over non-overlapping 8×8 blocks (luma). 1.0 = identical.
|
||||||
|
fn block_ssim(a: &[u8], b: &[u8], w: u32, h: u32) -> f64 {
|
||||||
|
const C1: f64 = (0.01 * 255.0) * (0.01 * 255.0);
|
||||||
|
const C2: f64 = (0.03 * 255.0) * (0.03 * 255.0);
|
||||||
|
let (w, h) = (w as usize, h as usize);
|
||||||
|
let bs = 8usize;
|
||||||
|
let mut acc = 0.0;
|
||||||
|
let mut blocks = 0.0;
|
||||||
|
let mut by = 0;
|
||||||
|
while by < h {
|
||||||
|
let mut bx = 0;
|
||||||
|
while bx < w {
|
||||||
|
let (mut sa, mut sb, mut saa, mut sbb, mut sab, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||||
|
for y in by..(by + bs).min(h) {
|
||||||
|
for x in bx..(bx + bs).min(w) {
|
||||||
|
let ia = a[y * w + x] as f64;
|
||||||
|
let ib = b[y * w + x] as f64;
|
||||||
|
sa += ia;
|
||||||
|
sb += ib;
|
||||||
|
saa += ia * ia;
|
||||||
|
sbb += ib * ib;
|
||||||
|
sab += ia * ib;
|
||||||
|
n += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (ma, mb) = (sa / n, sb / n);
|
||||||
|
let va = (saa / n - ma * ma).max(0.0);
|
||||||
|
let vb = (sbb / n - mb * mb).max(0.0);
|
||||||
|
let cov = sab / n - ma * mb;
|
||||||
|
let s = ((2.0 * ma * mb + C1) * (2.0 * cov + C2))
|
||||||
|
/ ((ma * ma + mb * mb + C1) * (va + vb + C2));
|
||||||
|
acc += s;
|
||||||
|
blocks += 1.0;
|
||||||
|
bx += bs;
|
||||||
|
}
|
||||||
|
by += bs;
|
||||||
|
}
|
||||||
|
if blocks > 0.0 { acc / blocks } else { 1.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peak signal-to-noise ratio (luma). ∞ for identical inputs.
|
||||||
|
fn psnr(a: &[u8], b: &[u8]) -> f64 {
|
||||||
|
let n = a.len().min(b.len());
|
||||||
|
if n == 0 {
|
||||||
|
return f64::INFINITY;
|
||||||
|
}
|
||||||
|
let mse: f64 = a
|
||||||
|
.iter()
|
||||||
|
.zip(b.iter())
|
||||||
|
.take(n)
|
||||||
|
.map(|(&x, &y)| {
|
||||||
|
let d = x as f64 - y as f64;
|
||||||
|
d * d
|
||||||
|
})
|
||||||
|
.sum::<f64>()
|
||||||
|
/ n as f64;
|
||||||
|
if mse == 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
10.0 * (255.0 * 255.0 / mse).log10()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! Phase 0 perf-benchmark support — deterministic image corpus.
|
||||||
|
//!
|
||||||
|
//! Shared by `benches/thumbnails.rs` (Task 0.2, criterion latency + output
|
||||||
|
//! size) and `examples/bench_thumbnails_mem.rs` (Task 0.3, peak RAM +
|
||||||
|
//! throughput). Gated behind the `bench` feature so it never touches normal
|
||||||
|
//! builds.
|
||||||
|
//!
|
||||||
|
//! The corpus is **generated deterministically** (a low-frequency gradient plus
|
||||||
|
//! seeded high-frequency xorshift noise) so it is reproducible, license-free and
|
||||||
|
//! gives the decoder/resizer realistic work without committing large binaries to
|
||||||
|
//! git. Files are written to `benches/corpus/` (git-ignored) on first run and
|
||||||
|
//! reused afterwards.
|
||||||
|
//!
|
||||||
|
//! Files already present on disk are **always preferred** over generation — so
|
||||||
|
//! you can drop your own real photos into `benches/corpus/` using the documented
|
||||||
|
//! filenames (see [`CASE_SPECS`]) to benchmark against real-world data.
|
||||||
|
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
|
||||||
|
|
||||||
|
/// One corpus entry: the encoded file bytes plus its probed dimensions.
|
||||||
|
pub struct CorpusCase {
|
||||||
|
/// Stable identifier (e.g. `"jpeg_12mp"`), used as the bench label.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Container format (`"jpeg"`, `"png"`, `"gif"`, `"webp"`).
|
||||||
|
pub format: &'static str,
|
||||||
|
/// Actual decoded width (probed from the bytes).
|
||||||
|
pub width: u32,
|
||||||
|
/// Actual decoded height (probed from the bytes).
|
||||||
|
pub height: u32,
|
||||||
|
/// Encoded file bytes (what the thumbnail pipeline receives as `&[u8]`).
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorpusCase {
|
||||||
|
/// Megapixels of the source image (decoded resolution).
|
||||||
|
pub fn megapixels(&self) -> f64 {
|
||||||
|
(self.width as f64 * self.height as f64) / 1_000_000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declarative description of a synthetic corpus image.
|
||||||
|
struct Spec {
|
||||||
|
name: &'static str,
|
||||||
|
filename: &'static str,
|
||||||
|
format: &'static str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
/// JPEG quality (ignored for non-JPEG formats).
|
||||||
|
quality: u8,
|
||||||
|
/// When set, injects an EXIF Orientation tag into the JPEG (e.g. 6 = rotate
|
||||||
|
/// 90° CW) to exercise the orientation-correction path.
|
||||||
|
exif_orientation: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The corpus matrix: a spread of sizes and formats so we never trust an
|
||||||
|
/// average. Drop a real photo at `benches/corpus/<filename>` to override any
|
||||||
|
/// entry with real-world data.
|
||||||
|
const CASE_SPECS: &[Spec] = &[
|
||||||
|
// JPEG photo-like sources at the three sizes that matter for "hundreds of
|
||||||
|
// phone/camera photos". These dominate the real upload load.
|
||||||
|
Spec {
|
||||||
|
name: "jpeg_12mp",
|
||||||
|
filename: "jpeg_12mp.jpg",
|
||||||
|
format: "jpeg",
|
||||||
|
width: 4000,
|
||||||
|
height: 3000,
|
||||||
|
quality: 90,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
Spec {
|
||||||
|
name: "jpeg_24mp",
|
||||||
|
filename: "jpeg_24mp.jpg",
|
||||||
|
format: "jpeg",
|
||||||
|
width: 6000,
|
||||||
|
height: 4000,
|
||||||
|
quality: 90,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
// 8000×6000 = 48 MP, just under the 50 MP MAX_DECODE_PIXELS guard.
|
||||||
|
Spec {
|
||||||
|
name: "jpeg_48mp",
|
||||||
|
filename: "jpeg_48mp.jpg",
|
||||||
|
format: "jpeg",
|
||||||
|
width: 8000,
|
||||||
|
height: 6000,
|
||||||
|
quality: 90,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
// Non-JPEG decode paths (no DCT shrink-on-load possible — useful contrast).
|
||||||
|
Spec {
|
||||||
|
name: "png_large",
|
||||||
|
filename: "png_large.png",
|
||||||
|
format: "png",
|
||||||
|
width: 3000,
|
||||||
|
height: 2000,
|
||||||
|
quality: 0,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
Spec {
|
||||||
|
name: "gif_large",
|
||||||
|
filename: "gif_large.gif",
|
||||||
|
format: "gif",
|
||||||
|
width: 600,
|
||||||
|
height: 600,
|
||||||
|
quality: 0,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
Spec {
|
||||||
|
name: "webp_large",
|
||||||
|
filename: "webp_large.webp",
|
||||||
|
format: "webp",
|
||||||
|
width: 1280,
|
||||||
|
height: 853,
|
||||||
|
quality: 0,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
// Small source: exercises the (future) no-upscale clamp — Large=800 target
|
||||||
|
// is bigger than the 300 px source.
|
||||||
|
Spec {
|
||||||
|
name: "small_300",
|
||||||
|
filename: "small_300.jpg",
|
||||||
|
format: "jpeg",
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
quality: 90,
|
||||||
|
exif_orientation: None,
|
||||||
|
},
|
||||||
|
// EXIF orientation ≠ 1: exercises the rotate/flip correction path.
|
||||||
|
Spec {
|
||||||
|
name: "jpeg_exif_orient",
|
||||||
|
filename: "jpeg_exif_orient.jpg",
|
||||||
|
format: "jpeg",
|
||||||
|
width: 4000,
|
||||||
|
height: 3000,
|
||||||
|
quality: 90,
|
||||||
|
exif_orientation: Some(6),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Absolute path to `benches/corpus/` next to this crate's `Cargo.toml`.
|
||||||
|
pub fn corpus_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("benches")
|
||||||
|
.join("corpus")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the corpus, generating any missing files on disk first.
|
||||||
|
///
|
||||||
|
/// Existing files win, so user-provided real photos are used as-is. A case that
|
||||||
|
/// fails to generate (e.g. an unavailable encoder) is logged and skipped rather
|
||||||
|
/// than aborting the whole baseline.
|
||||||
|
pub fn load_or_generate() -> Vec<CorpusCase> {
|
||||||
|
let dir = corpus_dir();
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||||
|
panic!("bench_support: cannot create {}: {e}", dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for spec in CASE_SPECS {
|
||||||
|
let path = dir.join(spec.filename);
|
||||||
|
let bytes = if path.exists() {
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("bench_support: skipping {} (read failed: {e})", spec.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match generate(spec) {
|
||||||
|
Ok(b) => {
|
||||||
|
if let Err(e) = std::fs::write(&path, &b) {
|
||||||
|
eprintln!("bench_support: could not cache {} ({e})", path.display());
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"bench_support: skipping {} (generate failed: {e})",
|
||||||
|
spec.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (width, height) = probe_dimensions(&bytes).unwrap_or((spec.width, spec.height));
|
||||||
|
out.push(CorpusCase {
|
||||||
|
name: spec.name,
|
||||||
|
format: spec.format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the decoded dimensions of encoded image bytes without a full decode.
|
||||||
|
fn probe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
image::ImageReader::new(Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?
|
||||||
|
.into_dimensions()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render and encode one spec into file bytes.
|
||||||
|
fn generate(spec: &Spec) -> Result<Vec<u8>, String> {
|
||||||
|
let img = synthesize(spec.width, spec.height, seed_for(spec.name));
|
||||||
|
|
||||||
|
match spec.format {
|
||||||
|
"jpeg" => {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let encoder = JpegEncoder::new_with_quality(&mut buf, spec.quality);
|
||||||
|
img.write_with_encoder(encoder)
|
||||||
|
.map_err(|e| format!("jpeg encode: {e}"))?;
|
||||||
|
match spec.exif_orientation {
|
||||||
|
Some(o) => inject_exif_orientation(&buf, o),
|
||||||
|
None => Ok(buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let fmt = match other {
|
||||||
|
"png" => ImageFormat::Png,
|
||||||
|
"gif" => ImageFormat::Gif,
|
||||||
|
"webp" => ImageFormat::WebP,
|
||||||
|
_ => return Err(format!("unknown format {other}")),
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut buf), fmt)
|
||||||
|
.map_err(|e| format!("{other} encode: {e}"))?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a photo-like RGB image: a smooth diagonal gradient (low frequency)
|
||||||
|
/// plus seeded ±32 white noise (high frequency). Deterministic for a given
|
||||||
|
/// seed, so corpus bytes are byte-stable across runs and machines.
|
||||||
|
fn synthesize(width: u32, height: u32, seed: u64) -> RgbImage {
|
||||||
|
let mut img = RgbImage::new(width, height);
|
||||||
|
let mut state = seed | 1; // xorshift requires a non-zero state
|
||||||
|
let (w, h) = (width.max(1), height.max(1));
|
||||||
|
for y in 0..height {
|
||||||
|
let gy = (y as i32 * 255 / h as i32).clamp(0, 255);
|
||||||
|
for x in 0..width {
|
||||||
|
let gx = (x as i32 * 255 / w as i32).clamp(0, 255);
|
||||||
|
let noise = (xorshift(&mut state) & 0x3F) as i32 - 32; // -32..=31
|
||||||
|
let r = (gx + noise).clamp(0, 255) as u8;
|
||||||
|
let g = (gy + noise).clamp(0, 255) as u8;
|
||||||
|
let b = (((gx + gy) / 2) + noise).clamp(0, 255) as u8;
|
||||||
|
img.put_pixel(x, y, Rgb([r, g, b]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny xorshift64 PRNG — fast, deterministic, no dependency.
|
||||||
|
fn xorshift(state: &mut u64) -> u64 {
|
||||||
|
let mut x = *state;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
*state = x;
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-case fixed seed so each image has distinct noise but stays reproducible.
|
||||||
|
fn seed_for(name: &str) -> u64 {
|
||||||
|
// FNV-1a over the name → splitmix-ish spread.
|
||||||
|
let mut hash: u64 = 0xcbf29ce484222325;
|
||||||
|
for b in name.bytes() {
|
||||||
|
hash ^= b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
hash.wrapping_mul(0x9E3779B97F4A7C15) | 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice a minimal, standard EXIF APP1 segment carrying a single Orientation
|
||||||
|
/// tag into a baseline JPEG, right after the SOI marker. Little-endian TIFF.
|
||||||
|
fn inject_exif_orientation(jpeg: &[u8], orientation: u16) -> Result<Vec<u8>, String> {
|
||||||
|
if jpeg.len() < 2 || jpeg[0] != 0xFF || jpeg[1] != 0xD8 {
|
||||||
|
return Err("not a JPEG (missing SOI)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIFF body (little-endian "II").
|
||||||
|
let mut tiff = Vec::new();
|
||||||
|
tiff.extend_from_slice(b"II");
|
||||||
|
tiff.extend_from_slice(&0x2Au16.to_le_bytes()); // magic 42
|
||||||
|
tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 offset
|
||||||
|
tiff.extend_from_slice(&1u16.to_le_bytes()); // 1 directory entry
|
||||||
|
tiff.extend_from_slice(&0x0112u16.to_le_bytes()); // tag: Orientation
|
||||||
|
tiff.extend_from_slice(&3u16.to_le_bytes()); // type: SHORT
|
||||||
|
tiff.extend_from_slice(&1u32.to_le_bytes()); // count
|
||||||
|
tiff.extend_from_slice(&(orientation as u32).to_le_bytes()); // value (SHORT in low bytes)
|
||||||
|
tiff.extend_from_slice(&0u32.to_le_bytes()); // next IFD = none
|
||||||
|
|
||||||
|
let mut payload = Vec::with_capacity(6 + tiff.len());
|
||||||
|
payload.extend_from_slice(b"Exif\0\0");
|
||||||
|
payload.extend_from_slice(&tiff);
|
||||||
|
|
||||||
|
let seg_len = u16::try_from(2 + payload.len()).map_err(|_| "EXIF segment too large")?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(jpeg.len() + 4 + payload.len());
|
||||||
|
out.extend_from_slice(&jpeg[0..2]); // SOI
|
||||||
|
out.extend_from_slice(&[0xFF, 0xE1]); // APP1 marker
|
||||||
|
out.extend_from_slice(&seg_len.to_be_bytes()); // APP1 length (big-endian)
|
||||||
|
out.extend_from_slice(&payload);
|
||||||
|
out.extend_from_slice(&jpeg[2..]); // rest of the original JPEG
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -551,11 +551,31 @@ impl ThumbnailService {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_thumbnail_from_data(
|
/// Cheap magic-byte check for a JPEG container (SOI + first marker).
|
||||||
|
fn is_jpeg(data: &[u8]) -> bool {
|
||||||
|
data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `data` into a `DynamicImage` sized for a thumbnail whose longest
|
||||||
|
/// side is `target_long`, with EXIF orientation already applied.
|
||||||
|
///
|
||||||
|
/// For JPEG this is **shrink-on-load**: the decoder emits the image at the
|
||||||
|
/// smallest DCT scale (1/8·1/4·1/2·1/1) whose longest axis is still ≥
|
||||||
|
/// `target_long`, so a 12 MP photo decodes ~16× fewer pixels for an 800 px
|
||||||
|
/// thumbnail — and the full-resolution bitmap (the dominant time/RAM cost)
|
||||||
|
/// is never materialised. PNG/GIF/WebP (no DCT scaling) and unusual JPEG
|
||||||
|
/// colour spaces (CMYK / 16-bit grey) fall back to a full decode.
|
||||||
|
fn decode_oriented(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
size: ThumbnailSize,
|
target_long: u32,
|
||||||
) -> Result<Vec<u8>, ThumbnailError> {
|
) -> Result<image::DynamicImage, ThumbnailError> {
|
||||||
let max_dim = size.max_dimension();
|
// JPEG fast path: shrink-on-load. A non-JPEG, or a JPEG colour space we
|
||||||
|
// don't map (CMYK / 16-bit grey → `None`), falls through to a full decode.
|
||||||
|
if Self::is_jpeg(data)
|
||||||
|
&& let Some(img) = Self::decode_jpeg_scaled(data, target_long)?
|
||||||
|
{
|
||||||
|
return Ok(Self::apply_exif_orientation(data, img));
|
||||||
|
}
|
||||||
|
|
||||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(data))
|
let (w, h) = image::ImageReader::new(std::io::Cursor::new(data))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
@@ -568,17 +588,74 @@ impl ThumbnailService {
|
|||||||
w as u64 * h as u64 / 1_000_000
|
w as u64 * h as u64 / 1_000_000
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let img =
|
let img =
|
||||||
image::load_from_memory(data).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
image::load_from_memory(data).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
Ok(Self::apply_exif_orientation(data, img))
|
||||||
|
}
|
||||||
|
|
||||||
let img = {
|
/// JPEG shrink-on-load. Returns `Ok(None)` for colour spaces we don't map
|
||||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
/// (CMYK / 16-bit grey), signalling the caller to fall back to a full decode.
|
||||||
let orientation = ExifService::extract(data)
|
fn decode_jpeg_scaled(
|
||||||
.and_then(|m| m.orientation)
|
data: &[u8],
|
||||||
.unwrap_or(1);
|
target_long: u32,
|
||||||
apply_orientation(img, orientation)
|
) -> Result<Option<image::DynamicImage>, ThumbnailError> {
|
||||||
|
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(data));
|
||||||
|
// read_info() first so the dimension guard sees the *original* size.
|
||||||
|
decoder
|
||||||
|
.read_info()
|
||||||
|
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
let info = decoder
|
||||||
|
.info()
|
||||||
|
.ok_or_else(|| ThumbnailError::ImageError("missing JPEG metadata".into()))?;
|
||||||
|
let (orig_w, orig_h) = (info.width as u64, info.height as u64);
|
||||||
|
if orig_w * orig_h > MAX_DECODE_PIXELS {
|
||||||
|
return Err(ThumbnailError::ImageError(format!(
|
||||||
|
"Image too large for thumbnail: {orig_w}×{orig_h} ({} MP, max {MAX_DECODE_PIXELS})",
|
||||||
|
orig_w * orig_h / 1_000_000
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request a `target_long` square box: jpeg-decoder picks the smallest
|
||||||
|
// scale whose longest axis is still ≥ target_long (its "≥ in at least
|
||||||
|
// one axis" rule reduces to the long axis since it dominates), so the
|
||||||
|
// later resample step only ever downscales — never upscales/blurs.
|
||||||
|
let req = target_long.min(u16::MAX as u32) as u16;
|
||||||
|
let (sw, sh) = decoder
|
||||||
|
.scale(req, req)
|
||||||
|
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
let pixels = decoder
|
||||||
|
.decode()
|
||||||
|
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
|
||||||
|
let (sw, sh) = (sw as u32, sh as u32);
|
||||||
|
let img = match decoder.info().map(|i| i.pixel_format) {
|
||||||
|
Some(jpeg_decoder::PixelFormat::RGB24) => {
|
||||||
|
image::RgbImage::from_raw(sw, sh, pixels).map(image::DynamicImage::ImageRgb8)
|
||||||
|
}
|
||||||
|
Some(jpeg_decoder::PixelFormat::L8) => {
|
||||||
|
image::GrayImage::from_raw(sw, sh, pixels).map(image::DynamicImage::ImageLuma8)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read EXIF orientation from the original bytes and rotate/flip the image.
|
||||||
|
/// (Applied after shrink-on-load, so the rotation works on the small bitmap.)
|
||||||
|
fn apply_exif_orientation(data: &[u8], img: image::DynamicImage) -> image::DynamicImage {
|
||||||
|
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
||||||
|
let orientation = ExifService::extract(data)
|
||||||
|
.and_then(|m| m.orientation)
|
||||||
|
.unwrap_or(1);
|
||||||
|
apply_orientation(img, orientation)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_thumbnail_from_data(
|
||||||
|
data: &[u8],
|
||||||
|
size: ThumbnailSize,
|
||||||
|
) -> Result<Vec<u8>, ThumbnailError> {
|
||||||
|
let max_dim = size.max_dimension();
|
||||||
|
let img = Self::decode_oriented(data, max_dim)?;
|
||||||
|
|
||||||
let (orig_width, orig_height) = (img.width(), img.height());
|
let (orig_width, orig_height) = (img.width(), img.height());
|
||||||
let (new_width, new_height) = if orig_width > orig_height {
|
let (new_width, new_height) = if orig_width > orig_height {
|
||||||
@@ -608,29 +685,11 @@ impl ThumbnailService {
|
|||||||
fn render_all_thumbnails_from_data(
|
fn render_all_thumbnails_from_data(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
) -> Result<Vec<(ThumbnailSize, Bytes)>, ThumbnailError> {
|
) -> Result<Vec<(ThumbnailSize, Bytes)>, ThumbnailError> {
|
||||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(data))
|
// Decode once, shrunk-on-load for the largest size (800 px); all three
|
||||||
.with_guessed_format()
|
// sizes are then resampled from this single shared bitmap. Sizing the
|
||||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?
|
// shared decode to Large keeps quality for every size while paying the
|
||||||
.into_dimensions()
|
// (now much smaller) decode cost only once.
|
||||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
let img = Self::decode_oriented(data, ThumbnailSize::Large.max_dimension())?;
|
||||||
if (w as u64) * (h as u64) > MAX_DECODE_PIXELS {
|
|
||||||
return Err(ThumbnailError::ImageError(format!(
|
|
||||||
"Image too large for thumbnail: {w}×{h} ({} MP, max {MAX_DECODE_PIXELS})",
|
|
||||||
w as u64 * h as u64 / 1_000_000
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let img =
|
|
||||||
image::load_from_memory(data).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
|
||||||
|
|
||||||
let img = {
|
|
||||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
|
||||||
let orientation = ExifService::extract(data)
|
|
||||||
.and_then(|m| m.orientation)
|
|
||||||
.unwrap_or(1);
|
|
||||||
apply_orientation(img, orientation)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (orig_w, orig_h) = (img.width(), img.height());
|
let (orig_w, orig_h) = (img.width(), img.height());
|
||||||
|
|
||||||
ThumbnailSize::all()
|
ThumbnailSize::all()
|
||||||
@@ -1271,6 +1330,31 @@ impl ThumbnailPort for ThumbnailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Benchmark-only public surface (Phase 0 perf harness).
|
||||||
|
///
|
||||||
|
/// The real render functions are private (`render_thumbnail_from_data` /
|
||||||
|
/// `render_all_thumbnails_from_data`). Benches and examples are separate crate
|
||||||
|
/// targets and can only see `pub` items, so these thin wrappers — gated behind
|
||||||
|
/// the `bench` feature so they never exist in production builds — expose the
|
||||||
|
/// exact CPU-bound work (decode → orientation → resize → JPEG encode) for
|
||||||
|
/// before/after measurement. Errors are flattened to `String` to avoid leaking
|
||||||
|
/// `ThumbnailError` into the public API.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
impl ThumbnailService {
|
||||||
|
/// Render a single thumbnail size, returning the encoded JPEG bytes.
|
||||||
|
pub fn bench_render_thumbnail(data: &[u8], size: ThumbnailSize) -> Result<Vec<u8>, String> {
|
||||||
|
Self::render_thumbnail_from_data(data, size).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render all sizes in one decode (the upload-time path), returning each
|
||||||
|
/// size paired with its encoded byte length (output-size baseline).
|
||||||
|
pub fn bench_render_all(data: &[u8]) -> Result<Vec<(ThumbnailSize, usize)>, String> {
|
||||||
|
Self::render_all_thumbnails_from_data(data)
|
||||||
|
.map(|v| v.into_iter().map(|(s, b)| (s, b.len())).collect())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Thumbnail service errors
|
/// Thumbnail service errors
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ThumbnailError {
|
pub enum ThumbnailError {
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ pub mod interfaces;
|
|||||||
#[cfg(integration_tests)]
|
#[cfg(integration_tests)]
|
||||||
pub mod integration_test_support;
|
pub mod integration_test_support;
|
||||||
|
|
||||||
|
// Phase 0 perf-benchmark support: deterministic image corpus generation/loading
|
||||||
|
// shared by `benches/thumbnails.rs` and `examples/bench_thumbnails_mem.rs`.
|
||||||
|
// Gated behind the `bench` feature so it adds nothing to normal builds.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub mod bench_support;
|
||||||
|
|
||||||
// Common public re-exports
|
// Common public re-exports
|
||||||
pub use application::services::folder_service::FolderService;
|
pub use application::services::folder_service::FolderService;
|
||||||
pub use application::services::i18n_application_service::I18nApplicationService;
|
pub use application::services::i18n_application_service::I18nApplicationService;
|
||||||
|
|||||||
Reference in New Issue
Block a user