Skip to main content

zenpixels_convert/
estimate.rs

1//! Resource-estimation primitive for [`ConvertPlan`].
2//!
3//! [`ConvertPlan::estimate`](crate::ConvertPlan::estimate) and
4//! [`ConvertPlan::estimate_in`](crate::ConvertPlan::estimate_in) walk a plan's
5//! steps and return a [`ResourceEstimate`] (peak memory, wall-ms,
6//! intermediate-buffer count) with no allocation or row work, so schedulers
7//! can decide whether an op fits a memory budget / SLA before it runs.
8//!
9//! Accuracy contract, calibration source, threading model, and the
10//! foundation-crate / `zencodec::estimate::*` shape-compatibility rationale
11//! live in [`docs/ESTIMATE.md`](https://github.com/imazen/zenpixels/blob/main/zenpixels-convert/docs/ESTIMATE.md).
12//!
13//! Every field is `Option`, all structs are `#[non_exhaustive]`, and builders
14//! are growable: future fields land additively at every match-bind site.
15
16use crate::PixelDescriptor;
17use crate::convert::{ConvertPlan, ConvertStep, FusedKind};
18
19/// SIMD instruction tier the codec will dispatch to. Optional hint on
20/// [`ComputeEnvironment`] — a wider/newer tier generally means faster
21/// encode/decode, so estimates can apply a per-tier time factor. Variants
22/// mirror the `x86-64-vN` microarchitecture levels and the archmage /
23/// magetypes token vocabulary, so an archmage-detected tier maps trivially.
24///
25/// ```rust,ignore
26/// use zenpixels_convert::{ComputeEnvironment, SimdTier};
27/// let tier = if archmage::X64V4Token::summon().is_some() { SimdTier::X86V4 }
28///     else if archmage::X64V3Token::summon().is_some() { SimdTier::X86V3 }
29///     else if archmage::X64V2Token::summon().is_some() { SimdTier::X86V2 }
30///     else { SimdTier::X86V1 };
31/// let env = ComputeEnvironment::new().with_cores(8).with_simd_tier(tier);
32/// ```
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum SimdTier {
36    /// SIMD tier unknown. Currently treated as the AVX2 calibration
37    /// baseline (multiplier 1.0) — there is no separate conservative
38    /// cross-tier model yet; one lands with the per-tier calibration
39    /// sweep (see the TODO on `estimate_plan`). Use
40    /// [`CurrentHost`](SimdTier::CurrentHost) for the local machine.
41    Unknown,
42    /// Host running the estimate (≈ calibration host's native tier). Distinct
43    /// from [`Unknown`](SimdTier::Unknown) which is a cross-tier average.
44    CurrentHost,
45    /// WebAssembly, no SIMD128 (scalar wasm).
46    Wasm,
47    /// WebAssembly SIMD128.
48    Wasm128,
49    /// AArch64 / ARM NEON (archmage `NeonToken`).
50    Neon,
51    /// x86-64-v1 — SSE2 baseline.
52    X86V1,
53    /// x86-64-v2 — SSE4.2 (archmage `X64V2Token`).
54    X86V2,
55    /// x86-64-v3 — AVX2 + FMA (archmage `X64V3Token`).
56    X86V3,
57    /// x86-64-v4 — AVX-512 (archmage `X64V4Token`).
58    X86V4,
59}
60
61/// Hardware + runtime conditions for a resource estimate. `#[non_exhaustive]`
62/// and shape-compatible with `zencodec::estimate::ComputeEnvironment`:
63/// construct via [`new`](Self::new), refine with `with_*` setters, read with
64/// the accessors. Carries cores + optional [`SimdTier`] + optional RAM today.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct ComputeEnvironment {
68    available_cores: usize,
69    available_ram_bytes: Option<u64>,
70    simd_tier: Option<SimdTier>,
71}
72
73#[rustfmt::skip]
74impl ComputeEnvironment {
75    /// Single-core, unknown RAM, unspecified SIMD tier (conservative default).
76    #[must_use] pub fn new() -> Self { Self { available_cores: 1, available_ram_bytes: None, simd_tier: None } }
77    /// CPU cores available, clamped to ≥ 1. `std` callers typically pass
78    /// `std::thread::available_parallelism()`.
79    #[must_use] pub fn with_cores(mut self, cores: usize) -> Self { self.available_cores = cores.max(1); self }
80    /// Physical RAM available, for memory-ceiling decisions.
81    #[must_use] pub fn with_available_ram_bytes(mut self, bytes: u64) -> Self { self.available_ram_bytes = Some(bytes); self }
82    /// SIMD instruction tier the codec will dispatch to.
83    #[must_use] pub fn with_simd_tier(mut self, tier: SimdTier) -> Self { self.simd_tier = Some(tier); self }
84    /// Available CPU cores (≥ 1).
85    #[must_use] pub fn cores(&self) -> usize { self.available_cores }
86    /// Available RAM in bytes, if known.
87    #[must_use] pub fn available_ram_bytes(&self) -> Option<u64> { self.available_ram_bytes }
88    /// The SIMD tier hint, if specified.
89    #[must_use] pub fn simd_tier(&self) -> Option<SimdTier> { self.simd_tier }
90}
91
92impl Default for ComputeEnvironment {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// Characteristics of the image being encoded/decoded. `#[non_exhaustive]`
99/// and shape-compatible with `zencodec::estimate::ImageCharacteristics`.
100/// Carries dimensions + pixel format today; per-frame, so animation fields
101/// belong on codec types, not here.
102#[derive(Clone, Debug, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct ImageCharacteristics {
105    width: u32,
106    height: u32,
107    descriptor: PixelDescriptor,
108}
109
110#[rustfmt::skip]
111impl ImageCharacteristics {
112    /// A still image of `width` × `height` with the given pixel format.
113    #[must_use] pub fn new(width: u32, height: u32, descriptor: PixelDescriptor) -> Self { Self { width, height, descriptor } }
114    /// Image width in pixels.
115    #[must_use] pub fn width(&self) -> u32 { self.width }
116    /// Image height in pixels.
117    #[must_use] pub fn height(&self) -> u32 { self.height }
118    /// The pixel format of the source/decoded buffer.
119    #[must_use] pub fn descriptor(&self) -> &PixelDescriptor { &self.descriptor }
120}
121
122/// Predicted resources for a conversion plan. Every field is `Option` —
123/// fillers may model what they can and leave the rest `None`. `wall_ms` is
124/// already scaled to `compute.cores()` when produced by
125/// [`ConvertPlan::estimate_in`](crate::ConvertPlan::estimate_in); the internal
126/// threading-bottleneck model is in `estimate_plan` (`docs/ESTIMATE.md`).
127/// `#[non_exhaustive]`, shape-compatible with
128/// `zencodec::estimate::ResourceEstimate`; build via [`new`](Self::new) /
129/// [`unknown`](Self::unknown) + the `with_*` setters.
130#[derive(Clone, Copy, Debug, PartialEq)]
131#[non_exhaustive]
132pub struct ResourceEstimate {
133    peak_memory_bytes_est: Option<u64>,
134    wall_ms: Option<u64>,
135    intermediate_buffer_count: Option<u32>,
136}
137
138#[rustfmt::skip]
139impl ResourceEstimate {
140    /// All-`None` estimate — for stages that don't model their resource use.
141    #[must_use] pub fn unknown() -> Self { Self { peak_memory_bytes_est: None, wall_ms: None, intermediate_buffer_count: None } }
142    /// Estimate from the two essentials: typical peak memory + wall time.
143    /// Buffer count left `None`; refine with
144    /// [`with_intermediate_buffer_count`](Self::with_intermediate_buffer_count).
145    #[must_use] pub fn new(peak_memory_bytes_est: u64, wall_ms: u64) -> Self {
146        Self { peak_memory_bytes_est: Some(peak_memory_bytes_est), wall_ms: Some(wall_ms), intermediate_buffer_count: None }
147    }
148    /// Full-image intermediate buffers held simultaneously, NOT counting input
149    /// or output. Distinguishes 1-giant-buffer from N-medium-buffer plans for
150    /// paging-pressure decisions.
151    #[must_use] pub fn with_intermediate_buffer_count(mut self, n: u32) -> Self { self.intermediate_buffer_count = Some(n); self }
152    /// Typical (≈ p50) estimated peak memory, bytes.
153    #[must_use] pub fn peak_memory_bytes_est(&self) -> Option<u64> { self.peak_memory_bytes_est }
154    /// Predicted **wall-clock** ms. Already scaled to
155    /// [`ComputeEnvironment::cores`] when produced by
156    /// [`ConvertPlan::estimate_in`](crate::ConvertPlan::estimate_in).
157    /// Rounded **up**, so sub-millisecond work reports `Some(1)` and
158    /// `Some(0)` means genuinely zero work (empty plan / zero pixels) —
159    /// schedulers summing many small conversions overestimate slightly
160    /// rather than seeing a stream of zeros.
161    #[must_use] pub fn wall_ms(&self) -> Option<u64> { self.wall_ms }
162    /// Full-image intermediate buffers held simultaneously (input/output
163    /// excluded). `None` when the planner can't determine it.
164    #[must_use] pub fn intermediate_buffer_count(&self) -> Option<u32> { self.intermediate_buffer_count }
165}
166
167// Per-step calibration: ns/MP at 4096-pixel rows, AVX2/V3, Ryzen 9 7950X,
168// from the 2026-04-23 bench suite under `zenpixels/benchmarks/`. See
169// docs/ESTIMATE.md for the per-file list.
170
171const ONE_MP: f64 = 1_048_576.0;
172const GIB: f64 = 1_073_741_824.0;
173
174/// `ns/MP = MP * bpp * 1e9 / (throughput_gib_s * GIB)`.
175const fn gib_to_ns_per_mp(throughput_gib_s: f64, bytes_per_pixel: f64) -> f64 {
176    bytes_per_pixel * ONE_MP * 1.0e9 / (throughput_gib_s * GIB)
177}
178
179/// Per-megapixel cost (ns) for a [`ConvertStep`], multiplied by
180/// `pixels / 1 MP` for the runtime contribution. Bench values from the
181/// 2026-04-23 suite (HDR tone-map: 2026-06-20); see `docs/ESTIMATE.md` for
182/// per-file sources. `gib(g)` returns the GiB/s→ns/MP cost at the step's
183/// source bpp; `bucketed` picks a per-bpp throughput with a fallback.
184#[rustfmt::skip]
185fn step_cost_ns_per_mp(step: &ConvertStep, current_bpp: usize) -> f64 {
186    let bpp = current_bpp as f64;
187    let gib = |g: f64| gib_to_ns_per_mp(g, bpp);
188    let bucketed = |bs: &[(usize, f64)], fallback: f64| -> f64 {
189        gib(bs.iter().copied().find_map(|(b, g)| (b == current_bpp).then_some(g)).unwrap_or(fallback))
190    };
191    match step {
192        ConvertStep::Identity => 0.0,
193        // Layout (t1).
194        ConvertStep::SwizzleBgraRgba => bucketed(&[(4, 116.42)], 75.0),
195        ConvertStep::RgbToBgra => gib(80.0),
196        ConvertStep::AddAlpha => bucketed(&[(3, 125.06), (6, 40.59), (12, 104.01)], 30.0),
197        ConvertStep::DropAlpha => bucketed(&[(4, 95.90), (8, 133.63), (16, 148.81)], 80.0),
198        ConvertStep::MatteComposite { .. } => gib(5.0), // EOTF-blend-OETF, 3-8 GiB/s by TF
199        ConvertStep::GrayToRgb => bucketed(&[(1, 12.85)], 60.0),
200        ConvertStep::GrayToRgba => gib(8.6),
201        ConvertStep::RgbToGray { .. } => gib(12.0),
202        ConvertStep::RgbaToGray { .. } => gib(10.0),
203        ConvertStep::GrayAlphaToRgba => bucketed(&[(2, 95.30), (4, 119.80), (8, 149.72)], 60.0),
204        ConvertStep::GrayAlphaToRgb | ConvertStep::GrayAlphaToGray => gib(80.0),
205        ConvertStep::GrayToGrayAlpha => gib(100.0),
206        // Depth (t2). 4096-row, RGB.
207        ConvertStep::U8ToU16 => gib(112.82),
208        ConvertStep::U16ToU8 => gib(34.39),
209        ConvertStep::NaiveU8ToF32 => gib(95.21),
210        ConvertStep::NaiveF32ToU8 => gib(52.99),
211        ConvertStep::U16ToF32 => gib(88.68),
212        ConvertStep::F32ToU16 => gib(64.33),
213        ConvertStep::F16ToF32 => gib(7.09),
214        ConvertStep::F32ToF16 => gib(3.25),
215        // Transfer functions (t3 fused, t4 f32). 4096-row, RGB.
216        ConvertStep::SrgbU8ToLinearF32 => gib(24.26),
217        ConvertStep::LinearF32ToSrgbU8 => gib(4.56),
218        ConvertStep::PqU16ToLinearF32 => gib(2.68),
219        ConvertStep::LinearF32ToPqU16 => gib(1.39),
220        ConvertStep::HlgU16ToLinearF32 => gib(6.16),
221        ConvertStep::LinearF32ToHlgU16 => gib(4.44),
222        ConvertStep::PqF32ToLinearF32 => gib(3.0),
223        ConvertStep::LinearF32ToPqF32 => gib(2.72),
224        ConvertStep::HlgF32ToLinearF32 => gib(6.0),
225        ConvertStep::LinearF32ToHlgF32 => gib(4.0),
226        ConvertStep::SrgbF32ToLinearF32 | ConvertStep::SrgbF32ToLinearF32Extended => gib(24.95),
227        ConvertStep::LinearF32ToSrgbF32 | ConvertStep::LinearF32ToSrgbF32Extended => gib(8.0),
228        ConvertStep::Bt709F32ToLinearF32 | ConvertStep::Gamma22F32ToLinearF32 => gib(6.0),
229        ConvertStep::LinearF32ToBt709F32 | ConvertStep::LinearF32ToGamma22F32 => gib(4.5),
230        // Alpha mode (t5). f32 4-channel.
231        ConvertStep::StraightToPremul => gib(13.74),
232        ConvertStep::PremulToStraight => gib(7.51), // divide is slow
233        // Oklab (t6). cbrt forward, cubed inverse.
234        ConvertStep::LinearRgbToOklab => gib(1.61),
235        ConvertStep::OklabToLinearRgb => gib(53.25),
236        ConvertStep::LinearRgbaToOklaba => gib(2.14),
237        ConvertStep::OklabaToLinearRgba => gib(58.91),
238        // Gamut matrices (t7). 3×3 linear-F32 matmul.
239        ConvertStep::GamutMatrixRgbF32(_) => gib(21.84),
240        ConvertStep::GamutMatrixRgbaF32(_) => gib(20.0),
241        ConvertStep::Fused { kind, .. } => match kind {
242            FusedKind::SrgbU8GamutRgb => gib(3.79),
243            FusedKind::SrgbU8GamutRgba => gib(3.5),
244            FusedKind::SrgbU16GamutRgb => gib(5.84),
245            FusedKind::SrgbU8ToLinearF32Rgb => gib(11.19),
246            FusedKind::LinearF32ToSrgbU8Rgb => gib(3.11),
247        },
248        // HDR. BT.2446-A: ~250 Mpix/s on RGB f32 linear-light (2026-06-20).
249        // 1 MP / 250 Mpix/s = 4.194 ms/MP.
250        #[cfg(feature = "hdr-experimental")]
251        ConvertStep::ToneMapBt2446A { .. } => 4_194_304.0,
252        // Hue-preserving rational knee in OKLch; ~3 GiB/s (cbrt-dominated).
253        #[cfg(feature = "hdr-experimental")]
254        ConvertStep::SoftCompressOklch { .. } => gib(3.0),
255    }
256}
257
258/// Every row-stride SIMD kernel is parallel. HDR steps (BT.2446-A, OKLch
259/// soft-compress) read per-image scalars and are scheduled serially. Bias for
260/// ambiguous future steps: SERIAL (over-estimate wall time).
261#[rustfmt::skip]
262fn step_is_parallelizable(step: &ConvertStep) -> bool {
263    #[cfg(feature = "hdr-experimental")]
264    if matches!(step, ConvertStep::ToneMapBt2446A { .. } | ConvertStep::SoftCompressOklch { .. }) {
265        return false;
266    }
267    let _ = step;
268    true
269}
270
271/// Wall-time multiplier on the AVX2 (`X86V3`) calibration baseline. Coarse
272/// "Δkernel" estimates pending a per-tier sweep. Absent hint + future
273/// unhandled tiers fall back to baseline (1.0).
274#[rustfmt::skip]
275fn simd_tier_multiplier(tier: SimdTier) -> f64 {
276    match tier {
277        SimdTier::X86V4 => 0.85,                                                              // AVX-512
278        SimdTier::X86V2 | SimdTier::X86V1 => 1.4,                                             // SSE2/SSE4.2
279        SimdTier::Wasm128 => 1.3,
280        SimdTier::Wasm => 2.0,
281        SimdTier::X86V3 | SimdTier::Neon | SimdTier::Unknown | SimdTier::CurrentHost => 1.0,
282    }
283}
284
285/// Walk the plan once, summing time and tracking peak + live intermediate-
286/// buffer counts. Memory + threading model: `docs/ESTIMATE.md`.
287//
288// TODO: per-tier calibration tables once the t-series bench sweep re-runs on
289// AVX-512 / SSE / NEON / WASM hosts.
290#[rustfmt::skip]
291pub(crate) fn estimate_plan(plan: &ConvertPlan, image: &ImageCharacteristics, compute: &ComputeEnvironment) -> ResourceEstimate {
292    let (width, height) = (image.width(), image.height());
293    let tier_mul = compute.simd_tier().map(simd_tier_multiplier).unwrap_or(1.0);
294    let pixels = u64::from(width) * u64::from(height);
295    let dst_bytes = pixels * plan.to().bytes_per_pixel() as u64;
296    // Identity: memcpy-only ~30 GB/s midpoint, SERIAL, no scratch.
297    // `ceil` (here and below): sub-ms work must report 1, not truncate to
298    // 0 — see the `wall_ms` accessor doc.
299    if plan.is_identity() {
300        let ms = (dst_bytes as f64) / (30.0 * GIB) * 1_000.0 * tier_mul;
301        return finalize(dst_bytes, ms.ceil() as u64, 1, 0, compute);
302    }
303    let pixels_mp = (pixels as f64) / ONE_MP;
304    // Multi-step: 2 ping-pong row halves sized to the widest intermediate bpp.
305    // Single-step: kernel writes src→dst directly (no scratch).
306    let multi = plan.steps().len() > 1;
307    let (mut max_bpp, mut desc) = (plan.from().bytes_per_pixel(), plan.from());
308    // Bottleneck: SERIAL step → 1 thread; else min knee (rows/64 ∈ [1,16]).
309    let knee = (u64::from(height) / 64).clamp(1, 16) as u32;
310    let (mut total_time_ms, mut any_serial, mut min_knee) = (0.0_f64, false, u32::MAX);
311    for step in plan.steps() {
312        total_time_ms += step_cost_ns_per_mp(step, desc.bytes_per_pixel()) * pixels_mp / 1e6;
313        if step_is_parallelizable(step) { min_knee = min_knee.min(knee); } else { any_serial = true; }
314        desc = intermediate_after(desc, step);
315        max_bpp = max_bpp.max(desc.bytes_per_pixel());
316    }
317    let scratch_bytes = if multi { (u64::from(width) * max_bpp as u64).saturating_mul(2) } else { 0 };
318    let buffer_count: u32 = if multi { 2 } else { 0 };
319    let bottleneck = if any_serial || min_knee == u32::MAX { 1 } else { min_knee };
320    finalize(dst_bytes.saturating_add(scratch_bytes), (total_time_ms * tier_mul).ceil() as u64, bottleneck, buffer_count, compute)
321}
322
323/// Divide single-thread wall by `min(cores, bottleneck)` + attach buffer count.
324/// `div_ceil` keeps the round-up contract: nonzero work never scales to 0.
325#[rustfmt::skip]
326fn finalize(peak: u64, wall_st: u64, bottleneck: u32, buffers: u32, compute: &ComputeEnvironment) -> ResourceEstimate {
327    let eff = (compute.cores() as u64).max(1).min(bottleneck.max(1) as u64);
328    ResourceEstimate::new(peak, wall_st.div_ceil(eff)).with_intermediate_buffer_count(buffers)
329}
330
331/// Re-call of `crate::convert::intermediate_desc_for_estimate` to keep the
332/// two helpers from drifting.
333fn intermediate_after(current: PixelDescriptor, step: &ConvertStep) -> PixelDescriptor {
334    crate::convert::intermediate_desc_for_estimate(current, step)
335}
336
337#[cfg(test)]
338#[rustfmt::skip]
339mod local_type_contract_tests {
340    //! Mirrors `zencodec::estimate` contract tests so shape drift trips a
341    //! unit test, not a downstream compile failure.
342    use super::*;
343    const D: PixelDescriptor = PixelDescriptor::RGB8_SRGB;
344
345    #[test]
346    fn compute_environment_builder_clamps_and_defaults() {
347        assert_eq!(ComputeEnvironment::new().cores(), 1);
348        assert_eq!(ComputeEnvironment::new().with_cores(0).cores(), 1);
349        assert_eq!(ComputeEnvironment::default().with_cores(16).cores(), 16);
350        let e = ComputeEnvironment::new().with_available_ram_bytes(1 << 30);
351        assert_eq!(e.available_ram_bytes(), Some(1 << 30));
352        assert_eq!(ComputeEnvironment::new().simd_tier(), None);
353        let t = ComputeEnvironment::new().with_simd_tier(SimdTier::X86V3);
354        assert_eq!(t.simd_tier(), Some(SimdTier::X86V3));
355    }
356    #[test]
357    fn image_characteristics_fields() {
358        let im = ImageCharacteristics::new(1024, 768, D);
359        assert_eq!((im.width(), im.height(), *im.descriptor()), (1024, 768, D));
360    }
361    #[test]
362    fn resource_estimate_new_unknown_and_buffer_count() {
363        let est = ResourceEstimate::new(200, 1000);
364        assert_eq!(est.peak_memory_bytes_est(), Some(200));
365        assert_eq!(est.wall_ms(), Some(1000));
366        assert_eq!(est.intermediate_buffer_count(), None);
367        let u = ResourceEstimate::unknown();
368        assert_eq!(u.peak_memory_bytes_est(), None);
369        assert_eq!(u.wall_ms(), None);
370        assert_eq!(u.intermediate_buffer_count(), None);
371        let withbuf = ResourceEstimate::new(200, 1000).with_intermediate_buffer_count(2);
372        assert_eq!(withbuf.intermediate_buffer_count(), Some(2));
373    }
374}